Learn
🤖 AI Hub 📰 News 📖 Dictionary 🧰 Resources
Grow
💼 Jobs 🚀 Community 🏆 Challenges
Explore by Stage
🌑 Zero 📚 Learn 🔧 Practice 🏗 Build 🚀 Launch 💰 Earn
Account
Login Sign Up
🏗️HTML 🎨CSS ⚡JavaScript 🐙Git & GitHub ⚛️React Soon 📘TypeScript Soon 🐍Python 🗄️Database & SQL Soon 🌐Web Development Soon ☁️Cloud & DevOps Soon 🤖AI & ML Soon 📄Resume Prep Soon 🎤Interview Prep Soon 💼Freelancing Soon 🚀SaaS & Side Projects Soon 🤖Automation Soon
Advanced 🕑 25 min read 👁 2 views 📅 Oct 22, 2026

Project: Organise Messy Folders Automatically

Build a Python program that sorts a messy Downloads folder into Documents, Images, Videos, Archives, Code and Others, with a dry run, safe renaming of clashing names, a log of every move and a summary.

The Story: One Semester of Downloads

Ananya is a second-year B.Sc. student in Jaipur. Every assignment PDF, fee receipt, photo from the college fest, screenshot of a result, lab video, zip file from a friend and software installer goes into one place: her Downloads folder. After one semester it has hundreds of files. Finding last month’s fee receipt takes ten minutes of scrolling.

Sorting files by hand is slow and boring, which makes it a perfect job for a program. In this project you will build a folder organiser that looks at every file, decides its category from its extension (the part after the dot, like .pdf), and moves it into a folder such as Documents, Images or Videos.

Because a program that moves files can also make a mess very quickly, we will build safety in from the start: a dry run that only prints the plan, no overwriting of files with the same name, skipping hidden files, and a log of every move.

Safety first: on your own computer, always test this program on a copy of your real folder, never on the original. The section after Step 7 shows how to make a copy with one command. In the playground there is nothing to worry about: each run creates its own sample folder.

The Plan

StepFeatureMain skill
1Create a messy sample folder and look insidepathlib: iterdir(), suffix
2Decide a category from the extensionA dictionary and a comprehension
3Skip folders and hidden filesis_dir(), startswith()
4Dry run: print the plan onlyPlanning before acting
5Move the filesshutil.move, mkdir()
6Handle name clashes with (1), (2)A while loop, with_name()
7Log, summary and the complete programcsv, datetime, Counter

Step 1: Create a Messy Folder and Look Inside

The playground starts in an empty folder, so the program first builds a copy of Ananya’s mess: empty files with many different extensions, two hidden files and one sub-folder.

from pathlib import Path

FOLDER = Path("Downloads")

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)


create_sample_folder(FOLDER)

for item in sorted(FOLDER.iterdir()):
    kind = "folder" if item.is_dir() else "file"
    print(f"{kind:<8}{item.name:<26}suffix={item.suffix!r}")
  • Path("Downloads") is a path object. It is smarter than a plain string: it knows its name, its extension and its parent folder.
  • folder.mkdir(exist_ok=True) creates the folder, and does nothing if it already exists.
  • folder / name joins paths with the / operator. It works on Windows too, where Python uses the correct separator for you.
  • touch() creates an empty file. An empty file is enough, because the organiser looks only at names.
  • folder.iterdir() gives every item inside the folder, in no fixed order, so we wrap it in sorted().
  • item.suffix is the extension, including the dot. Look at the output: README has no extension, IMG_2042.JPG uses capital letters, and .DS_Store also shows an empty suffix, because Python treats a name that starts with a dot as a hidden name, not an extension. The !r shows the value with quotes, so an empty suffix appears as ''.

Step 2: Decide a Category from the Extension

We describe the categories in the way a person thinks: “Documents are PDFs, Word files, text files...”. But the program needs the opposite question: “I have a .pdf, which category is it?”. A comprehension turns the dictionary around.

from pathlib import Path

FOLDER = Path("Downloads")

CATEGORIES = {
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".xlsx", ".pptx", ".csv"],
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"],
    "Videos": [".mp4", ".mov", ".mkv", ".avi"],
    "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Code": [".py", ".html", ".css", ".js", ".java", ".c", ".cpp", ".ipynb"],
}

# Turn it around: {".pdf": "Documents", ".jpg": "Images", ...}
EXTENSION_TO_CATEGORY = {
    extension: category
    for category, extensions in CATEGORIES.items()
    for extension in extensions
}

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)


def category_for(path):
    """Return the category folder name for a file."""
    return EXTENSION_TO_CATEGORY.get(path.suffix.lower(), "Others")


create_sample_folder(FOLDER)

for item in sorted(FOLDER.iterdir()):
    if item.is_file():
        print(f"{item.name:<26}-> {category_for(item)}")
  • CATEGORIES maps each category to a list of extensions. It is easy to read and easy to edit.
  • EXTENSION_TO_CATEGORY is built with a dictionary comprehension that has two for parts. For every category, and for every extension in it, it adds extension: category. Looking up a key in a dictionary is very fast, even with hundreds of extensions.
  • path.suffix.lower() turns .JPG into .jpg, so capital letters do not matter.
  • .get(key, "Others") returns "Others" for any extension we do not know, such as .exe or .mp3, and for files with no extension.
  • item.is_file() leaves out the College Fest folder.

Notice the problem in the output: .DS_Store and .hidden_settings are going to Others. Those are hidden files that the operating system or other programs use. We must not touch them.

Step 3: Skip Folders and Hidden Files

We put the decision “should this item be moved?” in its own small function, so the main code reads like a sentence.

from pathlib import Path

FOLDER = Path("Downloads")

CATEGORIES = {
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".xlsx", ".pptx", ".csv"],
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"],
    "Videos": [".mp4", ".mov", ".mkv", ".avi"],
    "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Code": [".py", ".html", ".css", ".js", ".java", ".c", ".cpp", ".ipynb"],
}

# Turn it around: {".pdf": "Documents", ".jpg": "Images", ...}
EXTENSION_TO_CATEGORY = {
    extension: category
    for category, extensions in CATEGORIES.items()
    for extension in extensions
}

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)


def category_for(path):
    """Return the category folder name for a file."""
    return EXTENSION_TO_CATEGORY.get(path.suffix.lower(), "Others")


def should_skip(path):
    """Skip folders and hidden files (names that start with a dot)."""
    return path.is_dir() or path.name.startswith(".")


def files_to_organise(folder):
    """Return the files in the folder that we are allowed to move."""
    return [item for item in sorted(folder.iterdir()) if not should_skip(item)]


create_sample_folder(FOLDER)

files = files_to_organise(FOLDER)
for item in files:
    print(f"{item.name:<26}-> {category_for(item)}")

skipped = len(list(FOLDER.iterdir())) - len(files)
print(f"{len(files)} files to organise, {skipped} items skipped")
  • should_skip() returns True for a folder or for a name that starts with a dot. On Mac and Linux, a dot at the start means “hidden”.
  • files_to_organise() uses a list comprehension with if not to keep only the files we may move.
  • We skip every folder, including College Fest. Ananya made that folder herself, so it is already organised. This also means our own category folders are never moved into each other on the next run.
  • The last two lines count how many items were skipped, so you can confirm that nothing was quietly forgotten.

Step 4: A Dry Run That Only Prints the Plan

A good habit for any program that changes files: first make a plan, then show it, and only then carry it out. A run that only shows the plan is called a dry run.

from pathlib import Path

FOLDER = Path("Downloads")
DRY_RUN = True  # True: only print the plan

CATEGORIES = {
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".xlsx", ".pptx", ".csv"],
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"],
    "Videos": [".mp4", ".mov", ".mkv", ".avi"],
    "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Code": [".py", ".html", ".css", ".js", ".java", ".c", ".cpp", ".ipynb"],
}

# Turn it around: {".pdf": "Documents", ".jpg": "Images", ...}
EXTENSION_TO_CATEGORY = {
    extension: category
    for category, extensions in CATEGORIES.items()
    for extension in extensions
}

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)


def category_for(path):
    """Return the category folder name for a file."""
    return EXTENSION_TO_CATEGORY.get(path.suffix.lower(), "Others")


def should_skip(path):
    """Skip folders and hidden files (names that start with a dot)."""
    return path.is_dir() or path.name.startswith(".")


def files_to_organise(folder):
    """Return the files in the folder that we are allowed to move."""
    return [item for item in sorted(folder.iterdir()) if not should_skip(item)]


def make_plan(folder):
    """Return a list of (source, destination) pairs. Nothing is moved here."""
    plan = []
    for source in files_to_organise(folder):
        destination = folder / category_for(source) / source.name
        plan.append((source, destination))
    return plan


create_sample_folder(FOLDER)

plan = make_plan(FOLDER)
if DRY_RUN:
    print("DRY RUN: nothing will be moved")
    for source, destination in plan:
        print(f"Would move {source.name:<26}-> {destination.parent.name}/")
print(f"{len(plan)} files in the plan")
  • DRY_RUN = True is a setting at the top of the program, easy to find and change.
  • make_plan() returns a list of pairs: where each file is now (source) and where it will go (destination). It does not move anything.
  • folder / category_for(source) / source.name builds a path like Downloads/Documents/Resume.pdf.
  • destination.parent.name is the name of the folder the file will go into, for example Documents.

If anything in the plan looks wrong, you fix the CATEGORIES dictionary before a single file has moved.

Step 5: Move the Files

Now the real work. The shutil module (short for “shell utilities”) copies and moves files. This step shows the dry run first, then moves everything and prints the new folder.

import shutil
from pathlib import Path

FOLDER = Path("Downloads")

CATEGORIES = {
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".xlsx", ".pptx", ".csv"],
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"],
    "Videos": [".mp4", ".mov", ".mkv", ".avi"],
    "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Code": [".py", ".html", ".css", ".js", ".java", ".c", ".cpp", ".ipynb"],
}

# Turn it around: {".pdf": "Documents", ".jpg": "Images", ...}
EXTENSION_TO_CATEGORY = {
    extension: category
    for category, extensions in CATEGORIES.items()
    for extension in extensions
}

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)


def category_for(path):
    """Return the category folder name for a file."""
    return EXTENSION_TO_CATEGORY.get(path.suffix.lower(), "Others")


def should_skip(path):
    """Skip folders and hidden files (names that start with a dot)."""
    return path.is_dir() or path.name.startswith(".")


def files_to_organise(folder):
    """Return the files in the folder that we are allowed to move."""
    return [item for item in sorted(folder.iterdir()) if not should_skip(item)]


def make_plan(folder):
    """Return a list of (source, destination) pairs. Nothing is moved here."""
    plan = []
    for source in files_to_organise(folder):
        destination = folder / category_for(source) / source.name
        plan.append((source, destination))
    return plan


def organise(folder, dry_run=True):
    """Print the plan, or carry it out when dry_run is False."""
    plan = make_plan(folder)
    for source, destination in plan:
        if dry_run:
            print(f"Would move {source.name:<26}-> {destination.parent.name}/")
        else:
            destination.parent.mkdir(exist_ok=True)
            shutil.move(str(source), str(destination))
    return plan


def show_tree(folder):
    """Print each item in the folder, and the files inside sub-folders."""
    print(f"{folder.name}/")
    for item in sorted(folder.iterdir()):
        if item.is_dir():
            inside = ", ".join(child.name for child in sorted(item.iterdir()))
            print(f"    {item.name}/  {inside}")
        else:
            print(f"    {item.name}")


create_sample_folder(FOLDER)

print("First, a dry run:")
organise(FOLDER, dry_run=True)

print()
moved = organise(FOLDER, dry_run=False)
print(f"Moved {len(moved)} files. The folder now looks like this:")
show_tree(FOLDER)
  • organise(folder, dry_run=True) has a default argument. If you forget to pass dry_run, the safe choice is used.
  • destination.parent.mkdir(exist_ok=True) creates the category folder the first time a file needs it. So no empty Videos folder appears if there are no videos.
  • shutil.move(str(source), str(destination)) moves the file. Passing plain strings with str() works the same in every Python version.
  • show_tree() prints every item, and for each sub-folder it joins the names inside with ", ".join(...).

Ananya’s 20 visible files are now in six neat folders, and the hidden files and College Fest are exactly where they were.

Step 6: Handle Name Clashes with (1), (2)

Ananya ran the organiser last month too, so Images already has an IMG_2041.jpg, and Documents already has Resume.pdf and Resume (1).pdf. If we move a new file with the same name, shutil.move may silently replace the old file, and the old photo is gone forever. Browsers solve this by adding a number, and so will we.

import shutil
from pathlib import Path

FOLDER = Path("Downloads")

CATEGORIES = {
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".xlsx", ".pptx", ".csv"],
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"],
    "Videos": [".mp4", ".mov", ".mkv", ".avi"],
    "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Code": [".py", ".html", ".css", ".js", ".java", ".c", ".cpp", ".ipynb"],
}

# Turn it around: {".pdf": "Documents", ".jpg": "Images", ...}
EXTENSION_TO_CATEGORY = {
    extension: category
    for category, extensions in CATEGORIES.items()
    for extension in extensions
}

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)
    # Files sorted in an earlier run, so some names will clash
    (folder / "Images").mkdir(exist_ok=True)
    (folder / "Images" / "IMG_2041.jpg").touch()
    (folder / "Documents").mkdir(exist_ok=True)
    (folder / "Documents" / "Resume.pdf").touch()
    (folder / "Documents" / "Resume (1).pdf").touch()


def category_for(path):
    """Return the category folder name for a file."""
    return EXTENSION_TO_CATEGORY.get(path.suffix.lower(), "Others")


def should_skip(path):
    """Skip folders and hidden files (names that start with a dot)."""
    return path.is_dir() or path.name.startswith(".")


def files_to_organise(folder):
    """Return the files in the folder that we are allowed to move."""
    return [item for item in sorted(folder.iterdir()) if not should_skip(item)]


def unique_destination(target):
    """Return target, or 'name (1).ext', 'name (2).ext' if that name is taken."""
    candidate = target
    counter = 1
    while candidate.exists():
        candidate = target.with_name(f"{target.stem} ({counter}){target.suffix}")
        counter += 1
    return candidate


def make_plan(folder):
    """Return a list of (source, destination) pairs. Nothing is moved here."""
    plan = []
    for source in files_to_organise(folder):
        target = folder / category_for(source) / source.name
        destination = unique_destination(target)
        plan.append((source, destination))
    return plan


def organise(folder, dry_run=True):
    """Print the plan, or carry it out when dry_run is False."""
    plan = make_plan(folder)
    for source, destination in plan:
        if dry_run:
            print(f"Would move {source.name:<26}-> {destination.parent.name}/{destination.name}")
        else:
            destination.parent.mkdir(exist_ok=True)
            shutil.move(str(source), str(destination))
    return plan


create_sample_folder(FOLDER)
organise(FOLDER, dry_run=True)
  • create_sample_folder() now also creates the files from “last month”, so we can see clashes.
  • unique_destination() starts with the normal name. while candidate.exists() keeps trying a new name until it finds one that is free.
  • target.stem is the name without the extension (Resume) and target.suffix is the extension (.pdf). with_name() gives a new path in the same folder with a different file name.
  • For the resume, Resume (1).pdf is also taken, so the loop runs twice and picks Resume (2).pdf.
  • The dry run message now shows the full new name, so Ananya sees the renames before they happen.

Step 7: The Complete Program with a Log and a Summary

Two final features. A log records every move with the date and time, so Ananya can always find out where a file went (and even undo it, as you will do in the Practice Task). A summary counts the files per category. The program also asks before moving anything: type YES for a real run, or just press Enter for a dry run. The Input box already contains YES.

import csv
import shutil
from collections import Counter
from datetime import datetime
from pathlib import Path

FOLDER = Path("Downloads")
LOG_FILE = Path("organise_log.csv")  # kept outside FOLDER on purpose

CATEGORIES = {
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".xlsx", ".pptx", ".csv"],
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"],
    "Videos": [".mp4", ".mov", ".mkv", ".avi"],
    "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"],
    "Code": [".py", ".html", ".css", ".js", ".java", ".c", ".cpp", ".ipynb"],
}

# Turn it around: {".pdf": "Documents", ".jpg": "Images", ...}
EXTENSION_TO_CATEGORY = {
    extension: category
    for category, extensions in CATEGORIES.items()
    for extension in extensions
}

# Sample data: on your computer you would already have this folder
SAMPLE_FILES = [
    "Assignment_1_Physics.pdf", "Fee_Receipt_Sem3.pdf", "Resume.pdf",
    "Chemistry_Notes.docx", "Exam_Timetable.xlsx", "lab_readings.txt",
    "IMG_2041.jpg", "IMG_2042.JPG", "Result_Screenshot.png", "Fest_Poster.webp",
    "Convocation.mp4", "Dance_Practice.mov",
    "Project_Backup.zip", "Old_Notes.rar",
    "hello.py", "index.html", "style.css",
    "python-3.12-setup.exe", "ringtone.mp3", "README",
    ".DS_Store", ".hidden_settings",
]


def create_sample_folder(folder):
    """Make a messy folder full of empty files, for practice."""
    folder.mkdir(exist_ok=True)
    for name in SAMPLE_FILES:
        (folder / name).touch()
    (folder / "College Fest").mkdir(exist_ok=True)
    # Files sorted in an earlier run, so some names will clash
    (folder / "Images").mkdir(exist_ok=True)
    (folder / "Images" / "IMG_2041.jpg").touch()
    (folder / "Documents").mkdir(exist_ok=True)
    (folder / "Documents" / "Resume.pdf").touch()
    (folder / "Documents" / "Resume (1).pdf").touch()


def category_for(path):
    """Return the category folder name for a file."""
    return EXTENSION_TO_CATEGORY.get(path.suffix.lower(), "Others")


def should_skip(path):
    """Skip folders and hidden files (names that start with a dot)."""
    return path.is_dir() or path.name.startswith(".")


def files_to_organise(folder):
    """Return the files in the folder that we are allowed to move."""
    return [item for item in sorted(folder.iterdir()) if not should_skip(item)]


def unique_destination(target):
    """Return target, or 'name (1).ext', 'name (2).ext' if that name is taken."""
    candidate = target
    counter = 1
    while candidate.exists():
        candidate = target.with_name(f"{target.stem} ({counter}){target.suffix}")
        counter += 1
    return candidate


def make_plan(folder):
    """Return a list of (source, destination) pairs. Nothing is moved here."""
    plan = []
    for source in files_to_organise(folder):
        target = folder / category_for(source) / source.name
        destination = unique_destination(target)
        plan.append((source, destination))
    return plan


def organise(folder, dry_run=True):
    """Print the plan, or carry it out when dry_run is False."""
    plan = make_plan(folder)
    for source, destination in plan:
        if dry_run:
            print(f"Would move {source.name:<26}-> {destination.parent.name}/{destination.name}")
        else:
            destination.parent.mkdir(exist_ok=True)
            shutil.move(str(source), str(destination))
    return plan


def write_log(plan, log_path):
    """Add one row per moved file to a CSV log, with the date and time."""
    is_new = not log_path.exists()
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with open(log_path, "a", newline="", encoding="utf-8") as file:
        writer = csv.writer(file)
        if is_new:
            writer.writerow(["time", "source", "destination"])
        for source, destination in plan:
            writer.writerow([now, source, destination])


def print_summary(plan):
    """Print how many files went into each category."""
    counts = Counter(destination.parent.name for source, destination in plan)
    print("Summary")
    for category, count in sorted(counts.items()):
        print(f"    {category:<10}{count:>3} files")
    print(f"    {'Total':<10}{len(plan):>3} files")


def main():
    create_sample_folder(FOLDER)  # delete this line for a real folder

    answer = input("Type YES to move the files, or press Enter for a dry run: ")
    dry_run = answer.strip().upper() != "YES"

    plan = organise(FOLDER, dry_run=dry_run)
    if not plan:
        print("Nothing to organise. The folder is already tidy.")
        return
    if dry_run:
        print("Dry run finished. Nothing was moved.")
    else:
        write_log(plan, LOG_FILE)
        print(f"Moved {len(plan)} files. Details are in {LOG_FILE}.")
    print_summary(plan)


if __name__ == "__main__":
    main()
    print()
    print(LOG_FILE.read_text(encoding="utf-8"))
  • LOG_FILE is kept outside the folder being organised. If it were inside, the next run would move the log itself into Documents.
  • In write_log(), mode "a" means append: new rows are added at the end, so the log keeps the history of every run. The header row is written only when the file is new.
  • datetime.now().strftime("%Y-%m-%d %H:%M:%S") gives the time as text like 2026-10-22 18:30:05. Your output shows the current time.
  • Counter(...) from collections counts how many times each category appears, in one line.
  • In main(), answer.strip().upper() != "YES" means anything except YES (in any capital or small letters) gives a dry run. When in doubt, the program does nothing harmful.
  • If the plan is empty, return ends main() early with a friendly message.
  • The last two lines (after main()) print the log, only so you can see it here. Remove them on your computer.

Change the Input box to an empty line and run again to see the dry run version.

Using It on Your Real Downloads Folder

Copy the program into a file such as organiser.py on your computer. First make a test copy of your folder with this small script:

import shutil
from pathlib import Path

real_folder = Path.home() / "Downloads"
test_folder = Path.home() / "Downloads_test"

# Makes a full copy. It stops with an error if Downloads_test already exists.
shutil.copytree(real_folder, test_folder)
print("Copied to", test_folder)

Then change two lines in the organiser:

# At the top of the organiser, point it at the copy:
FOLDER = Path.home() / "Downloads_test"

# And in main(), delete this line:
#     create_sample_folder(FOLDER)

Run it with python organiser.py on Windows or python3 organiser.py on Mac and Linux. Press Enter for a dry run first, read the plan, and type YES only when you are happy. When you trust it, you can point it at the real folder. On Windows, hidden files do not start with a dot, but files like desktop.ini are system files: add them to should_skip() if you see them.

Ideas to Take It Further

  • Installers category: add "Installers": [".exe", ".msi", ".dmg", ".apk"] so setup files stop landing in Others.
  • Sort by month: use the file’s modified time (path.stat().st_mtime with datetime.fromtimestamp()) to create folders like Images/2026-10.
  • Command-line options: use the argparse module so you can run python organiser.py D:/Downloads --move.
  • Find duplicates: compare file sizes, then file contents, to find the same photo saved three times.

Common Mistakes

  • Testing on the real folder first. A small bug can move hundreds of files to the wrong place. Always test on a copy and always read a dry run first.
  • Forgetting that extensions can be in capital letters. Phones and cameras often save photos as .JPG. Without .lower() they go to Others:
from pathlib import Path

CATEGORY = {".jpg": "Images"}
photo = Path("IMG_2042.JPG")
print(photo.suffix)                                 # .JPG
print(CATEGORY.get(photo.suffix, "Others"))         # Others  (wrong!)
print(CATEGORY.get(photo.suffix.lower(), "Others")) # Images
  • Overwriting files with the same name. Never move a file without checking that the destination is free. Use a function like unique_destination().
  • Moving folders and hidden files. Moving a program’s hidden settings file can break that program, and moving your own folders destroys your own organisation. Skip both.
  • Changing the folder while looping over iterdir(). Moving files while you read the folder can give confusing results. Make the full plan as a list first, then move. Our make_plan() does exactly that.

Practice Task: Undo the Last Organise

Ananya’s brother says he liked the old messy folder better. Use the log to put every file back. The starter code creates a folder and a log as they look right after an organiser run.

  • Read the log rows with csv.DictReader.
  • Go through the rows in reverse order (the last move is undone first).
  • Move each file from destination back to source, but only if the file still exists and the old name is free. Print a message for each file.
  • Finally, remove category folders that are now empty, with rmdir().
import csv
import shutil
from pathlib import Path

FOLDER = Path("Downloads")
LOG_FILE = Path("organise_log.csv")

# Sample data: the folder and the log just after an organiser run
for category, name in [("Images", "IMG_2041.jpg"), ("Documents", "Resume.pdf"),
                       ("Code", "hello.py")]:
    (FOLDER / category).mkdir(parents=True, exist_ok=True)
    (FOLDER / category / name).touch()

with open(LOG_FILE, "w", newline="", encoding="utf-8") as file:
    writer = csv.writer(file)
    writer.writerow(["time", "source", "destination"])
    writer.writerow(["2026-10-22 18:30:05", "Downloads/IMG_2041.jpg", "Downloads/Images/IMG_2041.jpg"])
    writer.writerow(["2026-10-22 18:30:05", "Downloads/Resume.pdf", "Downloads/Documents/Resume.pdf"])
    writer.writerow(["2026-10-22 18:30:05", "Downloads/hello.py", "Downloads/Code/hello.py"])


def undo(log_path):
    """Move every file in the log back to where it came from."""
    # TODO 1: read the rows with csv.DictReader
    # TODO 2: go through the rows in reverse order
    # TODO 3: if the destination file exists and the source name is free,
    #         move it back with shutil.move and print what you did
    # TODO 4: remove category folders that are now empty
    pass


undo(LOG_FILE)
print(sorted(item.name for item in FOLDER.iterdir()))
Show one possible answer (try it yourself first!)
import csv
import shutil
from pathlib import Path

FOLDER = Path("Downloads")
LOG_FILE = Path("organise_log.csv")

# Sample data: the folder and the log just after an organiser run
for category, name in [("Images", "IMG_2041.jpg"), ("Documents", "Resume.pdf"),
                       ("Code", "hello.py")]:
    (FOLDER / category).mkdir(parents=True, exist_ok=True)
    (FOLDER / category / name).touch()

with open(LOG_FILE, "w", newline="", encoding="utf-8") as file:
    writer = csv.writer(file)
    writer.writerow(["time", "source", "destination"])
    writer.writerow(["2026-10-22 18:30:05", "Downloads/IMG_2041.jpg", "Downloads/Images/IMG_2041.jpg"])
    writer.writerow(["2026-10-22 18:30:05", "Downloads/Resume.pdf", "Downloads/Documents/Resume.pdf"])
    writer.writerow(["2026-10-22 18:30:05", "Downloads/hello.py", "Downloads/Code/hello.py"])


def undo(log_path):
    """Move every file in the log back to where it came from."""
    with open(log_path, newline="", encoding="utf-8") as file:
        rows = list(csv.DictReader(file))

    for row in reversed(rows):
        source = Path(row["source"])
        destination = Path(row["destination"])
        if not destination.exists():
            print(f"Missing, skipped: {destination}")
        elif source.exists():
            print(f"Name taken, skipped: {source}")
        else:
            shutil.move(str(destination), str(source))
            print(f"Moved back: {destination.name} -> {source.parent}/")

    for row in rows:
        category_folder = Path(row["destination"]).parent
        if category_folder.exists() and not any(category_folder.iterdir()):
            category_folder.rmdir()


undo(LOG_FILE)
print(sorted(item.name for item in FOLDER.iterdir()))

Key Takeaways

  • pathlib.Path gives you iterdir(), is_dir(), name, stem, suffix and the / operator for building paths.
  • Describe categories in a readable dictionary, then build a reverse lookup with a comprehension. Use .lower() and .get(..., "Others").
  • Plan first, show a dry run, then act. Make the safe choice the default.
  • Never overwrite: add (1), (2) to clashing names, skip hidden files and folders, and keep a log outside the folder.
  • Always test a file-moving program on a copy of your real folder.

❓ Frequently Asked Questions

No. It only moves files, and unique_destination() makes sure it never replaces an existing file. Still, test it on a copy first, because a wrong setting can move files to places you did not expect.
Yes. pathlib and shutil work the same on Windows, Mac and Linux. Use a path like Path.home() / "Downloads" instead of typing backslashes, and Python builds the right path for your system.
The playground runs in your browser and cannot see the files on your computer, for your safety. That is why each step creates its own sample folder. Run the program on your computer to organise real files.
Yes. Build the destination with an extra folder, for example by month, and create it with mkdir(parents=True, exist_ok=True), which also creates any missing parent folders.
Was this lesson helpful?

🎯 Finished this lesson?

🔗 Related Tutorials
🤖 Got a Doubt? Ask AI
Get instant answers in Hindi or English
🤖
Z2T Bot
Online
Ask me anything about this tutorial — I'm here! 👋