Python Bulk File Renamer Project

Python project 20

Python Bulk File Renamer Project

Create a safety-focused bulk file renamer that sorts files predictably, previews every change, filters by extension, detects collisions, requires an explicit confirmation word, and uses temporary filenames to prevent overlapping-name failures.

Learn Python with mentor-guided projectsView all project ideas

What you will build

The tool creates numbered names such as photo_001.jpg. Its default mode is preview-only; files change only when the user adds --apply and then types RENAME after reviewing the plan.

Skills practised

  • Pathlib file operations
  • Dataclasses and type hints
  • Command-line options
  • Collision detection and rollback

Requirements

  • Python 3.10 or later
  • A test folder containing copied files
  • No external packages
  • About 40 minutes to build

Full Python code

Save this code as bulk_file_renamer.py, then follow the run instructions below.

"""Preview and safely rename files in one folder using numbered names."""

from __future__ import annotations

import argparse
import uuid
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Rename:
    source: Path
    destination: Path


def build_plan(
    folder: Path,
    prefix: str,
    start: int = 1,
    padding: int = 3,
    extension: str | None = None,
) -> list[Rename]:
    if not folder.is_dir():
        raise ValueError(f"Folder does not exist: {folder}")
    if not prefix or any(character in prefix for character in '<>:"/\\|?*'):
        raise ValueError("Prefix must be non-empty and cannot contain filename separators.")
    if start < 0 or padding < 1:
        raise ValueError("Start must be zero or greater, and padding must be at least 1.")

    normalized_extension = None
    if extension:
        normalized_extension = "." + extension.lower().lstrip(".")

    files = sorted(
        (
            path
            for path in folder.iterdir()
            if path.is_file()
            and (normalized_extension is None or path.suffix.lower() == normalized_extension)
        ),
        key=lambda path: path.name.casefold(),
    )
    plan = [
        Rename(source, folder / f"{prefix}_{number:0{padding}d}{source.suffix.lower()}")
        for number, source in enumerate(files, start=start)
    ]

    source_keys = {item.source.name.casefold() for item in plan}
    destination_keys: set[str] = set()
    for item in plan:
        key = item.destination.name.casefold()
        if key in destination_keys:
            raise ValueError(f"Two files would receive the same name: {item.destination.name}")
        destination_keys.add(key)
        if item.destination.exists() and key not in source_keys:
            raise ValueError(f"A different file already uses the target name: {item.destination.name}")
    return plan


def apply_plan(plan: list[Rename]) -> None:
    """Use temporary names so swaps and overlapping target names remain safe."""
    staged: list[tuple[Rename, Path]] = []
    completed: list[Rename] = []
    try:
        for item in plan:
            temporary = item.source.with_name(f".bulk-rename-{uuid.uuid4().hex}{item.source.suffix}")
            item.source.rename(temporary)
            staged.append((item, temporary))
        for item, temporary in staged:
            temporary.rename(item.destination)
            completed.append(item)
    except OSError:
        for item in reversed(completed):
            if item.destination.exists() and not item.source.exists():
                item.destination.rename(item.source)
        for item, temporary in reversed(staged[len(completed) :]):
            if temporary.exists() and not item.source.exists():
                temporary.rename(item.source)
        raise


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Preview and bulk-rename files in one folder.")
    parser.add_argument("folder", type=Path)
    parser.add_argument("--prefix", required=True)
    parser.add_argument("--start", type=int, default=1)
    parser.add_argument("--padding", type=int, default=3)
    parser.add_argument("--extension", help="Only rename this extension, for example jpg")
    parser.add_argument("--apply", action="store_true", help="Apply the previewed plan")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    try:
        plan = build_plan(args.folder, args.prefix, args.start, args.padding, args.extension)
    except (ValueError, OSError) as error:
        raise SystemExit(f"Cannot create rename plan: {error}") from error

    if not plan:
        print("No matching files found.")
        return
    print("Rename preview:")
    for item in plan:
        print(f"- {item.source.name} -> {item.destination.name}")

    if not args.apply:
        print("\nPreview only. Run again with --apply after checking every name.")
        return
    if input("Type RENAME to continue: ").strip() != "RENAME":
        print("Cancelled. No files were renamed.")
        return

    try:
        apply_plan(plan)
    except OSError as error:
        raise SystemExit(f"Rename failed; rollback was attempted: {error}") from error
    print(f"Renamed {len(plan)} file(s).")


if __name__ == "__main__":
    main()

How the project works

build_plan() sorts names without case sensitivity, optionally filters one extension, and computes every destination before changing anything. It rejects duplicate target names and a target already occupied by an unrelated file.

apply_plan() first moves every source to a unique temporary name. This two-stage process supports cases where a destination overlaps another original name. If an operating-system error occurs, the function attempts to move completed and staged files back to their original paths.

Run the project

  1. Create a separate practice folder containing copies of several files.
  2. Preview with python bulk_file_renamer.py Practice --prefix photo --extension jpg.
  3. Check every proposed source and destination.
  4. Add --apply, review again, and type RENAME only when correct.

Accuracy and safety notes

  • Preview first: the safe default performs no rename.
  • Scope: only files directly inside the selected folder are considered.
  • Backups: test with copies before processing important files.
  • Rollback: an attempted rollback cannot replace a real backup.

Ways to extend it

Add date-based names, recursive folders, a CSV mapping log, undo from the log, regular-expression replacements, or a graphical preview with checkboxes.

Continue learning Python

Build the next project, return to the Softenant project library, or explore the Python programming course in Vizag for structured lessons, mentor feedback, and portfolio practice.