This is the first project that does something to your own computer, which makes it genuinely useful and slightly dangerous. We will build it with a dry-run mode first, so you can see exactly what it would do before it does anything.
What you will build#
DRY RUN — nothing will be moved
invoice.pdf -> Documents/
holiday.jpg -> Images/
song.mp3 -> Audio/
setup.exe -> Installers/
notes.txt -> Documents/
weird.xyz -> Other/
6 files would be moved into 5 folders.
Run again with --go to actually move them.What you need#
- Python installed — see how to install Python
- Comfort with dictionaries and for loops
- A test folder with some junk files in it
Step 1: make a test folder#
Do this before writing anything. Let the script create its own mess to tidy:
from pathlib import Path
TEST = Path(__file__).parent / "test-folder"
TEST.mkdir(exist_ok=True)
for name in ["invoice.pdf", "holiday.jpg", "song.mp3", "setup.exe", "notes.txt", "weird.xyz"]:
(TEST / name).write_text("test file")
print("Created", TEST)Run that once. Now you have something safe to experiment on, and you can recreate it any time you want to start over.
Step 2: list what is there#
from pathlib import Path
folder = Path(__file__).parent / "test-folder"
for item in folder.iterdir():
if item.is_file():
print(item.name, "|", item.suffix)iterdir() lists everything directly inside the folder. The is_file() check matters — without it you would try to move folders too, including the ones this script creates.
item.suffix gives you .pdf, including the dot. It is empty for a file with no extension.
Step 3: decide where each type goes#
A dictionary maps extensions to folder names. Building it the other way round — folder to list of extensions — reads better and is easier to extend:
RULES = {
"Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"],
"Documents": [".pdf", ".doc", ".docx", ".txt", ".md", ".odt"],
"Spreadsheets": [".xls", ".xlsx", ".csv"],
"Audio": [".mp3", ".wav", ".flac", ".m4a"],
"Video": [".mp4", ".mov", ".avi", ".mkv"],
"Archives": [".zip", ".tar", ".gz", ".rar", ".7z"],
"Installers": [".exe", ".msi", ".dmg", ".deb"],
"Code": [".py", ".js", ".html", ".css", ".json"],
}
# Flip it once into extension -> folder, which is what we look up.
LOOKUP = {ext: folder for folder, exts in RULES.items() for ext in exts}
def destination_for(path):
return LOOKUP.get(path.suffix.lower(), "Other").lower() matters — a file called PHOTO.JPG should still land in Images.
Step 4: the dry run#
This is the important step, and the one most tutorials skip. Print what would happen, change nothing:
def plan(folder):
moves = []
for item in folder.iterdir():
if not item.is_file():
continue
if item.name.startswith("."): # leave hidden files alone
continue
moves.append((item, destination_for(item)))
return moves
for source, target in plan(folder):
print(f" {source.name:<22} -> {target}/")Run it. Read every line. Only when the output is right for several different test folders should you let it move anything.
Step 5: moving, without overwriting#
If Documents/notes.txt already exists and you move another notes.txt in, the first one is silently destroyed. Find a free name instead:
def unique_path(target_dir, name):
candidate = target_dir / name
if not candidate.exists():
return candidate
stem = candidate.stem # "notes"
suffix = candidate.suffix # ".txt"
n = 2
while True:
candidate = target_dir / f"{stem} ({n}){suffix}"
if not candidate.exists():
return candidate
n += 1Now a second notes.txt becomes notes (2).txt, which is what every operating system does and what people expect.
The finished script#
import sys
from pathlib import Path
FOLDER = Path(__file__).parent / "test-folder"
RULES = {
"Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"],
"Documents": [".pdf", ".doc", ".docx", ".txt", ".md", ".odt"],
"Spreadsheets": [".xls", ".xlsx", ".csv"],
"Audio": [".mp3", ".wav", ".flac", ".m4a"],
"Video": [".mp4", ".mov", ".avi", ".mkv"],
"Archives": [".zip", ".tar", ".gz", ".rar", ".7z"],
"Installers": [".exe", ".msi", ".dmg", ".deb"],
"Code": [".py", ".js", ".html", ".css", ".json"],
}
LOOKUP = {ext: folder for folder, exts in RULES.items() for ext in exts}
KNOWN_FOLDERS = set(RULES) | {"Other"}
def destination_for(path):
return LOOKUP.get(path.suffix.lower(), "Other")
def unique_path(target_dir, name):
candidate = target_dir / name
if not candidate.exists():
return candidate
stem, suffix, n = candidate.stem, candidate.suffix, 2
while True:
candidate = target_dir / f"{stem} ({n}){suffix}"
if not candidate.exists():
return candidate
n += 1
def plan(folder):
moves = []
for item in sorted(folder.iterdir()):
if not item.is_file() or item.name.startswith("."):
continue
if item.name == Path(__file__).name: # never move this script
continue
moves.append((item, destination_for(item)))
return moves
def main():
go = "--go" in sys.argv
if not FOLDER.exists():
print(f"No such folder: {FOLDER}")
return
moves = plan(FOLDER)
if not moves:
print("Nothing to do.")
return
if not go:
print("DRY RUN — nothing will be moved\n")
for source, target_name in moves:
target_dir = FOLDER / target_name
print(f" {source.name:<22} -> {target_name}/")
if go:
target_dir.mkdir(exist_ok=True)
source.rename(unique_path(target_dir, source.name))
folders = len({t for _, t in moves})
verb = "moved" if go else "would be moved"
print(f"\n{len(moves)} files {verb} into {folders} folders.")
if not go:
print("Run again with --go to actually move them.")
main()Run the dry run with python organise.py and the real thing with python organise.py --go.
How the code works#
sys.argvholds the words typed after the filename, so--gois how the script knows you mean it. Defaulting to the safe behaviour is the whole design.- The dictionary comprehension flips
RULESinto a flat extension lookup once, rather than searching every list for every file. plan()decides andmain()acts. Because the decision is separate, the dry run and the real run cannot disagree — they use the same plan.{s:<22}in the f-string pads the name to 22 characters so the arrows line up.source.rename(...)moves the file. It only works within the same drive; useshutil.moveif you need to cross drives.
Questions people ask#
Why pathlib instead of the os module?
Paths become objects with useful methods (.suffix, .stem, .exists()) instead of strings you have to slice, and the / operator joins them correctly on every operating system. See reading and writing files in Python.
Can it run automatically?
Yes — Task Scheduler on Windows, cron on macOS and Linux. Only do that once you have run it manually many times and trust it completely.
What happens to files with no extension?
suffix is an empty string, which is not in the lookup, so they go to Other. That is deliberate — better than guessing.
Can I move files to a different drive?
rename cannot cross filesystems. Use shutil.move(source, target), which copies and deletes when it has to.