Overview
Renaming large photo collections is tedious by hand. This guide shows a practical Python approach to batch-rename photos using EXIF capture dates (with fallbacks), safe collision handling, and a dry-run mode. It fits the Automate boring tasks with Python mindset: small script, readable logic, no fragile shell one-liners.
What you’ll get:
- Date-stamped filenames like 2021-08-14_15-32-07.jpg
- Stable, collision-free renames with incremental suffixes
- Dry-run preview before changes
- Recursive processing and glob filtering
Quickstart
- Requirements
- Python 3.9+
- Pillow for EXIF parsing
- Install
pip install pillow
Save the script below as rename_photos.py
Preview changes (dry-run)
python rename_photos.py /path/to/photos --glob "*.jpg" --recursive --dry-run
- Apply changes
python rename_photos.py /path/to/photos --glob "*.*" --recursive
Minimal working example (script)
#!/usr/bin/env python3
import argparse
from datetime import datetime
from pathlib import Path
from typing import Optional
# Optional dependency for more formats (HEIC) may be required; Pillow supports common JPEG/PNG.
from PIL import Image
IMAGE_LIKE_EXTS = {
".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".heic", ".heif", ".dng", ".cr2", ".nef", ".arw", ".orf", ".rw2", ".raf",
}
VIDEO_LIKE_EXTS = {".mp4", ".mov", ".avi", ".m4v"} # fallback to file mtime
def parse_exif_datetime(dt_val: str) -> Optional[datetime]:
if isinstance(dt_val, bytes):
try:
dt_val = dt_val.decode("utf-8", errors="ignore")
except Exception:
return None
dt_val = str(dt_val).strip()
for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(dt_val, fmt)
except ValueError:
pass
return None
def exif_datetime(path: Path) -> Optional[datetime]:
ext = path.suffix.lower()
if ext not in IMAGE_LIKE_EXTS:
return None
try:
with Image.open(path) as img:
exif = getattr(img, "_getexif", lambda: None)()
if not exif:
return None
# EXIF tags: 36867 DateTimeOriginal, 36868 DateTimeDigitized, 306 DateTime
for tag in (36867, 36868, 306):
if tag in exif:
dt = parse_exif_datetime(exif[tag])
if dt:
return dt
except Exception:
return None
return None
def proposed_basename(path: Path) -> str:
dt = exif_datetime(path)
if dt is None:
# Fallback: file modification time
dt = datetime.fromtimestamp(path.stat().st_mtime)
return dt.strftime("%Y-%m-%d_%H-%M-%S")
def unique_target(path: Path, base: str) -> Path:
ext = path.suffix.lower()
directory = path.parent
candidate = directory / f"{base}{ext}"
i = 1
while candidate.exists() and candidate.resolve() != path.resolve():
candidate = directory / f"{base}-{i}{ext}"
i += 1
return candidate
def iter_files(root: Path, pattern: str, recursive: bool):
if recursive:
yield from root.rglob(pattern)
else:
yield from root.glob(pattern)
def should_process(p: Path) -> bool:
if not p.is_file():
return False
# Process any file if user’s glob matched; photos/videos are common targets
return True
def main():
ap = argparse.ArgumentParser(description="Batch-rename photos using EXIF dates or file times.")
ap.add_argument("root", type=Path, help="Root directory to scan")
ap.add_argument("--glob", default="*.*", help="Glob pattern (e.g., '*.jpg', '*.*')")
ap.add_argument("--recursive", action="store_true", help="Recurse into subfolders")
ap.add_argument("--dry-run", action="store_true", help="Preview without renaming")
ap.add_argument("--prefix", default="", help="Optional prefix to prepend (e.g., 'Trip_')")
args = ap.parse_args()
root: Path = args.root.expanduser().resolve()
if not root.exists():
raise SystemExit(f"Not found: {root}")
total = 0
changed = 0
for p in iter_files(root, args.glob, args.recursive):
if not should_process(p):
continue
total += 1
base = proposed_basename(p)
if args.prefix:
base = f"{args.prefix}{base}"
target = unique_target(p, base)
if target == p:
continue
if args.dry_run:
print(f"DRY: {p} -> {target.name}")
else:
try:
p.rename(target)
print(f"RENAMED: {p.name} -> {target.name}")
changed += 1
except Exception as e:
print(f"SKIP ({e}): {p}")
if args.dry_run:
print(f"Dry-run complete. Consider removing --dry-run to apply.")
print(f"Files scanned: {total}; Renamed: {changed}")
if __name__ == "__main__":
main()
How it works
- EXIF read: For images, the script attempts DateTimeOriginal, then DateTimeDigitized, then DateTime.
- Fallback: If EXIF is missing/unsupported (videos, scans), it uses file modification time.
- Naming: yyyy-mm-dd_HH-MM-SS plus -N suffix to avoid collisions.
- Safety: Dry-run shows planned changes. Actual renames occur in-place, preserving extensions.
Step-by-step recipe
- Collect files with a glob
- Use --glob ".jpg" for only JPEGs, or ".*" for everything.
- Preview
- Run with --dry-run to inspect the proposed mapping.
- Execute
- Rerun without --dry-run when satisfied.
- Optional prefix
- Add --prefix Trip_ to group a specific import or album.
- Subfolders
- Include --recursive to scan nested directories.
Customizing the pattern
- Change the format string in proposed_basename to suit your scheme:
- Compact: dt.strftime("%Y%m%d_%H%M%S")
- Add camera model when available: you can extend exif_datetime to also read tag 272 (Model) via Pillow and append to base.
- Use --prefix to tag a batch without editing code.
Pitfalls and safety checks
- Missing EXIF: Many scans, screenshots, and some HEICs lack EXIF; fallback uses file mtime, which may reflect copy time.
- Timezones: EXIF timestamps rarely store timezone. If your camera clock was wrong, filenames will reflect that.
- Collisions: Bursts within the same second will collide; the script appends -1, -2, … to keep all files.
- Case-insensitive filesystems: On Windows/macOS, renames that only change letter case may be ignored; the script typically changes the whole stem to avoid this edge case.
- Non-image files: The script happily renames any matched file using mtime; restrict with --glob to avoid renaming unrelated docs.
- RAW/HEIC support: Pillow might not decode EXIF for every RAW/HEIC; you’ll get the mtime fallback unless you add a HEIC/RAW plugin.
- Backups: Always keep a backup, or run under versioned storage. Renames are fast and hard to undo without a log.
- Long paths (Windows): Deep paths may exceed legacy limits; Python 3.9+ handles long paths if system settings allow it.
- Symlinks: The script renames the link itself if matched, not its target.
Performance notes
- Directory walking: rglob and glob are efficient; prefer narrow globs (e.g., *.jpg) to reduce per-file work.
- EXIF cost: Reading EXIF is the slowest step. If you don’t need EXIF, remove that call to rely on mtime only.
- I/O bound: Threading offers limited benefit; disk seeks dominate. If needed, you can parallelize EXIF reads with ThreadPoolExecutor, but test carefully.
- Atomicity: On the same filesystem, rename is atomic. Moving across filesystems is not performed here; renames stay in place.
Testing on a sample
- Copy a handful of files to a temp folder.
- Run with --dry-run to see names.
- Verify collisions are resolved correctly with -N suffixes.
Tiny FAQ
- Will this change image content or metadata? No. It only renames files.
- Does it work for videos? Yes, but it uses file modification time, not embedded metadata.
- Can I undo? Not automatically. Keep a dry-run log or run under version control. You can also script a reverse map if you save prints to a file.
- Can I group by folders (e.g., year/month)? Extend target = unique_target(...) to place files into directory / f"{dt:%Y}/{dt:%m}" before renaming.
- What if two files have identical timestamps and names? The -N suffix disambiguates them so no data is lost.