Case Study 2: Bulk File Renaming (Invoice Folder)
Scenario
A folder contains approximately one hundred invoice files with inconsistent names generated from scanners, mobile devices, and manual uploads (think scan001.pdf, IMG_2044.pdf, inv_final(2).pdf). Your task is to rename all files into a consistent naming convention based on their creation or modification date, while ensuring no files are overwritten.
Suggested Libraries
pathlibosshutildatetime
Sample Files You May Create
invoices/— a folder of inconsistently named filesinvoice_renamer.py— your solution script
Requirements
- Scan all files in the invoice folder.
- Determine creation or modification dates.
- Generate a standardized naming pattern, e.g.
Invoice_<year>_<month>_<serial>.<ext>. - Prevent naming collisions.
- Ensure files are not overwritten.
- Implement a dry-run mode for validation.
- Produce a rename plan before making changes.
Have a genuine attempt at this before revealing the solution below. Pay particular attention to what happens if you run your script twice in a row — a good solution should be safe to re-run without corrupting an already-renamed folder.
💡 Show Answer
import shutil
from pathlib import Path
from datetime import datetime
from collections import defaultdict
def rename_invoices(folder_path, dry_run=True):
folder = Path(folder_path)
if not folder.is_dir():
raise NotADirectoryError(f"{folder_path} is not a valid folder")
files = sorted(
[f for f in folder.iterdir() if f.is_file()],
key=lambda f: f.stat().st_mtime # oldest first, so serials are chronological
)
# Serial numbers reset per (year, month) group, matching the requested pattern
serial_counters = defaultdict(int)
rename_plan = []
for f in files:
mod_time = datetime.fromtimestamp(f.stat().st_mtime)
year, month = mod_time.year, mod_time.strftime("%m")
key = (year, month)
serial_counters[key] += 1
serial = str(serial_counters[key]).zfill(3) # e.g. 001, 002 ...
new_name = f"Invoice_{year}_{month}_{serial}{f.suffix.lower()}"
new_path = folder / new_name
# Guard against collisions (e.g. re-running the script, or two files landing
# on the same computed name) by appending a suffix rather than overwriting.
counter = 1
while new_path.exists() and new_path != f:
new_path = folder / f"Invoice_{year}_{month}_{serial}_{counter}{f.suffix.lower()}"
counter += 1
rename_plan.append((f, new_path))
for old_path, new_path in rename_plan:
if old_path == new_path:
continue
print(f"{'[DRY RUN] ' if dry_run else ''}{old_path.name} -> {new_path.name}")
if not dry_run:
shutil.move(str(old_path), str(new_path)) # shutil.move is safe across filesystems too
return rename_plan
if __name__ == "__main__":
# Always dry-run first to review the plan before touching real files
rename_invoices("./invoices", dry_run=True)
# Once the printed plan looks correct:
# rename_invoices("./invoices", dry_run=False)
Notes:
- Sorting by modification time before assigning serials means the serial number reflects actual chronological order rather than arbitrary filesystem listing order.
shutil.moveis preferred overPath.renamewhen the destination might be on a different filesystem/drive —renamecan fail across filesystems,movehandles both cases.- The collision guard means it's safe to re-run the script on a partially-renamed folder without clobbering files.
- If "year and month" should come from the invoice's content (e.g. the invoice date printed inside a PDF) rather than the file's modified timestamp, combine this with Case Study 6's PDF text extraction to pull the real date first.