Automation That Actually Saves Time

Chapter 14

Automating Files and Folders

Page 194 of the printed book · 7 sections

Open your Downloads folder. Go on, have a look. If it’s like most people’s, it’s full of PDFs you meant to read, installers you ran once, photos from two phones ago, and at least one file called final_v2_REAL (3).docx. Sorting it out by hand takes an afternoon, and a month later it’s back to the same state.

This is where programming starts paying for itself. Renaming three hundred holiday photos so they sort by date. Moving a messy folder’s files into subfolders by type. Doing it all again next Friday with one command. Each of these is a few dozen lines of Python, and you only write them once.

There’s a catch, though, and it’s better you hear it now. A program that moves and renames files can make a mess much faster than you ever could by hand. So half of this chapter is about the habits that make file automation safe: dry runs, asking for confirmation, handling name clashes and keeping an undo log. Please don’t skip that half.

#Setting up a playground

Rule one: never experiment on your real files. Before anything else, we’ll make a folder of fake ones, so a mistake costs nothing. This script creates a folder that looks a lot like a typical Downloads folder:

Python
1# make_messy_folder.py - create practice files to organise
2from pathlib import Path
3
4HERE = Path(__file__).parent
5MESSY = HERE / "messy_downloads"
6
7names = ["report_final.pdf", "IMG_2041.jpg", "budget.xlsx",
8         "song.mp3", "IMG_2042.jpg", "notes.txt", "setup.zip",
9         "invoice_march.pdf", "photo.png", "slides.pptx",
10         "draft (1).docx", "IMG_2043.jpg"]
11
12MESSY.mkdir(exist_ok=True)
13for name in names:
14    (MESSY / name).write_text(f"pretend contents of {name}\n")
15print(f"Created {len(names)} files in {MESSY.name}/")

Run it, and you’ll have a messy_downloads folder with twelve files to play with. If you break something, run it again. That’s the beauty of a playground.

#Bulk renaming

Imagine those IMG_2041.jpg files are photos from a holiday, and you’d like them named lisbon_01.jpg, lisbon_02.jpg, and so on. Here’s the core of a renaming script:

Python
1from pathlib import Path
2
3folder = Path("messy_downloads")
4photos = sorted(folder.glob("IMG_*.jpg"))
5
6for number, photo in enumerate(photos, start=1):
7    new_name = f"lisbon_{number:02d}{photo.suffix}"
8    photo.rename(photo.with_name(new_name))
9    print(f"{photo.name} -> {new_name}")
Output
IMG_2041.jpg -> lisbon_01.jpg
IMG_2042.jpg -> lisbon_02.jpg
IMG_2043.jpg -> lisbon_03.jpg

Let’s take it apart. folder.glob("IMG_*.jpg") finds every file whose name matches the pattern, where * means “anything at all here.” We wrap it in sorted() so the photos are numbered in a predictable order. photo.suffix is the file’s extension, .jpg, so the new name keeps it. photo.with_name(new_name) gives the full path with just the file name swapped. And .rename() does the actual renaming.

The {number:02d} format pads the number with zeros to two digits, giving 01 instead of 1. That small detail matters more than it looks. Computers sort names letter by letter, so without padding, lisbon_10 sorts before lisbon_2, because “1” comes before “2.” With padding, lisbon_02 correctly sorts before lisbon_10. If you’ll have more than 99 files, use 03d.

Now, that script has a problem. It renames files the moment you run it. If you’ve got the pattern slightly wrong, you’ll find out after three hundred files have changed. The fix is the most important habit in this chapter: a dry run. Work out what would happen and show it, without changing anything. Then, only if it looks right, do it for real. Here’s the safer version:

Python
1# rename_photos.py - bulk rename with a dry run first
2from pathlib import Path
3
4folder = Path(__file__).parent / "messy_downloads"
5prefix = input("New name prefix (e.g. lisbon): ").strip()
6
7photos = sorted(folder.glob("IMG_*.jpg"))
8plan = []
9for number, photo in enumerate(photos, start=1):
10    new_name = f"{prefix}_{number:02d}{photo.suffix}"
11    plan.append((photo, photo.with_name(new_name)))
12
13for old, new in plan:
14    print(f"  {old.name:<16} -> {new.name}")
15
16if not plan:
17    print("Nothing to rename.")
18elif input(f"Rename {len(plan)} files? (y/n) ").lower() == "y":
19    for old, new in plan:
20        if new.exists():
21            print(f"  skipped {old.name}: {new.name} exists")
22        else:
23            old.rename(new)
24    print("Done.")
25else:
26    print("No changes made.")

The program now runs in two phases. First it builds a plan, a list of (old, new) pairs, and prints it. Nothing on disk has changed at that point. Then it asks. Only after a “y” does it touch any files, and even then it refuses to overwrite a file that already exists. That new.exists() check is important, because on some systems, renaming onto an existing name silently replaces it.

Building a plan first and acting second feels like extra work. It’s the difference between a tool you trust and a tool you’re scared of.

#Sorting files

The next classic job is sorting files into folders. The simplest approach is by type: images in one folder, documents in another. The file extension tells you the type, so all you need is a dictionary from extension to folder name:

Python
1CATEGORIES = {
2    "Images": [".jpg", ".jpeg", ".png", ".gif", ".heic"],
3    "Documents": [".pdf", ".docx", ".txt", ".pptx", ".md"],
4    "Spreadsheets": [".xlsx", ".csv"],
5    "Audio": [".mp3", ".wav", ".m4a"],
6    "Archives": [".zip", ".tar", ".gz"],
7}
8
9
10def category_for(path):
11    ext = path.suffix.lower()
12    for category, extensions in CATEGORIES.items():
13        if ext in extensions:
14            return category
15    return "Other"

path.suffix.lower() handles the photos that came off a phone as IMG_0012.JPG with capital letters. Anything we don’t recognise goes into “Other,” so no file is ever left without a home.

Moving a file into a folder uses shutil.move(), which works across drives and handles a few edge cases that .rename() doesn’t:

Python
1import shutil
2
3target_folder = folder / category_for(file)
4target_folder.mkdir(exist_ok=True)
5shutil.move(file, target_folder / file.name)

Sorting by date is just as easy, using the modification time you met in Chapter 13. Each file’s stat().st_mtime can be turned into a readable date with datetime.fromtimestamp(), and then you can group files into folders like 2026-11:

Python
1from datetime import datetime
2
3stamp = datetime.fromtimestamp(file.stat().st_mtime)
4month_folder = folder / f"{stamp:%Y-%m}"

That’s a lovely way to tame a folder of thousands of photos: one folder per month, created automatically.

#Safe automation strategies

We’ve already met dry runs and the “don’t overwrite” check. Let’s gather all the safety habits together, because a file-moving program should have every one of them. Figure 14.1 shows the shape.

Plan first, act last: a safe shape for file automation
Plan first, act last: a safe shape for file automation

Plan, show, confirm, act: Never move or rename anything in the same loop that decides what to do. Build the full plan first, show it, and ask.

Handle name collisions: If Documents/notes.txt already exists and you move another notes.txt there, one of them disappears. The usual fix is to add a number: notes.txt, then notes_1.txt, then notes_2.txt. Here’s a function for that:

Python
1def free_name(path):
2    """Return path, or path with _1, _2... if it's taken."""
3    candidate = path
4    n = 1
5    while candidate.exists():
6        new_name = f"{path.stem}_{n}{path.suffix}"
7        candidate = path.with_name(new_name)
8        n += 1
9    return candidate

Hannah, a wedding photographer, is the reason this function is in the book. She shot a wedding with two cameras, then ran a script to sort the photos into folders by hour. Both cameras numbered their files the same way, so there were two IMG_0412.jpgs, two IMG_0413.jpgs, and about six hundred other pairs. Luckily, her script did a dry run first. She was skimming the plan when she noticed the same target name appearing twice in a row, stopped, and added a collision check. Without the dry run, half the bride’s photos would have silently replaced the other half.

Leave things you didn’t create alone: Skip folders, skip hidden files (names starting with a dot, which often hold system settings), and skip the program’s own files.

Keep an undo log: Every time you move a file, write down where it came from and where it went. If something goes wrong, or you just change your mind, a second command can read the log and put everything back. Of all the habits, this is the one that lets you sleep at night.

Stop at the first sign of trouble: If moving a file fails, perhaps because it’s open in another program, it’s usually better to log the problem and skip that file than to crash halfway with half the files moved and no record.

#Project: Automatic File Organizer

Let’s put every one of those habits into a real tool. organizer.py sorts a folder by file type. It builds a plan, shows it as a dry run, asks before acting, avoids overwriting anything, records every move in an undo log, and can reverse the whole lot with one command:

Figure 14.2 shows the program’s decisions. Look at how many ways it has to stop safely before a single file is moved: if the folder doesn’t exist, if there’s nothing to do, or if you answer anything other than y. Only the path all the way down the left-hand side actually changes anything.

Flowchart for the file organizer: many safe ways to stop
Flowchart for the file organizer: many safe ways to stop
Python
1# organizer.py - sort a folder into subfolders by file type
2import csv
3import shutil
4import sys
5from pathlib import Path
6
7CATEGORIES = {
8    "Images": [".jpg", ".jpeg", ".png", ".gif", ".heic"],
9    "Documents": [".pdf", ".docx", ".txt", ".pptx", ".md"],
10    "Spreadsheets": [".xlsx", ".csv"],
11    "Audio": [".mp3", ".wav", ".m4a"],
12    "Archives": [".zip", ".tar", ".gz"],
13}
14UNDO_LOG = "organizer_undo.csv"
15
16
17def category_for(path):
18    ext = path.suffix.lower()
19    for category, extensions in CATEGORIES.items():
20        if ext in extensions:
21            return category
22    return "Other"
23
24
25def free_name(path):
26    candidate, n = path, 1
27    while candidate.exists():
28        new_name = f"{path.stem}_{n}{path.suffix}"
29        candidate = path.with_name(new_name)
30        n += 1
31    return candidate
32
33
34def make_plan(folder):
35    """Return the planned (source, destination) moves."""
36    plan = []
37    for item in sorted(folder.iterdir()):
38        if item.is_dir() or item.name.startswith("."):
39            continue
40        if item.name == UNDO_LOG:
41            continue
42        destination = folder / category_for(item) / item.name
43        plan.append((item, destination))
44    return plan
45
46
47def carry_out(plan, folder):
48    moved = 0
49    with open(folder / UNDO_LOG, "a", newline="") as log:
50        writer = csv.writer(log)
51        for source, destination in plan:
52            destination.parent.mkdir(exist_ok=True)
53            destination = free_name(destination)
54            try:
55                shutil.move(source, destination)
56            except OSError as error:
57                print(f"  could not move {source.name}: {error}")
58                continue
59            writer.writerow([source, destination])
60            moved += 1
61    return moved
62
63
64def undo(folder):
65    log_path = folder / UNDO_LOG
66    if not log_path.exists():
67        print("Nothing to undo.")
68        return
69    with open(log_path, newline="") as log:
70        moves = list(csv.reader(log))
71    for source, destination in reversed(moves):
72        if Path(destination).exists():
73            shutil.move(destination, free_name(Path(source)))
74    log_path.unlink()
75    for category in list(CATEGORIES) + ["Other"]:
76        sub = folder / category
77        if sub.is_dir() and not any(sub.iterdir()):
78            sub.rmdir()            # remove folders we emptied
79    print(f"Put back {len(moves)} file(s).")
80
81
82def main():
83    default = Path(__file__).parent / "messy_downloads"
84    folder = Path(sys.argv[1]) if len(sys.argv) > 1 else default
85    if not folder.is_dir():
86        print(f"{folder} is not a folder.")
87        return
88
89    choice = input("(o)rganise or (u)ndo? ").strip().lower()
90    if choice == "u":
91        undo(folder)
92        return
93
94    plan = make_plan(folder)
95    if not plan:
96        print("Nothing to organise.")
97        return
98    print(f"\nDry run for {folder.name}/:")
99    for src, dest in plan:
100        print(f"  {src.name:<20} -> {dest.parent.name}/")
101    if input(f"\nMove {len(plan)} files? (y/n) ").lower() == "y":
102        print(f"Moved {carry_out(plan, folder)} file(s). "
103              f"Run again and choose 'u' to undo.")
104    else:
105        print("No changes made.")
106
107
108if __name__ == "__main__":
109    main()

The functions mirror the safety pipeline exactly. make_plan() decides what should happen and changes nothing. main() shows the plan and asks. carry_out() does the moving, avoids collisions with free_name(), skips any file it can’t move, and records each successful move. undo() reads that record backwards, puts everything back where it came from, and removes any category folders it left empty.

Two small new tools appear. The undo log uses Python’s csv module, which writes and reads rows of values safely, even if a file name contains a comma. You’ll see much more of it in the next chapter. (The newline="" argument is something the csv module asks for when opening files; just include it.) And sys.argv holds any extra words typed after the program name on the command line. So python organizer.py ~/Downloads organises your real Downloads folder, while running it with no extra words uses the practice folder. That makes the tool usable from a terminal, a shortcut, or a scheduled task, all without changing the code.

Here’s a run on the practice folder:

Output
(o)rganise or (u)ndo? o

Dry run for messy_downloads/:
  IMG_2041.jpg         -> Images/
  IMG_2042.jpg         -> Images/
  IMG_2043.jpg         -> Images/
  budget.xlsx          -> Spreadsheets/
  draft (1).docx       -> Documents/
  invoice_march.pdf    -> Documents/
  notes.txt            -> Documents/
  photo.png            -> Images/
  report_final.pdf     -> Documents/
  setup.zip            -> Archives/
  slides.pptx          -> Documents/
  song.mp3             -> Audio/

Move 12 files? (y/n) y
Moved 12 file(s). Run again and choose 'u' to undo.

Figure 14.3 shows the result: twelve files in one folder became five tidy categories.

Before and after running the organizer
Before and after running the organizer

Now run it again and choose u. Everything goes back where it was. Run make_messy_folder.py and organizer.py a few times, organising and undoing, until you trust it. Only then point it at a real folder, and even then, the first time, maybe a copy of one.

Some ways to extend it:

  • Add a choice to sort by month instead of by type, using the date code from earlier.
  • Add your own categories, like “Code” for .py files or “Videos” for .mp4.
  • Add the organizer as a task in the automation toolkit from Chapter 13. You already have everything you need: a run() method that calls make_plan() and carry_out(), with the folder read from toolkit.ini.

Files and folders are the foundation of almost every automation. Next, we’ll look inside the files themselves, starting with the format that runs half the world’s offices: spreadsheets.

#On the Job

How it shows up at work: File automation is one of the most common reasons people outside IT learn Python at all. Offices are full of jobs like “rename these scans to the client’s name and date,” “move last month’s invoices into an archive folder,” or “collect every report from these forty folders into one place.” In technical jobs, the same skills turn up in cleaning up data exports, managing log files, and preparing files for other systems.

What employers expect: Use pathlib and shutil confidently, and above all, be safe: a dry run by default, a confirmation before anything changes, collision handling, and a way to undo. Someone who automates a task but loses a file along the way has made things worse, and that’s how employers will see it.

Interview questions you might meet:

  • “How would you find every .csv file in a folder and all its subfolders?” Path(folder).rglob("*.csv").
  • “What’s the difference between shutil.copy, shutil.move and Path.rename?” Copy leaves the original in place. Move relocates it, even to another drive. rename changes the name or location on the same drive, and on some systems silently overwrites an existing file.
  • “What’s a dry run, and why bother?” Showing exactly what a program would do, without doing it. It’s the cheapest safety check there is. Hannah’s story is a good example to have ready.
  • “How would you make a file-moving script undoable?” Record every (from, to) pair in a log as you go, and write a command that replays the log backwards.

Portfolio task: Push the file organizer, including its undo feature. In the README, show a before-and-after folder listing and the dry-run output. Then write a short “Safety” section listing the protections the program has. That section alone tells a reviewer a lot about how you think.

Professional habit: Never test file automation on real files first. Make a playground folder of fake files, as this chapter did, and only point the program at real data once it has behaved perfectly there.

#Exercises

Solutions are in solutions/chapter_14 in the book’s GitHub repository. Every solution builds its own playground folder of fake files to work on, so you can run them without risking anything of yours.

Warm-up

Exercise 14.1. Write a script that creates a folder called playground containing ten empty files with a mix of extensions: .txt, .jpg, .pdf and .csv.

Exercise 14.2. List every file in playground, with its size in bytes, sorted by name.

Exercise 14.3. Count how many files of each extension there are in playground. Print the results like .txt: 3.

Practice

Exercise 14.4. Rename every .txt file in playground so its name is in lower case with spaces replaced by underscores. Do a dry run first that prints each old -> new pair, and only rename after the user types y.

Exercise 14.5. Add a date prefix to every file, so that report.pdf becomes 2026-11-05_report.pdf, using the date each file was last modified. Skip files that already start with a date.

Exercise 14.6. Write a script that moves every image (.jpg, .jpeg, .png) from playground into playground/Images, creating the folder if needed, and uses free_name() so nothing is ever overwritten.

Exercise 14.7. Find duplicate file names across a folder and all its subfolders: print any name that appears in more than one place, with every location.

Exercise 14.8. Write a script that numbers photos in order of their modification time: holiday_001.jpg, holiday_002.jpg, and so on. The user chooses the word at the start.

Challenge

Exercise 14.9. Hannah’s problem. Create a playground with two subfolders, camera_a and camera_b, each containing IMG_0001.jpg to IMG_0005.jpg. Write a script that gathers every photo into one all_photos folder, renaming them so none is lost (for example, a_IMG_0001.jpg). Check at the end that the number of files matches.

Exercise 14.10. Write an “archive old files” script: files not modified in the last N days (the user chooses N) are moved into archive/<year>-<month>/ folders, based on their modification date. Record every move in a CSV file, moves.csv.

Exercise 14.11. Write the matching undo script for Exercise 14.10. It reads moves.csv and puts every file back, in reverse order, skipping (and reporting) any file that’s no longer where the log says it should be.

Exercise 14.12. Write a folder-size report: for each subfolder of a folder, print its total size in KB and how many files it contains, biggest first.