Skip to content
Happy Programming Guide
Start learning
Programming Projects

Python Project: Build a File Organiser

A Python script that tidies a messy downloads folder by file type. Covers pathlib, dictionaries and — most importantly — how to test a file-moving script safely before it touches anything.

Hand tools hanging on a workshop wall

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#

Output
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#

Step 1: make a test folder#

Do this before writing anything. Let the script create its own mess to tidy:

Python
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#

Python
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:

Python
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:

Python
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:

Python
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 += 1

Now a second notes.txt becomes notes (2).txt, which is what every operating system does and what people expect.

The finished script#

Python
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.argv holds the words typed after the filename, so --go is how the script knows you mean it. Defaulting to the safe behaviour is the whole design.
  • The dictionary comprehension flips RULES into a flat extension lookup once, rather than searching every list for every file.
  • plan() decides and main() 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; use shutil.move if 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.

Where to go next#

Related lessonReading and writing files in Python

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *