Project: File Organizer (Tidy a Messy Folder)
1 · The lesson
readYou'll build a real automation tool. Point it at a messy Downloads folder, and it sorts every file into category subfolders (Documents, Images, Code, Archives, Audio, Video, Other). It has a dry-run mode, so it never moves anything until you tell it to.
Scripts like this one are a common reason people keep Python installed on their computer.
What you'll practise: pathlib, shutil, dict-of-categories, dry-run pattern, collision handling, recursive walking.
Step 1: Categorise by Extension
The main idea: a dict that maps each category name to a set of file extensions.
CATEGORIES = {
"Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".heic", ".bmp"},
"Documents": {".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".odt"},
"Spreadsheets": {".xlsx", ".xls", ".csv", ".ods"},
"Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".yml", ".yaml", ".sh"},
"Archives": {".zip", ".tar", ".gz", ".7z", ".rar"},
"Audio": {".mp3", ".wav", ".flac", ".m4a", ".ogg"},
"Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"},
}
def category_for(extension):
"""Return the category name for a file extension, or 'Other' if unknown."""
ext = extension.lower()
for category, exts in CATEGORIES.items():
if ext in exts:
return category
return "Other"
# Test
print(category_for(".PDF")) # Documents
print(category_for(".py")) # Code
print(category_for(".xyz")) # OtherEach category uses a set of extensions. Checking whether an item is in a set is faster than checking a list (set membership is O(1)), and the code is easy to read.
Step 2: Walk a Folder
pathlib makes it easy to loop over the files in a folder:
from pathlib import Path # In a real script: # folder = Path("/Users/me/Downloads") # For the demo we'll fabricate a structure in memory: fake_files = [ "Downloads/photo.jpg", "Downloads/resume.pdf", "Downloads/notes.txt", "Downloads/script.py", "Downloads/data.csv", "Downloads/movie.mp4", "Downloads/archive.zip", "Downloads/something.weird", ] # Pretend each file exists in `folder` folder = Path("Downloads") files = [Path(p) for p in fake_files] print(f"Found {len(files)} files in {folder}:") for f in files: cat = category_for(f.suffix) print(f" {f.name:<25} → {cat}")
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def category_for(*_a, **_kw): print('-> category_for() called') return _AutoMock('category_for()') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'CATEGORIES = {\n "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".heic", ".bmp"},\n "Documents": {".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".odt"},\n "Spreadsheets": {".xlsx", ".xls", ".csv", ".ods"},\n "Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".yml", ".yaml", ".sh"},\n "Archives": {".zip", ".tar", ".gz", ".7z", ".rar"},\n "Audio": {".mp3", ".wav", ".flac", ".m4a", ".ogg"},\n "Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"},\n}\n\ndef category_for(extension):\n """Return the category name for a file extension, or \'Other\' if unknown."""\n ext = extension.lower()\n for category, exts in CATEGORIES.items():\n if ext in exts:\n return category\n return "Other"\n\n# Test\nprint(category_for(".PDF")) # Documents\nprint(category_for(".py")) # Code\nprint(category_for(".xyz")) # Other', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
In a real script:
# from pathlib import Path # folder = Path.home() / "Downloads" # files = [p for p in folder.iterdir() if p.is_file()]
Path.iterdir() yields each item in a directory. .is_file() filters out subdirectories. .suffix gives the extension (including the leading dot).
Step 3: Plan the Moves (Dry-Run)
Never move files until you've planned exactly what will happen and the user has confirmed it. Build the plan as a list:
from pathlib import Path def plan_moves(files, source): """Return a list of (source_path, destination_path) tuples. Nothing is moved yet.""" plan = [] for f in files: if not f.name or f.name.startswith("."): continue # skip hidden files category = category_for(f.suffix) dest_dir = source / category dest_path = dest_dir / f.name plan.append((f, dest_path)) return plan # Demo source = Path("Downloads") files = [source / "photo.jpg", source / "resume.pdf", source / "script.py"] plan = plan_moves(files, source) print("Planned moves (dry-run):") for src, dst in plan: print(f" {src.name:<20} → {dst.relative_to(source)}")
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def category_for(*_a, **_kw): print('-> category_for() called') return _AutoMock('category_for()') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'CATEGORIES = {\n "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".heic", ".bmp"},\n "Documents": {".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".odt"},\n "Spreadsheets": {".xlsx", ".xls", ".csv", ".ods"},\n "Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".yml", ".yaml", ".sh"},\n "Archives": {".zip", ".tar", ".gz", ".7z", ".rar"},\n "Audio": {".mp3", ".wav", ".flac", ".m4a", ".ogg"},\n "Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"},\n}\n\ndef category_for(extension):\n """Return the category name for a file extension, or \'Other\' if unknown."""\n ext = extension.lower()\n for category, exts in CATEGORIES.items():\n if ext in exts:\n return category\n return "Other"\n\n# Test\nprint(category_for(".PDF")) # Documents\nprint(category_for(".py")) # Code\nprint(category_for(".xyz")) # Other', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
.relative_to() shows the destination as Code/script.py instead of the full path, which is shorter to read.
Dry-run output:
Planned moves (dry-run): photo.jpg → Images/photo.jpg resume.pdf → Documents/resume.pdf script.py → Code/script.py
The user checks this list and confirms it. Only then do we move the files.
Step 4: Handle Collisions
What if Documents/resume.pdf already exists in the destination? Overwriting it without a warning would lose data. The usual fix: add a number to the name until we find a name that is not taken.
def unique_destination(dest): """If dest exists, return dest with `(2)`, `(3)`, ... appended until it doesn't.""" if not dest.exists(): return dest stem = dest.stem # filename without extension suffix = dest.suffix # extension including dot parent = dest.parent n = 2 while True: candidate = parent / f"{stem} ({n}){suffix}" if not candidate.exists(): return candidate n += 1 # Demo (simulated): class FakePath: def __init__(self, p): self.p = p def exists(self): return self.p in {"Documents/resume.pdf", "Documents/resume (2).pdf"} @property def stem(self): return "resume" @property def suffix(self): return ".pdf" @property def parent(self): return FakePath("Documents") def __truediv__(self, other): return FakePath(f"{self.p}/{other}") def __str__(self): return self.p # In real code with real Path objects: # next_free = unique_destination(Path("Documents/resume.pdf")) # print(next_free) # → Documents/resume (3).pdf print("(See unique_destination's docstring. When a name is taken, 'resume.pdf' becomes 'resume (2).pdf', then 'resume (3).pdf', and so on.)")
Look up pathlib.Path.stem, .suffix and .parent in the docs. With them, you can build any path you need without cutting and joining strings by hand.
Step 5: Execute (with shutil.move)
To move the files, use shutil.move(). It can move a file to a different drive or filesystem, which os.rename() can't do.
import shutil from pathlib import Path def execute_plan(plan, *, dry_run=True): """Carry out the planned moves. Set dry_run=False to actually move.""" moved = 0 skipped = 0 for src, dst in plan: if not src.exists(): print(f" ⚠️ source vanished: {src.name}") skipped += 1 continue # Ensure destination directory exists if not dry_run: dst.parent.mkdir(parents=True, exist_ok=True) dst = unique_destination(dst) shutil.move(str(src), str(dst)) rel = dst.relative_to(dst.parent.parent) if dry_run else dst.relative_to(dst.parent.parent) marker = "DRY" if dry_run else "✓" print(f" [{marker}] {src.name:<25} → {rel}") moved += 1 print(f"\n{moved} moved, {skipped} skipped " + ("(dry-run)" if dry_run else ""))
setup added so this can run · defines unique_destination
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def unique_destination(*_a, **_kw): print('-> unique_destination() called') return _AutoMock('unique_destination()')
The dry_run flag is the safety feature. Make its default value True. Then the user must pass dry_run=False before any file is moved.
Step 6: The Polished Tool
from pathlib import Path CATEGORIES = { "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".heic", ".bmp"}, "Documents": {".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".odt"}, "Spreadsheets":{".xlsx", ".xls", ".csv", ".ods"}, "Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".yml", ".yaml", ".sh"}, "Archives": {".zip", ".tar", ".gz", ".7z", ".rar"}, "Audio": {".mp3", ".wav", ".flac", ".m4a", ".ogg"}, "Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"}, } def category_for(suffix): s = suffix.lower() for cat, exts in CATEGORIES.items(): if s in exts: return cat return "Other" def make_plan(folder): plan = [] # In real code: items = folder.iterdir() # For demo, fake a list of files: items = [ Path(folder, name) for name in ["photo.jpg", "report.pdf", "data.csv", "script.py", "song.mp3", "movie.mp4", "archive.zip", "mystery.bin"] ] for f in items: # Skip directories (.is_file() doesn't work on fake paths, so assume all are files) cat = category_for(f.suffix) dest = folder / cat / f.name plan.append((f, dest)) return plan def summarize(plan): """Print 'Documents: 3 files' style summary.""" from collections import Counter counts = Counter(dst.parent.name for _, dst in plan) for cat, n in counts.most_common(): print(f" {cat:<15} {n} file{'s' if n != 1 else ''}") # Run it source = Path("Downloads") plan = make_plan(source) print(f"Plan for {source}:") summarize(plan) print() print("Files:") for src, dst in plan: print(f" {src.name:<20} → {dst.relative_to(source)}") print("\nDry run only. No files were moved. In real usage, call execute_plan(plan, dry_run=False).")
Stretch Goals
1. Recursive mode: descend into subfolders (use folder.rglob("*") and .is_file()).
2. Age-based cleanup: move files older than 90 days to an Archive folder (use path.stat().st_mtime).
3. Size-based: separate "large" files (> 100 MB) so you can decide manually.
4. Custom rules file: load categories from rules.yaml, so that people who don't program can add their own.
5. Undo log: write each move to organizer.log so you can build an unorganize command.
6. Watcher mode: use the watchdog package to sort each new file as soon as it arrives in Downloads.
🎯 Your Turn: A summarize_folder Function
Write a function that takes a list of file paths and sizes, and returns a summary dict: the number of files, the total size and the largest file in each category. The function only reports on the files and moves nothing.
from pathlib import Path from collections import defaultdict # Reuse from earlier CATEGORIES = { "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp"}, "Documents": {".pdf", ".docx", ".txt", ".md"}, "Code": {".py", ".js", ".ts", ".html", ".css"}, } def category_for(suffix): s = suffix.lower() for cat, exts in CATEGORIES.items(): if s in exts: return cat return "Other" def summarize_files(files_with_sizes): """Take a list of (path, size_in_bytes) tuples. Return a dict that maps each category to {'count': int, 'total_bytes': int, 'largest': (name, size)}. """ # TODO 1: initialize a result dict, perhaps using defaultdict # TODO 2: for each (path, size), find its category # TODO 3: increment count, add to total_bytes # TODO 4: track the largest file per category pass # Test files = [ ("photo.jpg", 2_500_000), ("vacation.png", 4_100_000), ("resume.pdf", 320_000), ("notes.txt", 4_000), ("script.py", 12_500), ("mystery.xyz", 99_000), ] summary = summarize_files(files) for cat, info in summary.items(): print(f" {cat}: {info['count']} files, {info['total_bytes']:,} bytes, largest: {info['largest']}")
Hint 1: defaultdict makes this easier
from collections import defaultdict
result = defaultdict(lambda: {"count": 0, "total_bytes": 0, "largest": ("", 0)})
Then you don't need to check if a category key exists before using it.
Hint 2: Tracking the largest
After updating count and total_bytes, check:if size > result[cat]["largest"][1]: result[cat]["largest"] = (name, size).
Show full solution
from pathlib import Path from collections import defaultdict def summarize_files(files_with_sizes): result = defaultdict(lambda: {"count": 0, "total_bytes": 0, "largest": ("", 0)}) for path, size in files_with_sizes: cat = category_for(Path(path).suffix) result[cat]["count"] += 1 result[cat]["total_bytes"] += size if size > result[cat]["largest"][1]: result[cat]["largest"] = (path, size) return dict(result) files = [ ("photo.jpg", 2_500_000), ("vacation.png", 4_100_000), ("resume.pdf", 320_000), ("notes.txt", 4_000), ("script.py", 12_500), ("mystery.xyz", 99_000), ] summary = summarize_files(files) for cat, info in sorted(summary.items(), key=lambda kv: -kv[1]["total_bytes"]): name, size = info["largest"] mb = info["total_bytes"] / 1_000_000 print(f" {cat:<12} {info['count']:>2} files {mb:>6.2f} MB total (largest: {name})")
setup added so this can run · re-runs 2 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def category_for(*_a, **_kw): print('-> category_for() called') return _AutoMock('category_for()') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from pathlib import Path except Exception: pass _pm_earlier = [ 'CATEGORIES = {\n "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".heic", ".bmp"},\n "Documents": {".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".odt"},\n "Spreadsheets": {".xlsx", ".xls", ".csv", ".ods"},\n "Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".yml", ".yaml", ".sh"},\n "Archives": {".zip", ".tar", ".gz", ".7z", ".rar"},\n "Audio": {".mp3", ".wav", ".flac", ".m4a", ".ogg"},\n "Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"},\n}\n\ndef category_for(extension):\n """Return the category name for a file extension, or \'Other\' if unknown."""\n ext = extension.lower()\n for category, exts in CATEGORIES.items():\n if ext in exts:\n return category\n return "Other"\n\n# Test\nprint(category_for(".PDF")) # Documents\nprint(category_for(".py")) # Code\nprint(category_for(".xyz")) # Other', 'from pathlib import Path\n\nCATEGORIES = {\n "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".heic", ".bmp"},\n "Documents": {".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".odt"},\n "Spreadsheets":{".xlsx", ".xls", ".csv", ".ods"},\n "Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".yml", ".yaml", ".sh"},\n "Archives": {".zip", ".tar", ".gz", ".7z", ".rar"},\n "Audio": {".mp3", ".wav", ".flac", ".m4a", ".ogg"},\n "Video": {".mp4", ".mov", ".mkv", ".avi", ".webm"},\n}\n\ndef category_for(suffix):\n s = suffix.lower()\n for cat, exts in CATEGORIES.items():\n if s in exts:\n return cat\n return "Other"\n\ndef make_plan(folder):\n plan = []\n # In real code: items = folder.iterdir()\n # For demo, fake a list of files:\n items = [\n Path(folder, name) for name in\n ["photo.jpg", "report.pdf", "data.csv", "script.py",\n "song.mp3", "movie.mp4", "archive.zip", "mystery.bin"]\n ]\n for f in items:\n # Skip directories (.is_file() doesn\'t work on fake paths, so assume all are files)\n cat = category_for(f.suffix)\n dest = folder / cat / f.name\n plan.append((f, dest))\n return plan\n\ndef summarize(plan):\n """Print \'Documents: 3 files\' style summary."""\n from collections import Counter\n counts = Counter(dst.parent.name for _, dst in plan)\n for cat, n in counts.most_common():\n print(f" {cat:<15} {n} file{\'s\' if n != 1 else \'\'}")\n\n# Run it\nsource = Path("Downloads")\nplan = make_plan(source)\n\nprint(f"Plan for {source}:")\nsummarize(plan)\nprint()\nprint("Files:")\nfor src, dst in plan:\n print(f" {src.name:<20} → {dst.relative_to(source)}")\n\nprint("\\nDry run only. No files were moved. In real usage, call execute_plan(plan, dry_run=False).")', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
In a real script, get sizes via path.stat().st_size. A tool that moves files should show a summary like this FIRST, so the user knows what is about to happen.
What You Learned
pathlibas the modern way to handle paths:.suffix,.stem,.parent,.iterdir(),Path / "subdir".shutil.move()to move files, also between different drives.- The dry-run pattern: every script that deletes or moves files should have one.
- Set-of-extensions for fast category lookup.
- Collision handling via a numbered-suffix loop.
You can now write scripts that do repetitive file work for you. Always give them a dry-run mode, so that a mistake doesn't move or delete the wrong files.
You've completed 10 projects, from beginner to intermediate level. The next projects in this path include a weather tool that uses a web API, a URL shortener, a Markdown-to-HTML converter, an image resizer and a chatbot.
Next: Project: Weather CLI.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.