My screenshots folder currently holds 200-odd files with names like Screen Shot 2026-06-01 at 10.14.33.PNG. They're unique, they sort into chaos, and renaming them by hand is a lost afternoon. Bulk renaming is one of those jobs every scripting language claims to do, so here are the four approaches I've actually used, roughly in the order I learned them — worst to best.
The version everyone writes first, including me:
import os
folder = "screenshots"
shots = sorted(n for n in os.listdir(folder) if n.endswith(".png"))
for i, name in enumerate(shots, start=1):
os.rename(os.path.join(folder, name), os.path.join(folder, f"shot_{i:03d}.png"))
It works. The costs are quiet: os.path.join twice per line, the {i:03d} padding trick to remember, and one stray non-screenshot file shifts every number in the folder. This version is still living in a script from 2019 somewhere on an old drive of mine, and it's fine — just noisy.
The next step up is separating deciding the new names from doing the renaming:
import os
folder = "screenshots"
old_names = sorted(n for n in os.listdir(folder) if n.endswith(".png"))
new_names = (f"shot_{i:03d}.png" for i in range(1, len(old_names) + 1))
for src, dst in zip(old_names, new_names):
os.rename(os.path.join(folder, src), os.path.join(folder, dst))
The gain is that the pairs now exist before anything moves, so you can print them and look at them first. And zip stops at the shorter list, which means a bug in the name generator can't rename more files than you have. Still, all that join noise remains.
Path objects take over the joining and give you rename methods that read like English:
from pathlib import Path
# the extension swap:
for f in Path("invoices").glob("*.jpeg"):
f.rename(f.with_suffix(".jpg"))
# and a full cleanup, since my screenshots are named by an operating
# system that apparently hates me:
for f in Path("screenshots").glob("*.PNG"):
clean = f.with_name(f.stem.lower().replace(" ", "-") + f.suffix.lower())
f.rename(clean)
with_suffix() swaps the extension in one call. For anything more creative, f.stem gives you the name without the extension and f.with_name() swaps the whole filename:
One scar to save you: my first attempt ran with_suffix(".png") on the cleaned name, and it silently turned 10.14.33 into 10.14 — pathlib counts the .33 in the timestamp as an extension and replaces it. Gluing the suffix on by hand, as above, sidesteps the whole question.
Renames have no undo. A script that moves files doesn't use the trash folder, so a wrong pattern means restoring by hand. That's why the version I actually use prints its plan and does nothing else until I say otherwise:
from pathlib import Path
RENAME = False # flip to True when the list looks right
for f in Path("screenshots").glob("*.PNG"):
clean = f.with_name(f.stem.lower().replace(" ", "-") + f.suffix.lower())
if clean == f:
continue
if RENAME:
f.rename(clean)
else:
print(f.name, "->", clean.name)
Run it and you get the plan, not the damage:
Screen Shot 2026-06-01 at 10.14.33.PNG -> screen-shot-2026-06-01-at-10.14.33.png
Screen Shot 2026-06-01 at 10.21.07.PNG -> screen-shot-2026-06-01-at-10.21.07.png
Way 4, every time. The flag costs one line and buys the confidence to point the script at a real folder full of real files instead of a test copy. The zip version is my fallback when I need numbered names, and the plain os version survives only in old code that hasn't needed me yet.