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
| Step | Feature | Main skill |
|---|---|---|
| 1 | Create a messy sample folder and look inside | pathlib: iterdir(), suffix |
| 2 | Decide a category from the extension | A dictionary and a comprehension |
| 3 | Skip folders and hidden files | is_dir(), startswith() |
| 4 | Dry run: print the plan only | Planning before acting |
| 5 | Move the files | shutil.move, mkdir() |
| 6 | Handle name clashes with (1), (2) | A while loop, with_name() |
| 7 | Log, summary and the complete program | csv, 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 / namejoins 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 insorted().item.suffixis the extension, including the dot. Look at the output:READMEhas no extension,IMG_2042.JPGuses capital letters, and.DS_Storealso shows an empty suffix, because Python treats a name that starts with a dot as a hidden name, not an extension. The!rshows 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)}")
CATEGORIESmaps each category to a list of extensions. It is easy to read and easy to edit.EXTENSION_TO_CATEGORYis built with a dictionary comprehension that has twoforparts. For every category, and for every extension in it, it addsextension: category. Looking up a key in a dictionary is very fast, even with hundreds of extensions.path.suffix.lower()turns.JPGinto.jpg, so capital letters do not matter..get(key, "Others")returns"Others"for any extension we do not know, such as.exeor.mp3, and for files with no extension.item.is_file()leaves out theCollege Festfolder.
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()returnsTruefor 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 withif notto 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 = Trueis 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.namebuilds a path likeDownloads/Documents/Resume.pdf.destination.parent.nameis the name of the folder the file will go into, for exampleDocuments.
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 passdry_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 emptyVideosfolder appears if there are no videos.shutil.move(str(source), str(destination))moves the file. Passing plain strings withstr()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.stemis the name without the extension (Resume) andtarget.suffixis the extension (.pdf).with_name()gives a new path in the same folder with a different file name.- For the resume,
Resume (1).pdfis also taken, so the loop runs twice and picksResume (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_FILEis kept outside the folder being organised. If it were inside, the next run would move the log itself intoDocuments.- 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 like2026-10-22 18:30:05. Your output shows the current time.Counter(...)fromcollectionscounts 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,
returnendsmain()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_mtimewithdatetime.fromtimestamp()) to create folders likeImages/2026-10. - Command-line options: use the
argparsemodule so you can runpython 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. Ourmake_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
destinationback tosource, 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.Pathgives youiterdir(),is_dir(),name,stem,suffixand 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.