Introduction
Everyone knows they should back up; nobody does, because manual copying is tedious. This script fixes that: point it at a source folder and a destination, and it performs an incremental backup — copying only new and changed files, skipping identical ones, and pruning backups older than your retention window. Schedule it with cron or Task Scheduler and backups stop being a chore.
It is the file-management heavyweight sibling of the file organizer: that one tidies, this one protects.
Features
- Incremental sync — only copies new or modified files (size + mtime check).
- Timestamped snapshots — each backup is a dated folder.
- Retention pruning — keep the last N backups, delete the rest.
- Dry-run mode — preview every action before it happens.
- Exclusion patterns — skip
node_modules,.git, temp files.
Prerequisites
- Python 3.8+ — everything is built in (
pathlib,shutil, no dependencies).
Step 1: Create the Script
Save as backup.py:
import sys
import shutil
from pathlib import Path
from datetime import datetime, timedelta
EXCLUDE = {".git", "node_modules", "__pycache__", ".venv", "venv"}
def should_copy(src: Path, dst: Path) -> bool:
if not dst.exists():
return True
return src.stat().st_mtime > dst.stat().st_mtime or src.stat().st_size != dst.stat().st_size
def backup(source: Path, dest_root: Path, keep: int = 5, dry_run: bool = False):
if not source.is_dir():
sys.exit(f"Source folder not found: {source}")
stamp = datetime.now().strftime("%Y-%m-%d_%H%M")
dest = dest_root / stamp
print(f"{'[DRY RUN] ' if dry_run else ''}Backup: {source} -> {dest}")
copied = skipped = 0
for src_file in source.rglob("*"):
if any(part in EXCLUDE for part in src_file.parts):
continue
if not src_file.is_file():
continue
rel = src_file.relative_to(source)
dst_file = dest / rel
if should_copy(src_file, dst_file):
print(f" + {rel}")
if not dry_run:
dst_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dst_file) # copy2 preserves mtime
copied += 1
else:
skipped += 1
print(f"\n{copied} copied, {skipped} unchanged")
if dry_run:
return
# Retention: keep newest N backup folders
backups = sorted(d for d in dest_root.iterdir() if d.is_dir())
for old in backups[:-keep]:
print(f"Pruning old backup: {old.name}")
shutil.rmtree(old)
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit(f"Usage: python {sys.argv[0]} <source> <dest> [--keep N] [--dry-run]")
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
keep = int(sys.argv[sys.argv.index("--keep") + 1]) if "--keep" in sys.argv else 5
backup(src, dst, keep=keep, dry_run="--dry-run" in sys.argv)
Step 2: Run the Backup
python backup.py ~/Documents ~/Backups --keep 7 --dry-run
python backup.py ~/Documents ~/Backups --keep 7
Run it twice in a row — the second run copies nothing, which is the incremental promise.
How It Works
The change detection is deliberately simple: a file needs copying if it doesn’t exist at the destination, or its modification time or size differs. That misses exotic changes (same size, same mtime, different content — vanishingly rare) but avoids hashing gigabytes on every run. copy2 preserves the source mtime, which keeps the comparison stable across runs — copy would reset timestamps and break incrementality.
Snapshot folders (each backup in a dated directory) trade disk space for simplicity and safety: restoring is a plain folder copy, deleting a snapshot can’t corrupt others, and there’s no archive format to get wrong. For personal backups this beats incremental archive formats that need special tools to restore.
Retention pruning sorts backup folders by name — the timestamp format %Y-%m-%d_%H%M sorts chronologically as text — and deletes everything beyond the newest N. The lexicographic-sort trick only works because the format is zero-padded and year-first; use any other date order and pruning deletes the wrong backups.
The exclude list prevents the classic disaster of backing up node_modules — 40,000 files of pure noise — plus git internals and virtualenvs.
Common Errors & Fixes
- Everything copies every run — you used
shutil.copyinstead ofcopy2, so destination mtimes reset and always look “older” than source. PermissionErroron system files — locked or protected files; wrap the copy in try/except, log the skip, continue.- Pruning deleted the wrong folder — your backup folder names don’t sort chronologically; keep the zero-padded
%Y-%m-%dformat. - Long paths fail on Windows — enable long-path support or map the drive closer to the root.
Key Concepts
- Incremental detection — mtime + size as a cheap change oracle.
- Snapshot backups — dated folders over clever archive formats.
- Chronological-by-name — zero-padded timestamps make sorting free.
- Dry-run first — every destructive tool deserves a preview mode.
What to Try Next
- Add hash verification (MD5) for files where mtime lies — cloud-synced folders.
- Add zip mode — compress each snapshot with
shutil.make_archive. - Add email notification on completion via the email automation mailer.
- Schedule it: cron (
0 2 * * *) on your home server or Task Scheduler on Windows.
FAQ
Is folder-snapshot backup better than a zip archive?
For restore speed and partial recovery, yes — you browse and copy instead of extracting. Zips save space; run both if storage is cheap and data is precious.
How is this different from cloud sync?
Cloud sync mirrors one state; snapshots keep history — you can recover last Tuesday’s version of a file you overwrote today. The best setups use both.
Should the destination be an external drive?
Absolutely — a backup on the same disk as the original survives software mistakes, not hardware failure. Point the destination at an external drive or network share.