Apply a daily, weekly and monthly backup retention policy
Problem statement
A backup directory contains db-YYYYMMDD-HHMM.tar.gz files, created nightly plus the odd manual run. Implement a grandfather-father-son retention policy and print, newest first, which files to keep and why, and which to delete:
- daily 3: keep the newest backup of each of the 3 most recent days that have a backup.
- weekly 3: keep the newest backup of each of the 3 most recent ISO weeks that have a backup.
- monthly 3: keep the newest backup of each of the 3 most recent calendar months that have a backup.
A file is kept if any rule keeps it. Everything else is deleted. Files whose name does not match the pattern, or whose date is invalid, are never touched; list them as ignored. It is a dry run by default. As a safety net, refuse to run if the policy would delete every backup.
Directory listing (dates in September 2026: the 24th is a Thursday, the 21st, 14th and 7th are Mondays)
db-20260924-0200.tar.gzdb-20260923-1415.tar.gz (manual run)db-20260923-0200.tar.gzdb-20260922-0200.tar.gzdb-20260921-0200.tar.gzdb-20260914-0200.tar.gzdb-20260907-0200.tar.gzdb-20260831-0200.tar.gzdb-20260801-0200.tar.gzdb-20260715-0200.tar.gzdb-20260601-0200.tar.gzdb-latest.tar.gznotes.txtExamples
Example 1
Input: `python solution.py` (the demo creates these files in a temporary directory and runs a dry run)
Output: KEEP db-20260924-0200.tar.gz daily, weekly, monthly
KEEP db-20260923-1415.tar.gz daily
DELETE db-20260923-0200.tar.gz
KEEP db-20260922-0200.tar.gz daily
DELETE db-20260921-0200.tar.gz
KEEP db-20260914-0200.tar.gz weekly
KEEP db-20260907-0200.tar.gz weekly
KEEP db-20260831-0200.tar.gz monthly
DELETE db-20260801-0200.tar.gz
KEEP db-20260715-0200.tar.gz monthly
DELETE db-20260601-0200.tar.gz
ignored (name does not match): db-latest.tar.gz, notes.txt
Explanation: Daily keeps the 24th, the 23rd (its newest run, 14:15) and the 22nd. The 21st to 24th are one ISO week, already covered by the 24th, so weekly then takes the 14th and the 7th. Monthly takes the newest in September (24th), August (31st) and July (15th).
Hints
Approach
Optimal
Parse strictly, sort once, then run each rule as a bucket scan.
parseaccepts only names thatfullmatchthe pattern and whose timestamp passesstrptime, sodb-20260231-...(no 31 February) is ignored rather than guessed. Sort(timestamp, name)newest first.- For each rule, walk the sorted list keeping a set of buckets seen. The first backup met in a new bucket is that bucket's newest: add the rule name to its reasons. Stop when the rule has filled its count.
- A backup with no reasons is deleted.
applyrefuses to proceed if every backup would be deleted, prints the plan, and callsos.removeonly withdry_run=False.
b backups, r rules. Keeping reasons per file makes the output auditable: you can see why a file survived, which is the first question anyone asks when a retention policy surprises them.
O(b log b + r * b)Space O(b)import osimport reimport sysimport tempfilefrom datetime import datetimefrom pathlib import Path NAME = re.compile(r"db-(\d{8}-\d{4})\.tar\.gz") RULES = { # rule -> function giving the bucket a backup falls into "daily": lambda ts: ts.date(), "weekly": lambda ts: ts.isocalendar()[:2], # (ISO year, ISO week) "monthly": lambda ts: (ts.year, ts.month),} def parse(names): backups, ignored = [], [] for name in names: m = NAME.fullmatch(name) try: ts = datetime.strptime(m[1], "%Y%m%d-%H%M") if m else None except ValueError: # e.g. 20260231 ts = None (backups.append((ts, name)) if ts else ignored.append(name)) backups.sort(reverse=True) # newest first return backups, sorted(ignored) def plan(names, keep): """keep = {"daily": 3, ...}. Returns ([(name, [reasons])] newest first, ignored).""" backups, ignored = parse(names) reasons = {name: [] for _, name in backups} for rule, count in keep.items(): bucket_of = RULES[rule] seen = set() for ts, name in backups: # newest first, so the first hit per bucket is its newest if len(seen) >= count: break bucket = bucket_of(ts) if bucket not in seen: seen.add(bucket) reasons[name].append(rule) return [(name, reasons[name]) for _, name in backups], ignored def apply(directory, keep, dry_run=True): names = [p.name for p in Path(directory).iterdir() if p.is_file()] decisions, ignored = plan(names, keep) if decisions and not any(r for _, r in decisions): sys.exit("refusing to delete every backup; check the policy") # safety net for name, why in decisions: if why: print(f"KEEP {name} {', '.join(why)}") else: print(f"DELETE {name}") if not dry_run: os.remove(Path(directory, name)) if ignored: print("ignored (name does not match):", ", ".join(ignored)) SAMPLE = [ "db-20260924-0200.tar.gz", "db-20260923-1415.tar.gz", "db-20260923-0200.tar.gz", "db-20260922-0200.tar.gz", "db-20260921-0200.tar.gz", "db-20260914-0200.tar.gz", "db-20260907-0200.tar.gz", "db-20260831-0200.tar.gz", "db-20260801-0200.tar.gz", "db-20260715-0200.tar.gz", "db-20260601-0200.tar.gz", "db-latest.tar.gz", "notes.txt",] if __name__ == "__main__": policy = {"daily": 3, "weekly": 3, "monthly": 3} if len(sys.argv) > 1: apply(sys.argv[1], policy, dry_run="--apply" not in sys.argv) else: with tempfile.TemporaryDirectory() as tmp: for name in SAMPLE: Path(tmp, name).touch() apply(tmp, policy) # dry run: prints the plan, deletes nothingFollow-up questions
- Add a yearly rule, and a
keep-last Nrule that ignores buckets. - The backups are objects in S3 with a
LastModifiedtime instead of dated names. What changes? (Listing is paginated, and deletes are batched.) - How would you test the policy across a year boundary and a leap day without waiting for real backups?
Frequently asked questions
With "the last 3 calendar days", a week-long backup outage followed by a cleanup run would keep nothing from the daily rule. Counting buckets that actually contain backups, which is how tools such as restic's forget --keep-daily behave, always keeps the most recent N. It fails safe when backups have stopped, which is exactly when you need the old ones.
Every backup system needs retention, and it is a classic source of off-by-one bugs, time zone bugs and year-boundary bugs that surface months later as "why is there no backup from last month?". Interviewers look for bucket logic, strict parsing, a dry run and a refusal to delete everything.
Yes. A backup at 00:30 local time might be the previous day in UTC, which changes which daily bucket it falls into. Pick one zone, preferably UTC, write it into the file names, and bucket in that zone. This script treats the timestamps in names as naive and consistent, and that assumption should be stated.