🐍 Python 3.10+
Bulk File Renamer
Renames files using regex search/replace or sequential numbering, with a dry-run preview by default before anything is touched.
bulk_file_renamer.py
#!/usr/bin/env python3
"""
bulk_file_renamer.py
Renames files in a folder using a search/replace pattern (regex supported)
or sequential numbering. Dry-run by default so you can preview changes
before anything is touched.
Usage:
python bulk_file_renamer.py <folder> --pattern "IMG_(\\d+)" --replace "Photo_\\1"
python bulk_file_renamer.py <folder> --sequence "vacation_{:03d}" --ext .jpg
python bulk_file_renamer.py <folder> --pattern "IMG_(\\d+)" --replace "Photo_\\1" --apply
Without --apply, the script only prints what it WOULD rename (dry run).
"""
import argparse
import re
import sys
from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bulk rename files in a folder.")
parser.add_argument("folder", type=Path, help="Folder containing files to rename")
parser.add_argument("--pattern", help="Regex pattern to match against filenames (without extension)")
parser.add_argument("--replace", help="Replacement string, may use \\1, \\2 backreferences")
parser.add_argument("--sequence", help="Sequential name template, e.g. 'photo_{:03d}'")
parser.add_argument("--ext", help="Only process files with this extension, e.g. .jpg")
parser.add_argument("--apply", action="store_true", help="Actually perform the rename (default: dry run)")
return parser.parse_args()
def collect_files(folder: Path, ext: str | None) -> list[Path]:
if not folder.is_dir():
sys.exit(f"Error: '{folder}' is not a valid folder.")
files = sorted(p for p in folder.iterdir() if p.is_file())
if ext:
ext = ext if ext.startswith(".") else f".{ext}"
files = [f for f in files if f.suffix.lower() == ext.lower()]
return files
def rename_with_pattern(files: list[Path], pattern: str, replace: str, apply: bool) -> int:
regex = re.compile(pattern)
count = 0
for f in files:
new_stem = regex.sub(replace, f.stem)
if new_stem == f.stem:
continue
new_path = f.with_name(new_stem + f.suffix)
print(f"{'RENAME' if apply else 'WOULD RENAME'}: {f.name} -> {new_path.name}")
if apply:
f.rename(new_path)
count += 1
return count
def rename_with_sequence(files: list[Path], template: str, apply: bool) -> int:
count = 0
for i, f in enumerate(files, start=1):
new_name = template.format(i) + f.suffix
new_path = f.with_name(new_name)
print(f"{'RENAME' if apply else 'WOULD RENAME'}: {f.name} -> {new_path.name}")
if apply:
f.rename(new_path)
count += 1
return count
def main() -> None:
args = parse_args()
files = collect_files(args.folder, args.ext)
if not files:
print("No matching files found.")
return
if args.sequence:
count = rename_with_sequence(files, args.sequence, args.apply)
elif args.pattern and args.replace is not None:
count = rename_with_pattern(files, args.pattern, args.replace, args.apply)
else:
sys.exit("Error: specify either --sequence, or both --pattern and --replace.")
print(f"\n{count} file(s) {'renamed' if args.apply else 'would be renamed'}.")
if not args.apply:
print("Run again with --apply to perform the rename.")
if __name__ == "__main__":
main()