DEV Community

Stephano kambeta
Stephano kambeta

Posted on

Organize Your Downloads Folder Automatically with Python

The Downloads folder is where files go to be forgotten. Receipts, installers, screenshots and the one PDF you actually need all sit in one long list, sorted by nothing in particular.

Cleaning it up by hand takes ten minutes. A week later it's a mess again.

This guide builds a small Python script that sorts the folder for you. It's built to be safe: it only moves files, never deletes or overwrites anything, and you can undo every run. You can run it by hand, on a schedule, or leave it watching the folder all day.

What the script does

It looks at the files sitting loose in your Downloads folder and moves each one into a subfolder based on its type:

  • Images, Documents, Spreadsheets and Presentations
  • Archives (like zip files) and Installers
  • Audio and Video

Anything it doesn't recognize stays exactly where it is.

A few rules keep it from doing damage:

  • It never deletes anything. Files are moved, nothing else.
  • It never overwrites. If report.pdf already exists in Documents, the new one becomes report (1).pdf.
  • It leaves unfinished downloads alone. Files that are still downloading, like .crdownload or .part, are skipped, and so is anything changed in the last minute.
  • It ignores folders, hidden files and shortcuts. Only plain files at the top level are touched.
  • It has a dry run. You can see exactly what would happen before anything moves.
  • It has an undo. One command puts the files from the last run back.

Is this worth automating?

Mostly yes, with one caveat. Moving files is a bigger step than reading data, because it changes your computer. That's why the safety features above matter more than the sorting itself.

A task like this is a decent fit for automation because it's repetitive and reversible. For a way to judge other tasks, I wrote When Not to Automate: 6 Signs a Task Isn't Worth It.

This kind of job, working with files on a desktop, is also what robotic process automation was built for. If you're curious how that world works, I explain it in robotic process automation (RPA).

Why not GitHub Actions?

Many of the automations I write about run on GitHub's servers, for example my free Python uptime monitor. That works because they only need the internet.

This one needs your files, and a workflow running on GitHub's machines can't see your computer. So this script runs on your own machine instead. Everything else stays the same: a short Python script with no extra libraries, apart from one optional extra at the end.

Step 1: Add the script

Save this as organize_downloads.py in a folder of your own, such as a scripts folder in your home directory. Don't put it in Downloads itself.

"""Sort the files in a folder (your Downloads folder, by default) into tidy subfolders.

Safe by design: files are only moved, never deleted or overwritten,
and the last run can always be undone.
"""
import argparse
import json
import shutil
import sys
import threading
import time
from pathlib import Path

CATEGORIES = {
    "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".svg", ".bmp", ".tiff"},
    "Documents": {".pdf", ".doc", ".docx", ".txt", ".rtf", ".odt", ".md", ".pages"},
    "Spreadsheets": {".xls", ".xlsx", ".csv", ".ods", ".numbers"},
    "Presentations": {".ppt", ".pptx", ".key", ".odp"},
    "Archives": {".zip", ".rar", ".7z", ".tar", ".gz", ".tgz"},
    "Installers": {".exe", ".msi", ".dmg", ".pkg", ".deb", ".appimage"},
    "Audio": {".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg"},
    "Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"},
}
FOLDER_FOR = {ext: name for name, exts in CATEGORIES.items() for ext in exts}
UNFINISHED = {".crdownload", ".part", ".download", ".tmp", ".partial", ".opdownload"}
UNDO_FILE = ".organizer-undo.json"


def free_name(folder, name):
    """Return a path in folder that doesn't exist yet: report.pdf, then report (1).pdf, and so on."""
    target = folder / name
    stem, suffix, number = target.stem, target.suffix, 1
    while target.exists():
        target = folder / f"{stem} ({number}){suffix}"
        number += 1
    return target


def plan(source, min_age):
    """List (file, destination folder) pairs for files that are safe to move."""
    moves = []
    for item in sorted(source.iterdir()):
        if item.is_symlink() or not item.is_file() or item.name.startswith("."):
            continue  # only plain, visible files
        suffix = item.suffix.lower()
        if suffix in UNFINISHED or suffix not in FOLDER_FOR:
            continue  # unfinished downloads and file types we don't know are left alone
        if time.time() - item.stat().st_mtime < min_age:
            continue  # changed a moment ago, so it may still be downloading
        moves.append((item, source / FOLDER_FOR[suffix]))
    return moves


def organize(source, dry_run=False, min_age=60):
    moved, planned = [], 0
    for item, folder in plan(source, min_age):
        try:
            target = free_name(folder, item.name)
            print(f"{'Would move' if dry_run else 'Moving'}: {item.name} -> {folder.name}/{target.name}")
            if dry_run:
                planned += 1
                continue
            folder.mkdir(exist_ok=True)
            shutil.move(str(item), str(target))
            moved.append({"from": str(item), "to": str(target)})
        except OSError as error:  # for example, the file is open in another program
            print(f"  Skipped {item.name}: {error}")
    if moved:  # remember this run, so it can be undone
        (source / UNDO_FILE).write_text(json.dumps(moved, indent=2))
    print(f"Would move {planned} file(s)." if dry_run else f"Moved {len(moved)} file(s).")
    return moved


def undo(source):
    log = source / UNDO_FILE
    if not log.exists():
        print("Nothing to undo.")
        return
    for move in reversed(json.loads(log.read_text())):
        current, original = Path(move["to"]), Path(move["from"])
        if not current.exists():
            print(f"Skipped {current.name}: it's no longer there")
            continue
        target = free_name(original.parent, original.name)  # in case the old name is taken again
        shutil.move(str(current), str(target))
        print(f"Restored: {target.name}")
    log.unlink()


def watch(source, min_age):
    """Sort new files as they arrive. Needs: pip install watchdog"""
    from watchdog.events import FileSystemEventHandler
    from watchdog.observers import Observer

    timer = None

    class Handler(FileSystemEventHandler):
        def on_any_event(self, event):
            nonlocal timer
            if event.is_directory:
                return
            if timer:
                timer.cancel()  # still busy, so wait for things to settle
            timer = threading.Timer(min_age + 5, organize, args=(source,), kwargs={"min_age": min_age})
            timer.daemon = True
            timer.start()

    observer = Observer()
    observer.schedule(Handler(), str(source), recursive=False)
    observer.start()
    print(f"Watching {source}. Press Ctrl+C to stop.")
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()


parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("folder", nargs="?", default=str(Path.home() / "Downloads"))
parser.add_argument("--dry-run", action="store_true", help="show what would move, without moving anything")
parser.add_argument("--undo", action="store_true", help="put back the files moved by the last run")
parser.add_argument("--watch", action="store_true", help="keep running and sort new files as they arrive")
parser.add_argument("--min-age", type=int, default=60, help="skip files changed in the last N seconds (default: 60)")
args = parser.parse_args()

source = Path(args.folder).expanduser()
if not source.is_dir():
    sys.exit(f"Folder not found: {source}")

if args.undo:
    undo(source)
else:
    organize(source, args.dry_run, args.min_age)
    if args.watch:
        watch(source, args.min_age)
Enter fullscreen mode Exit fullscreen mode

Here's how the main parts work:

  • CATEGORIES is the list of folders and the file types that go into each. It's the part you'll most likely edit.
  • plan() decides which files are safe to move. It skips folders, hidden files, shortcuts, unfinished downloads, unknown types and anything changed in the last minute.
  • free_name() makes sure nothing is overwritten by adding (1), (2) and so on to a name that's taken.
  • organize() does the moving. If one file fails, for example because another program has it open, it says so and carries on with the rest. It also saves a small hidden log of what it moved.
  • undo() reads that log and puts the files back. If you've deleted one since, it says so and skips it. If a new file has taken the old name, the restored one gets a (1).

By default it sorts your Downloads folder. To sort another folder, add its path: python3 organize_downloads.py "D:\Inbox".

Step 2: Try it safely

Start with the dry run, which only describes what would happen. If your system uses python instead of python3, use that.

python3 organize_downloads.py --dry-run
Enter fullscreen mode Exit fullscreen mode

On a test folder with a few files, it looks like this:

Would move: IMG_2041.jpg -> Images/IMG_2041.jpg
Would move: budget.xlsx -> Spreadsheets/budget.xlsx
Would move: family-photos.zip -> Archives/family-photos.zip
Would move: invoice-march.pdf -> Documents/invoice-march.pdf
Would move: meeting-notes.txt -> Documents/meeting-notes.txt
Would move: pitch-deck.pptx -> Presentations/pitch-deck.pptx
Would move: song.mp3 -> Audio/song.mp3
Would move: zoom-installer.exe -> Installers/zoom-installer.exe
Would move 8 file(s).
Enter fullscreen mode Exit fullscreen mode

If the list looks right, run it for real:

python3 organize_downloads.py
Enter fullscreen mode Exit fullscreen mode

The folder now looks like this:

Archives/family-photos.zip
Audio/song.mp3
Documents/invoice-march.pdf
Documents/meeting-notes.txt
Images/IMG_2041.jpg
Installers/zoom-installer.exe
Presentations/pitch-deck.pptx
Spreadsheets/budget.xlsx
big-video.crdownload
mystery-file.xyz
Enter fullscreen mode Exit fullscreen mode

Look at the last two lines. big-video.crdownload is an unfinished download, and mystery-file.xyz is a type the script doesn't know. Both stayed where they were, which is the point.

Changed your mind? This puts everything back:

python3 organize_downloads.py --undo
Enter fullscreen mode Exit fullscreen mode

Undo reverses the most recent run that moved something. It won't remove the empty category folders, so delete those yourself if you like.

Step 3: Run it automatically

Running it by hand is fine, but the point is not to think about it. Set it to run once a day.

On Windows, use Task Scheduler. This command creates a task that runs every day at 6 p.m.:

schtasks /Create /SC DAILY /ST 18:00 /TN "Organize Downloads" /TR "python C:\scripts\organize_downloads.py"
Enter fullscreen mode Exit fullscreen mode

If the task doesn't seem to run, use the full path to Python in place of python. You can find it by typing where python in a terminal.

On macOS or Linux, use cron. Run crontab -e and add a line like this:

0 18 * * * /usr/bin/python3 /home/yourname/scripts/organize_downloads.py >> /home/yourname/organize.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Find the right path for Python with which python3. The part at the end saves the script's output to a log file. A job that runs while you're not looking should leave a trail, and I wrote about why in how to monitor your automations when something breaks.

On a Mac, macOS protects folders like Downloads, so a scheduled job may not be allowed to touch it. If the script runs but nothing moves, check your privacy and security settings for the app that runs the job.

Whichever you use, run the script by hand once first, and look at the first log after a scheduled run.

Optional: Sort files as they arrive

If your computer is on most of the day, you can have the script watch the folder instead of running on a timer. This uses a small extra library called watchdog. Install it with:

python3 -m pip install watchdog
Enter fullscreen mode Exit fullscreen mode

Then start the script with --watch:

python3 organize_downloads.py --watch
Enter fullscreen mode Exit fullscreen mode

It sorts the folder once when it starts, then keeps running. Each time a file changes in Downloads, it waits for things to settle, and then runs the same sweep as before. It leaves unfinished downloads alone, so a big file is only moved after it's complete.

The window has to stay open, or you need to start it automatically when you log in. For most people, the daily schedule is simpler and just as good.

Making it your own

Add your own file types. Edit CATEGORIES. For example, to give e-books their own folder, add a line:

    "Ebooks": {".epub", ".mobi"},
Enter fullscreen mode Exit fullscreen mode

Change how long it waits. --min-age 300 skips anything changed in the last five minutes, which helps with slow downloads.

Leave a type alone. Remove its extension from the list, and the script ignores it.

One honest limit: this sorts by file type, not by what's in the file. Every PDF goes to Documents, whether it's a receipt or a recipe. Sorting by content, like "this is an invoice from March," needs something that reads the file and decides. That's where AI agents come in, and I explain how they differ from simple rule-based tools in AI agents: how they work vs RPA.

What can go wrong

A file is open in another program. On Windows especially, a file in use can't be moved. The script skips it, says so, and you can run it again later.

Your Downloads folder is synced. If it's inside OneDrive, iCloud or Dropbox, moving files may trigger uploads or downloads. Try the dry run and a small test first.

Something expects a file in Downloads. An installer that's still running, or a program that looks for yesterday's file, may not find it. Undo brings it back.

Your browser downloads somewhere else. The script only touches the folder you give it. Check where your browser saves files.

You forget it exists. A script that runs quietly will keep running, so make a note of where it lives, and keep an eye on the log for the first week.

Undo only goes back one run. If you sort the folder twice and want to reverse the first, you can't. Files from older runs have to be moved by hand.

Quick answers

Will it delete anything?
No. It only moves files, and it never overwrites a file with the same name.

What about files in subfolders?
It ignores them. Only the files directly inside the folder are sorted.

Does it work on Mac, Linux and Windows?
Yes. It uses only standard Python features that work on all three. The scheduling step is the only part that differs.

Can I sort my Desktop too?
Yes. Give it the path: python3 organize_downloads.py ~/Desktop. Try the dry run first.

Do I need to install anything?
No, unless you want watch mode. That needs the watchdog library.

Does it cost anything?
No. It's a small script that runs on your own computer.

The short version

A tidy Downloads folder doesn't take much effort once something else does the sorting. The important part is that the script is careful: it moves, never deletes, skips anything unfinished, and can undo its last run.

Start with the dry run, look at the list, then run it for real. Once you trust it, schedule it and forget it.

You can find more practical automation guides on Procwire.

Top comments (0)