My Downloads folder is where files go to disappear. Installers from three years ago, screenshots named screenshot_0143.png, PDFs I still need, one .csv I exported once and never found again. Search never helps, because I can't remember names — only types.
So I wrote a small script that sorts everything in Downloads into subfolders by extension: pdfs/, images/, archives/, data/. It's a cousin of the monthly PDF cleanup script from my pathlib post, but general-purpose — and writing it taught me two habits I now put into every script that moves files: a dry-run flag, and a plan for name collisions.
from pathlib import Path
import shutil
FOLDERS = {
".pdf": "pdfs",
".png": "images",
".jpg": "images",
".jpeg": "images",
".zip": "archives",
".csv": "data",
}
DRY_RUN = True
downloads = Path.home() / "Downloads"
for f in downloads.iterdir():
if f.is_dir():
continue
folder = FOLDERS.get(f.suffix.lower(), "other")
dest_dir = downloads / folder
verb = "would move" if DRY_RUN else "moving"
print(f"{verb}: {f.name} -> {folder}")
if DRY_RUN:
continue
dest_dir.mkdir(exist_ok=True)
dest = dest_dir / f.name
n = 1
while dest.exists():
dest = dest_dir / f"{f.stem}_{n}{f.suffix}"
n += 1
shutil.move(f, dest)
Files with no extension, or an extension I never listed, land in other/ — which is exactly the bucket I want to see grow. It means my mapping needs another entry, not that the script is wrong. The dict is the whole configuration: adding .md files is one line, ".md": "notes".
One deliberate omission: .exe and .dmg installers aren't in the mapping at all. I tried sorting them into an installers/ folder and discovered the honest truth about installers — I never want an old one, I want them gone. Now they fall into other/, the dry run lists them, and I delete them on the spot.
The first version moved files the moment I ran it, and I spent an evening digging things back out of the wrong folders. Now the script starts with DRY_RUN = True and only prints what it would do:
would move: flight_confirmation.pdf -> pdfs
would move: screenshot_0143.png -> images
I let it run like that for a week, read the output, added the two extensions I'd missed, and only then flipped the flag. I'd call that paranoia, except for the time I skipped it and renamed sixty files straight into a folder that didn't exist yet. A dry run is not paranoia; it's the difference between a cleanup script and a data-loss incident.
The while loop handles collisions. If report.pdf already exists in the target folder, the script tries report_1.pdf, then report_2.pdf, and keeps going until it finds a free name. Nothing gets overwritten, ever — your browser does the same thing when you download the same file twice.
As for shutil.move() instead of rename(): inside Downloads either would work, but rename() fails when source and destination sit on different drives, and shutil.move() just handles it. One less thing to think about on the day I point the script at a second disk.