Find files not modified in N days
Problem statement
Before a cleanup job deletes anything, you want a report. List every regular file under a directory whose modification time is at least N days old, oldest first, with its age in whole days. End with the count and total size.
- Age is measured from a reference time
now. Make it a parameter so the function can be tested; the demo uses a fixed2026-09-24 12:00 UTC. - A file dated in the future (clock skew, or an archive extracted with odd timestamps) is not old. Skip it with a warning.
- Do not follow symlinks. Keep walking past unreadable directories.
Sample tree (with modification ages in days relative to now)
backups/db-2026-08-01.tar.gz 4096 bytes 54 daysbackups/db-2026-09-20.tar.gz 4096 bytes 4 daystmp/build-cache.bin 2048 bytes 31 daystmp/session.lock 16 bytes 0.5 daysreports/q2.csv 1024 bytes 90 daysUse N = 30.
Examples
Example 1
Input: `python solution.py` (the demo builds the tree above in a temporary directory)
Output: 90d reports/q2.csv
54d backups/db-2026-08-01.tar.gz
31d tmp/build-cache.bin
3 file(s), 7.0 KiB, not modified for 30+ days
Explanation: 1024 + 4096 + 2048 = 7168 bytes = 7.0 KiB. A file exactly 30 days old would also be listed, since the rule is at least N days.
Hints
Approach
Optimal
A walk with a single comparison per file.
- Fix
nowand compute the cutoff once. - Walk with
os.walk, reporting unreadable directories throughonerror.lstateach file and skip non-regular files. A file that vanished mid-walk raisesOSErrorand is skipped. - Skip and warn about future
mtimes. - Keep
(age_days, size, path)for files at or before the cutoff.int((now - mtime) // 86400)gives whole days. - Sort by age descending, then path.
Only matching files (s) are kept in memory, never the whole tree.
O(f + s log s)Space O(s + d)import osimport statimport sysimport tempfileimport timefrom datetime import datetime, timezonefrom pathlib import Path DAY = 86400 def human(n): for unit in ("B", "KiB", "MiB", "GiB"): if n < 1024 or unit == "GiB": return f"{n} {unit}" if unit == "B" else f"{n:.1f} {unit}" n /= 1024 def stale_files(root, days, now=None): """Regular files whose mtime is at least `days` days before `now`.""" now = time.time() if now is None else now cutoff = now - days * DAY found = [] for dirpath, _dirs, files in os.walk(root, onerror=lambda e: print(f"warning: {e}", file=sys.stderr)): for name in files: path = os.path.join(dirpath, name) try: st = os.lstat(path) except OSError: continue # removed while we were walking if not stat.S_ISREG(st.st_mode): continue if st.st_mtime > now: print(f"warning: {path} is dated in the future, skipped", file=sys.stderr) continue if st.st_mtime <= cutoff: found.append((int((now - st.st_mtime) // DAY), st.st_size, path)) found.sort(key=lambda f: (-f[0], f[2])) # oldest first return found def report(root, days, now=None): found = stale_files(root, days, now) for age, _size, path in found: print(f"{f'{age}d':<5}{Path(path).relative_to(root).as_posix()}") total = sum(size for _, size, _ in found) print(f"{len(found)} file(s), {human(total)}, not modified for {days:g}+ days") NOW = datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc).timestamp()SAMPLE = { # path -> (size in bytes, age in days) "backups/db-2026-08-01.tar.gz": (4096, 54), "backups/db-2026-09-20.tar.gz": (4096, 4), "tmp/build-cache.bin": (2048, 31), "tmp/session.lock": (16, 0.5), "reports/q2.csv": (1024, 90),} def build_sample(base): for rel, (size, age_days) in SAMPLE.items(): p = Path(base, rel) p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(b"x" * size) mtime = NOW - age_days * DAY os.utime(p, (mtime, mtime)) if __name__ == "__main__": if len(sys.argv) > 2: report(sys.argv[1], float(sys.argv[2])) else: with tempfile.TemporaryDirectory() as tmp: build_sample(tmp) report(tmp, 30, now=NOW) # fixed "now" so the output is reproducibleFollow-up questions
- Add
--deletethat removes the listed files, and then removes directories left empty. - The same as
find DIR -type f -mtime +29. Why+29and not+30? (find rounds the age down to whole days before comparing.) - Exclude paths matching patterns from a
.cleanupignorefile.
Frequently asked questions
mtime changes when the content is written, which is usually what "not used" means for logs and build output. atime is often not updated: many systems mount with noatime or relatime for performance. ctime is the inode change time on Linux, not the creation time; it changes on chmod, so it is not a reliable age.
Retention cleanups (old logs, build caches, temp uploads) are among the most common scripts infra engineers write, and the most dangerous. The interviewer wants a dry-run report before any deletion, deterministic time handling, and no symlink following, because a cleanup that follows a link out of its directory deletes the wrong things.