Home / Guides

Python rename files from a CSV list

Last updated .

If the old and new names are already in a spreadsheet, Python can apply the list without a rename framework. This page gives a complete script that runs on macOS, Linux and Windows, previews by default, and refuses to overwrite. Copy it into rename_from_csv.py and run it with Python 3; there is nothing to install.

The CSV the script expects

Two columns, old and new, with a header row. Save the sheet as CSV UTF-8. The old name is the file as it exists in the folder; the new name is the name you want. A matching extension in the new column is ignored, because the extension of the file on disk always wins.

old,new
IMG_0001.jpg,SKU-1001
IMG_0002.jpg,SKU-1002
with space.txt,report final.txt

The script

It reads the whole mapping first, validates every row, then renames through a temporary folder. That last part matters when rows depend on each other: a→b and b→c in the same batch would lose a file if they ran top to bottom.

#!/usr/bin/env python3
"""Rename files from a two-column CSV. Dry run by default; pass --apply to rename."""
import csv
import shutil
import sys
from pathlib import Path

BAD = set('<>:"/\\|?*')

def load_plan(csv_path: Path, folder: Path):
    with csv_path.open(newline="", encoding="utf-8-sig") as fh:
        rows = list(csv.reader(fh))
    if rows and [c.strip().lower() for c in rows[0][:2]] == ["old", "new"]:
        rows = rows[1:]

    plan, errors, seen = [], [], set()
    for line, row in enumerate(rows, start=2):
        if not row or not any(cell.strip() for cell in row):
            continue
        if len(row) < 2:
            errors.append(f"line {line}: needs two columns (old,new)")
            continue
        old, new = row[0].strip(), row[1].strip()
        if not old or not new:
            errors.append(f"line {line}: old and new names are both required")
            continue
        if old != Path(old).name or "\\" in old:
            errors.append(f"line {line}: old name must be a file in the folder, not a path")
            continue
        src = folder / old
        if not src.is_file():
            errors.append(f"line {line}: no file named {old!r}")
            continue
        if any(ch in BAD for ch in new) or new.endswith((" ", ".")):
            errors.append(f"line {line}: {new!r} is not a safe file name")
            continue
        base = new[:-len(src.suffix)] if src.suffix and new.lower().endswith(src.suffix.lower()) else new
        target = src.with_name(base + src.suffix)
        if target.name == ".rename-tmp":
            errors.append(f"line {line}: .rename-tmp is reserved for the temporary folder")
            continue
        key = target.name.casefold()
        if key in seen:
            errors.append(f"line {line}: two rows produce the same name {target.name!r}")
            continue
        seen.add(key)
        plan.append((src, target))
    return plan, errors

def main():
    if len(sys.argv) not in (3, 4):
        sys.exit("usage: python rename_from_csv.py mapping.csv folder [--apply]")
    mapping, folder = Path(sys.argv[1]), Path(sys.argv[2])
    apply = "--apply" in sys.argv[3:]
    plan, errors = load_plan(mapping, folder)
    if errors:
        print("\n".join(errors), file=sys.stderr)
        sys.exit(f"stopped: {len(errors)} problem(s) found; nothing was renamed")

    sources = {src for src, _ in plan}
    for _, target in plan:
        if target.exists() and target not in sources:
            sys.exit(f"stopped: {target.name!r} already exists; nothing was renamed")

    for src, target in plan:
        print(f"{src.name} -> {target.name}")
    if not apply:
        print(f"\nDry run: {len(plan)} file(s) would be renamed. Re-run with --apply.")
        return

    temp = folder / ".rename-tmp"
    if temp.exists():
        sys.exit(f"stopped: {temp} exists; remove it after checking for leftovers")
    temp.mkdir()
    moved = []
    try:
        for i, (src, target) in enumerate(plan):
            tmp = temp / f"{i:06d}{src.suffix}"
            src.rename(tmp)
            moved.append((tmp, target, src))
        for tmp, target, _ in moved:
            tmp.rename(target)
    except OSError as exc:
        for tmp, target, src in reversed(moved):
            try:
                if tmp.exists():
                    tmp.rename(src)
                elif target.exists() and not src.exists():
                    target.rename(src)
            except OSError:
                pass
        shutil.rmtree(temp, ignore_errors=True)
        sys.exit(f"stopped: {exc}; attempted to put every file back")
    temp.rmdir()
    print(f"\nRenamed {len(plan)} file(s).")

if __name__ == "__main__":
    main()

Run it

Put the script next to the folder, or anywhere and pass the paths. Preview first:

python3 rename_from_csv.py names.csv ./photos

If every pair looks right, run the same command with --apply:

python3 rename_from_csv.py names.csv ./photos --apply

On Windows the command is python rename_from_csv.py names.csv .\photos. If Python is not installed, use the PowerShell guide or the browser tool instead.

What it refuses to do

When a script is the wrong tool

A script is fine when you are comfortable editing one and running it again next month. If you would rather not, Mass Rename does the same lookup in a browser tab: drop the CSV and the files, check the preview, download the renamed ZIP. The files are never uploaded, and the free tier covers 50 files per batch. The PowerShell and bash guides cover the same job in other shells.

Questions about renaming with Python

Does the script delete or overwrite files?

No. It checks every row before touching anything, refuses a batch with a missing source or a duplicate target, and moves files through a temporary folder so swaps and chains do not clobber each other. If a step fails it tries to put every file back.

Does it run on Windows?

Yes, with Python 3 installed. The script uses only the standard library, and pathlib handles Windows and macOS paths. Run it from Command Prompt or PowerShell with python rename_from_csv.py.

What should the CSV look like?

Two columns, old and new, with a header row. Save it as CSV UTF-8. A matching file extension in the new name is ignored: the extension is taken from the file on disk.

How do I preview before renaming?

Run the script without --apply. It prints every old to new pair and exits without touching the files. Add --apply only when the list looks right.

Why does it use a temporary folder?

Because renames can depend on each other. If one row turns a into b while another turns b into c, renaming top to bottom would destroy the file the second row still needs. Moving everything aside first makes the order irrelevant.

What if two rows ask for the same name?

The script stops before renaming anything and names the duplicate. Fix the spreadsheet and run it again; it will not guess which file should win.

Can I use this instead of installing Mass Rename?

Yes. If you would rather not run a script, Mass Rename does the same lookup in a browser tab: drop the CSV and the files, check the preview, download the renamed ZIP. Files are never uploaded.