Renaming photos by EXIF date

exifmv

Phone and camera photos land in the same folder as IMG_4512.JPG and DSC08811.JPG, and neither name tells you when anything happened. I wanted names that sort by time.

Project
exifmv
Stack
Go, a pure-Go EXIF reader
Time spent
About 4 evenings
Status
in daily use

didWhat I did

The tool walks a folder, reads DateTimeOriginal from each file and renames it to YYYY-MM-DD_HHMMSS.jpg. It is a single Go binary with no runtime dependencies, which matters because I run it on a laptop and on the home server.

It does nothing by default. Without --apply it prints the plan and exits, which saved me at least twice in the first week.

exifmv, dry run~/Pictures/inbox
$ exifmv ~/Pictures/inbox
IMG_4512.JPG  -> 2026-01-31_142305.jpg
IMG_4513.JPG  -> 2026-01-31_142305-1.jpg
DSC08811.JPG  -> 2026-01-31_122309.jpg
scan_0001.png skipped (no EXIF date)
4 files, 3 renames planned.
Run with --apply to rename.
Nothing moves until --apply. Note the camera file two hours behind the phone.

If two files end up with the same name, the second gets a -1 suffix, the third -2, and so on. Files without a date are left alone and listed, so I can deal with them by hand.

brokeWhat broke

Time zones. DateTimeOriginal is a local time with no zone attached. The phone also writes OffsetTimeOriginal, so its files are correct. My camera writes nothing of the kind, and its clock was still on home time during a trip. After renaming, the camera photos sat two hours away from the phone photos of the same street.

There is no clean fix for this from the file alone. For now, the tool prints a warning when a folder mixes files with and without an offset, which at least tells me to look.

Burst shots. Several frames share the same second, and the order of the -1, -2 suffixes changed between runs. I was iterating over a map, and Go randomises map order on purpose. Sorting by time and then by SubSecTimeOriginal made it stable:

sort.SliceStable(files, func(i, j int) bool {
    if files[i].Taken.Equal(files[j].Taken) {
        return files[i].SubSec < files[j].SubSec
    }
    return files[i].Taken.Before(files[j].Taken)
})

Case. On macOS, renaming photo.JPG to photo.jpg is a no-op on the default case-insensitive disk, so the check "does the target exist?" said yes and the tool added a pointless suffix. It now compares paths case-insensitively when the file system says it should.

nextWhat's next

  • A --shift 2h flag that applies only to files from one camera model, read from the EXIF Model field.
  • An undo log: a CSV of old and new names written next to the folder, plus exifmv undo to reverse a run.
  • RAW files. Most of them carry the same tags, but the reader I use doesn't parse every format, so it may need a sidecar approach.

The undo log is the one I actually need. A rename tool without undo is a tool you only trust after it has already hurt you once.