KhueApps
Home/Python/Batch rename photos with Python via EXIF dates

Batch rename photos with Python via EXIF dates

Last updated: October 07, 2025

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

  1. Requirements
  • Python 3.9+
  • Pillow for EXIF parsing
  1. Install
pip install pillow
  1. Save the script below as rename_photos.py

  2. Preview changes (dry-run)

python rename_photos.py /path/to/photos --glob "*.jpg" --recursive --dry-run
  1. 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

  1. Collect files with a glob
  • Use --glob ".jpg" for only JPEGs, or ".*" for everything.
  1. Preview
  • Run with --dry-run to inspect the proposed mapping.
  1. Execute
  • Rerun without --dry-run when satisfied.
  1. Optional prefix
  • Add --prefix Trip_ to group a specific import or album.
  1. 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.

Series: Automate boring tasks with Python

Python