Build a Python file organiser that sorts your Downloads folder

A practical automation project using pathlib, shutil and a dry-run switch so you can preview before anything moves. Tested before publishing.

A terminal showing files being sorted into Images, Documents and Code folders

Almost everyone's Downloads folder is a mess. A short script can sort it for you, and building it teaches the pathlib module, moving files with shutil, safe design with a dry-run option, and testing code that touches the file system. It uses only the standard library. The code and outputs below were run with Python 3 before publishing.

What we are building

A command that takes a folder and moves each file into a sub-folder named after its type:

  • Images (jpg, png, gif, webp), Documents (pdf, docx, txt, md), Archives (zip, tar, gz), Code (py, js, html, css, json)
  • everything else goes to Other
  • --dry-run prints what would happen without moving anything

The code

Save as organise.py:

#!/usr/bin/env python3
"""Sort the files in a folder into sub-folders by type. Use --dry-run to preview."""
import argparse
import shutil
from pathlib import Path

CATEGORIES = {
    "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp"},
    "Documents": {".pdf", ".doc", ".docx", ".txt", ".md"},
    "Archives": {".zip", ".tar", ".gz", ".rar"},
    "Code": {".py", ".js", ".html", ".css", ".json"},
}


def category_for(name):
    suffix = Path(name).suffix.lower()
    for category, extensions in CATEGORIES.items():
        if suffix in extensions:
            return category
    return "Other"


def unique_target(folder, name):
    target = folder / name
    counter = 1
    while target.exists():
        target = folder / f"{Path(name).stem}_{counter}{Path(name).suffix}"
        counter += 1
    return target


def organise(root, dry_run=False):
    moved = []
    for item in sorted(root.iterdir()):
        if not item.is_file() or item.name.startswith("."):
            continue
        folder = root / category_for(item.name)
        target = unique_target(folder, item.name)
        if not dry_run:
            folder.mkdir(exist_ok=True)
            shutil.move(str(item), str(target))
        moved.append((item.name, target.relative_to(root)))
    return moved


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("folder", type=Path)
    parser.add_argument("--dry-run", action="store_true", help="show what would move")
    args = parser.parse_args(argv)
    if not args.folder.is_dir():
        parser.error(f"{args.folder} is not a folder")
    for name, target in organise(args.folder, args.dry_run):
        print(f"{name} -> {target}")


if __name__ == "__main__":
    main()

Try it

$ ls
LICENSE  backup.zip  holiday.JPG  notes.txt  report.pdf  script.py

$ python3 organise.py . --dry-run
LICENSE -> Other/LICENSE
backup.zip -> Archives/backup.zip
holiday.JPG -> Images/holiday.JPG
notes.txt -> Documents/notes.txt
report.pdf -> Documents/report.pdf
script.py -> Code/script.py

$ python3 organise.py .
(same lines, and the files are really moved)

$ python3 organise.py nope
usage: organise.py [-h] [--dry-run] folder
organise.py: error: nope is not a folder

Always try --dry-run on a real folder first. The hidden file .hidden and existing sub-folders were left alone in my test.

How it works

A dictionary of categories. CATEGORIES maps a folder name to a set of extensions. Adding a new type means adding one line, not changing logic.

category_for lowercases the suffix, so holiday.JPG still counts as an image. It is a pure function, which makes it easy to test.

pathlib.Path treats paths as objects: root / "Images" joins paths, .suffix gets the extension, .stem the name without it, and .is_file() skips folders. This is cleaner than joining strings.

unique_target protects your data. If Images/a.png exists, the new file becomes a_1.png, then a_2.png, and so on. Without this, shutil.move could silently replace a file on some systems.

Dry run. The same function builds the list of moves, but skips mkdir and move when dry_run is true. Preview and real run share one code path, so the preview is trustworthy.

Skipped items. We ignore sub-folders and files starting with a dot, so we never touch hidden settings files or reorganise folders we created earlier.

Add tests

Tests use tempfile.TemporaryDirectory, a throw-away folder deleted afterwards, so your real files are never at risk. Save as test_organise.py:

import tempfile
import unittest
from pathlib import Path

import organise


class OrganiseTest(unittest.TestCase):
    def test_category_for(self):
        self.assertEqual(organise.category_for("Photo.JPG"), "Images")
        self.assertEqual(organise.category_for("notes.txt"), "Documents")
        self.assertEqual(organise.category_for("README"), "Other")

    def test_moves_files_and_avoids_overwrite(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "a.png").write_text("new")
            (root / "Images").mkdir()
            (root / "Images" / "a.png").write_text("old")
            organise.organise(root)
            self.assertEqual((root / "Images" / "a.png").read_text(), "old")
            self.assertEqual((root / "Images" / "a_1.png").read_text(), "new")

    def test_dry_run_changes_nothing(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "x.zip").write_text("z")
            organise.organise(root, dry_run=True)
            self.assertTrue((root / "x.zip").exists())
            self.assertFalse((root / "Archives").exists())


if __name__ == "__main__":
    unittest.main()
$ python3 -m unittest -v
test_category_for ... ok
test_dry_run_changes_nothing ... ok
test_moves_files_and_avoids_overwrite ... ok

Ran 3 tests in 0.005s

OK

The second test is the important one: it proves an existing file is not overwritten.

Known limits

  • It is not recursive: files inside sub-folders are not sorted.
  • There is no undo. The printed list is your record; you could also save it to a log file.
  • Two files with the same name in the same run get unique names only because they are moved one after another. A dry run cannot predict that case.
  • Extension is not the same as content. A file renamed to .txt is treated as a document.
  • On a very large folder, think about permissions and files that are open in another program.

Challenges

  1. Add --by-date to sort into 2026/10 folders using the file's modified time (item.stat().st_mtime).
  2. Write each move to a log file so you can build an --undo command.
  3. Load categories from a JSON config file.
  4. Add a --recursive option.
  5. Run it on a schedule with cron or Windows Task Scheduler.

Takeaway: automation scripts should be safe by default. A dry run, no overwriting and a few tests turn a risky script into one you can trust with your own files.


Build-along projects series, Part 1 (small tested projects): 3 of 5. Previous: Build a REST API in Node.js with no framework (tested).

Keep reading