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.

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-runprints 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
.txtis treated as a document. - On a very large folder, think about permissions and files that are open in another program.
Challenges
- Add
--by-dateto sort into2026/10folders using the file's modified time (item.stat().st_mtime). - Write each move to a log file so you can build an
--undocommand. - Load categories from a JSON config file.
- Add a
--recursiveoption. - 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).


