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.
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.
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.
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)
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.
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
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.
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.")
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.
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()
$ 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.
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.
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.
new_name = path.name.replace(" ", "-").lower()
Add a real --help with argparse, which also makes --apply a proper flag and validates arguments for you.
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.
- rename_files.py (finished tool) Dry-run-by-default bulk renamer.
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"))
An f-string does it in one line: f"{prefix}-{index:02d}{suffix.lower()}".
def new_name(index, prefix, suffix):
return f"{prefix}-{index:02d}{suffix.lower()}"
print(new_name(1, "vacation", ".JPG"))
print(new_name(12, "vacation", ".jpg"))
vacation-01.jpg
vacation-12.jpg
✓ Names look right!
assert new_name(1, "vacation", ".JPG") == "vacation-01.jpg", "zero-pad and lower-case the extension"
assert new_name(12, "vacation", ".jpg") == "vacation-12.jpg"
print("✓ Names look right!")