Safely Rename Files in Bulk

Rename a folder full of messily-named files to a clean, numbered scheme — with a dry-run that shows exactly what will happen before a single file is touched.

Intermediate 30–45 minutes

About this project

Sooner or later you meet a folder of files named IMG_2201.JPG, IMG 2202.jpeg, Photo 4.png, and you want them tidy and numbered. Doing it by hand is slow and error-prone. Doing it with Python is fast, but a careless script can mangle a whole folder in a second. So we’ll build one that is safe first, fast second.

Why it’s worth building: the real lesson here isn’t renaming. It’s the dry-run habit. A tool that shows you exactly what it will do before it does anything is the difference between a helpful script and a data-loss story. That habit carries into every destructive automation you’ll ever write.

The goal: consistent, numbered names, previewed safely before anything changes.

Build it step by step

We’ll build the safe parts first, finding the files and planning the new names, and only add the actual rename at the very end, behind a flag.

Step 1: Find just the files you mean

pathlib makes listing a folder easy. We keep only real files (not sub-folders) whose extension matches the one we want, comparing in lower case so .JPG and .jpg are treated the same.

Example
from pathlib import Path


def matching_files(folder, suffix):
    """Sorted list of files in `folder` with the given extension."""
    return sorted(
        p for p in Path(folder).iterdir()
        if p.is_file() and p.suffix.lower() == suffix.lower()
    )


# example against the current folder:
for path in matching_files(".", ".py"):
    print(path.name)
iterdir() lists a folder; the filter keeps only the files you asked for.

Step 2: Plan the new names (touch nothing yet)

This is the safe heart of the tool. We build a list of (old, new) pairs without renaming anything. enumerate(files, start=1) numbers them, and {number:02d} zero-pads so 2 becomes 02, which keeps them sorted correctly later.

Example
def planned_renames(folder, prefix, suffix):
    """Return a list of (old_path, new_path) — computed, not applied."""
    plans = []
    for number, path in enumerate(matching_files(folder, suffix), start=1):
        new_name = f"{prefix}-{number:02d}{suffix.lower()}"
        plans.append((path, path.with_name(new_name)))
    return plans
A pure planning function: it decides the new names but changes nothing.

Step 3: Dry-run by default, act only on request

Now the payoff. By default the tool prints the plan and stops. Only when you pass --apply does it call path.rename() for real. That one design choice makes the tool safe to run on anything.

Example
apply = "--apply" in sys.argv

for old, new in planned_renames(folder, prefix, suffix):
    print(f"{old.name}  ->  {new.name}")
    if apply:
        old.rename(new)

if not apply:
    print("\n(dry run) Re-run with --apply to rename for real.")
No --apply, no changes. The default is always the safe one.

The finished tool

The complete script: it previews by default and only renames when you add --apply. Run python rename_files.py ./photos vacation .jpg to preview, then add --apply when the plan looks right.

Example · rename_files.py
from pathlib import Path
import sys


def matching_files(folder, suffix):
    """Sorted list of files in `folder` with the given extension."""
    return sorted(
        p for p in Path(folder).iterdir()
        if p.is_file() and p.suffix.lower() == suffix.lower()
    )


def planned_renames(folder, prefix, suffix):
    """Return a list of (old_path, new_path), computed, not applied."""
    plans = []
    for number, path in enumerate(matching_files(folder, suffix), start=1):
        new_name = f"{prefix}-{number:02d}{suffix.lower()}"
        plans.append((path, path.with_name(new_name)))
    return plans


def main():
    if len(sys.argv) < 4:
        print("Usage: python rename_files.py <folder> <prefix> <.ext> [--apply]")
        raise SystemExit(1)

    folder, prefix, suffix = sys.argv[1], sys.argv[2], sys.argv[3]
    apply = "--apply" in sys.argv[4:]

    plans = planned_renames(folder, prefix, suffix)
    if not plans:
        print("No matching files found.")
        return

    for old, new in plans:
        print(f"{old.name}  ->  {new.name}")
        if apply:
            old.rename(new)

    if apply:
        print(f"\nRenamed {len(plans)} files.")
    else:
        print(f"\n(dry run) {len(plans)} files would be renamed. "
              "Re-run with --apply to do it.")


if __name__ == "__main__":
    main()
Output
$ python rename_files.py ./photos vacation .jpg
IMG_2201.jpg  ->  vacation-01.jpg
IMG_2202.jpg  ->  vacation-02.jpg
Photo 4.jpg   ->  vacation-03.jpg

(dry run) 3 files would be renamed. Re-run with --apply to do it.
A dry run: it shows the plan and changes nothing until you add --apply.

Keep going, make it your own

The safe core is done. These upgrades make it sturdier on real folders.

Refuse to overwrite. Before renaming, check whether the target already exists (or two plans share a name) and skip with a warning. A one-line set of target names catches collisions.

Example
targets = [new for _, new in plans]
if len(set(targets)) != len(targets):
    print("Refusing to run: two files would get the same name.")
    raise SystemExit(1)

Replace spaces with hyphens instead of renumbering, sometimes you just want to tidy names. name.replace(" ", "-").lower() is a gentle, predictable transform.

Example
new_name = path.name.replace(" ", "-").lower()

Add a real --help with argparse, which also makes --apply a proper flag and validates arguments for you.

Example
import argparse

parser = argparse.ArgumentParser(description="Bulk-rename files safely.")
parser.add_argument("folder")
parser.add_argument("prefix")
parser.add_argument("suffix")
parser.add_argument("--apply", action="store_true")

Download the files

Download the finished script. Try it on a <strong>copy</strong> of a folder: <code>python rename_files.py ./copy myprefix .jpg</code> previews; add <code>--apply</code> to commit.


Mini exercise (easy)

Write the naming rule: new_name(index, prefix, suffix) that returns names like "vacation-01.jpg", the prefix, a hyphen, the index zero-padded to two digits, then the lower-cased extension.

Take the wheel. Complete the code, hit Run, and check your output right here.

def new_name(index, prefix, suffix):
    # e.g. new_name(1, "vacation", ".JPG") -> "vacation-01.jpg"
    return ""

print(new_name(1, "vacation", ".JPG"))
print(new_name(12, "vacation", ".jpg"))

Where to go next

The dry-run habit you practised here belongs in every destructive script. To give this tool professional flags and help text, work through Building a Command-Line Tool with argparse; to organise it into a reusable project, see Organizing a Python Project.