Demo: Python script that sorts a messy Downloads folder by Prabhudutt MishraDemo: Python script that sorts a messy Downloads folder by Prabhudutt Mishra

Demo: Python script that sorts a messy Downloads folder

Prabhudutt Mishra

Prabhudutt Mishra

This is my own demo with sample data, not client work. The input is a made-up Downloads folder: 21 loose dummy files with set dates, plus 2 files already sorted by an earlier tidy.
The Python script reads each file's extension and last-modified date. It moves the file into PDFs, Images, Excel or Others, then into a year and month folder such as 2026-10. If a name is already taken, it adds " (2)" to the new file, so nothing is overwritten. It leaves unfinished downloads alone. A dry run prints the plan and moves nothing. The real run adds one row to a CSV log for every move.
In the run, the dry run planned 20 moves: 5 PDFs, 5 images, 4 Excel or CSV files and 6 others. The real run made the same 20 moves. Two names clashed, so screenshot.png and invoice_0042.pdf came in as screenshot (2).png and invoice_0042 (2).pdf, and the files already there kept their names. The file video_call.mp4.crdownload stayed where it was. The folder held 23 files before, and the same 23 plus the log after. A second dry run found nothing left to move.
One limitation: the month comes from the last-modified date. For a download that is usually the day it was downloaded, not the date on the invoice or the day a photo was taken.
I used AI tools to speed up the routine parts of the coding, then ran the script on the sample data and checked the result.
The script is below.
"""Tidy a messy Downloads-style folder.

Each loose file in FOLDER moves to FOLDER/<type>/<year-month>/<name>, where type
is PDFs, Images, Excel or Others (from the extension) and year-month comes from
the file's last-modified date. A name that is already taken gets " (2)", " (3)"
added, so nothing is overwritten. Subfolders and unfinished downloads are left
alone. Each move is added to FOLDER/move_log.csv right after it happens.

Run: python organize_files.py FOLDER --dry-run (print the plan, move nothing)
python organize_files.py FOLDER (move the files, write the log)
"""
import argparse
import csv
import shutil
from collections import Counter
from datetime import datetime
from pathlib import Path

TYPES = {
"PDFs": {".pdf"},
"Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"},
"Excel": {".xlsx", ".xls", ".xlsm", ".csv"},
}
LOG_NAME = "move_log.csv"
SKIP_NAMES = {LOG_NAME, "desktop.ini", "thumbs.db"}
UNFINISHED = (".crdownload", ".part", ".tmp")


def file_type(path):
extension = path.suffix.lower()
return next((name for name, exts in TYPES.items() if extension in exts), "Others")


def free_name(wanted, taken):
"""Return wanted, or 'name (2).ext', 'name (3).ext' ... if that name is in use."""
candidate, number = wanted, 1
while candidate.exists() or candidate in taken:
number += 1
candidate = wanted.with_name(f"{wanted.stem} ({number}){wanted.suffix}")
return candidate


def make_plan(folder):
"""Return (moves, skipped). Each move is (source, destination, renamed)."""
moves, skipped, taken = [], [], set()
for source in sorted((p for p in folder.iterdir() if p.is_file()), key=lambda p: p.name.lower()):
if source.name.lower() in SKIP_NAMES or source.name.lower().endswith(UNFINISHED):
skipped.append(source.name)
continue
month = datetime.fromtimestamp(source.stat().st_mtime).strftime("%Y-%m")
wanted = folder / file_type(source) / month / source.name
destination = free_name(wanted, taken)
taken.add(destination)
moves.append((source, destination, destination != wanted))
return moves, skipped


def move_files(moves, folder):
"""Move each file and write its log row right after the move."""
log_path = folder / LOG_NAME
new_log = not log_path.exists()
with open(log_path, "a", newline="", encoding="utf-8") as log:
writer = csv.writer(log)
if new_log:
writer.writerow(["moved_at", "from", "to", "renamed"])
for source, destination, renamed in moves:
if destination.exists(): # last check just before the move
raise SystemExit(f"Stopped: {destination} appeared during the run")
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.move(source, destination)
writer.writerow([datetime.now().isoformat(timespec="seconds"), source.name,
destination.relative_to(folder).as_posix(), "yes" if renamed else "no"])


def main():
parser = argparse.ArgumentParser(description="Tidy a Downloads-style folder.")
parser.add_argument("folder", type=Path)
parser.add_argument("--dry-run", action="store_true", help="only print the plan")
args = parser.parse_args()
folder = args.folder.resolve()
moves, skipped = make_plan(folder)
print("DRY RUN: only the plan, nothing is moved" if args.dry_run else "REAL RUN")
for source, destination, renamed in moves:
note = " (renamed: name taken)" if renamed else ""
print(f" {source.name:<23} -> {destination.relative_to(folder).as_posix()}{note}")
if moves and not args.dry_run:
move_files(moves, folder)
print(f"Log: {len(moves)} rows added to {LOG_NAME}")
counts = Counter(destination.relative_to(folder).parts[0] for _, destination, _ in moves)
by_type = ", ".join(f"{name} {counts[name]}" for name in [*TYPES, "Others"] if counts[name])
print(f"{'Would move' if args.dry_run else 'Moved'} {len(moves)} files ({by_type or 'none'})."
f" Renamed {sum(r for _, _, r in moves)} to avoid overwriting.")
print(f"Skipped: {', '.join(skipped) or 'none'}")


if __name__ == "__main__":
main()
Like this project

Posted Oct 29, 2026

A Python script sorts loose files into folders by type and month, shows a dry run first, never overwrites a file and logs every move.

Likes

0

Views

0

Timeline

Oct 25, 2026 - Oct 25, 2026