Practical infra coding

Find files not modified in N days

easyFiles and filesystem

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 fixed 2026-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)

TEXT
backups/db-2026-08-01.tar.gz 4096 bytes 54 days
backups/db-2026-09-20.tar.gz 4096 bytes 4 days
tmp/build-cache.bin 2048 bytes 31 days
tmp/session.lock 16 bytes 0.5 days
reports/q2.csv 1024 bytes 90 days

Use 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.

  1. Fix now and compute the cutoff once.
  2. Walk with os.walk, reporting unreadable directories through onerror. lstat each file and skip non-regular files. A file that vanished mid-walk raises OSError and is skipped.
  3. Skip and warn about future mtimes.
  4. Keep (age_days, size, path) for files at or before the cutoff. int((now - mtime) // 86400) gives whole days.
  5. Sort by age descending, then path.

Only matching files (s) are kept in memory, never the whole tree.

ComplexityTime O(f + s log s)Space O(s + d)
Python
import os
import stat
import sys
import tempfile
import time
from datetime import datetime, timezone
from 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 reproducible

Follow-up questions

  • Add --delete that removes the listed files, and then removes directories left empty.
  • The same as find DIR -type f -mtime +29. Why +29 and not +30? (find rounds the age down to whole days before comparing.)
  • Exclude paths matching patterns from a .cleanupignore file.

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.