Documentation

Immich Exif is a command-line tool. Point it at your Immich server, pick what to process, and it writes the metadata Immich knows back into your files.

Installation

You need exiftool on your PATH, an Immich server, and an API key. To build from source you also need Go 1.24+.

git clone https://github.com/Majorfi/immich-exif
cd immich-exif/src
go build -o immich-exif .

Or grab a prebuilt binary from the releases page.

No exiftool at hand (a NAS, a bare server)? The Docker image bundles it:

docker run --rm \
  -e IMMICH_URL=https://your-immich-server.com \
  -e IMMICH_API_KEY=your-api-key \
  ghcr.io/majorfi/immich-exif:latest -dry-run <asset-id>

Configuration

Credentials come from flags, environment variables, or a .env file in the working directory. The key is sent only to the server you configure.

# .env
IMMICH_URL=https://your-immich-server.com
IMMICH_API_KEY=your-api-key

API key permissions

On Immich 1.113+ you can scope the key to exactly what the tool needs (older servers issue all-or-nothing keys). A normal run that re-uploads and replaces assets uses:

PermissionWhy
server.aboutServer-version detection. Optional with a forced -immich-api.
asset.readRead asset metadata and page the library and albums.
asset.downloadDownload the original file.
asset.uploadRe-upload the metadata-corrected file.
asset.copyCopy associations (albums, favorites, …) to the new asset.
asset.updateRestore visibility for archived or hidden assets.
asset.deleteRemove the old original after a verified replacement.
album.readResolve -album and -album all selections.

-dry-run and -export-dir never write to the server, so they only need server.about, asset.read, asset.download, and album.read (drop the last one if you pass asset IDs directly).

Quick start

Preview a single photo first. Nothing is written until you are happy with the diff.

# preview one asset, write nothing
immich-exif -dry-run <asset-id>

# then process the whole library, no prompts
immich-exif -y -all

Selecting assets

Every run needs exactly one selector:

immich-exif <asset-id> <asset-id>      # specific assets
immich-exif -album <album-id>          # one album
immich-exif -album <id1> -album <id2>  # several albums
immich-exif -album all                 # every album
immich-exif -all                       # the whole library

-all and -album all are the same selector. Assets with no metadata to embed, and assets already in sync, are skipped either way.

Need an album ID? List them with -list-albums. It prints each album's ID, name and asset count, then exits.

Flags

FlagDefaultDescription
-url$IMMICH_URLImmich server URL.
-api-key$IMMICH_API_KEYAPI key from Account Settings → API Keys.
-immich-apiautoAPI contract: auto (detects; assumes v3 when unsure), v3, or legacy.
-dry-runfalseEmbed tags into a local copy and show the diff, but write nothing back.
-yfalseAuto-confirm every change (no prompts). Required for parallel workers.
-workers1Number of assets processed in parallel. Only applies with -y.
-albumAlbum ID to process. Repeatable, or all to cover every album.
-allfalseProcess the whole library. Same selector as -album all.
-export-dirWrite modified files to a directory instead of re-uploading.
-include-no-albumtrueIn mirrored export, put assets with no album under no-album/.
-no-verify-uploadfalseSkip the post-upload checksum verification; the original is moved to Immich's trash instead of being permanently deleted.
-allow-httpfalseAllow a plaintext http:// server URL. The API key is sent in clear text.
-list-albumsfalseList your albums (ID and name) and exit.
-resolve-duplicatefalseOn a duplicate upload, move associations to the duplicate and trash the old asset.
-forcefalseIgnore the state cache and re-check every asset. Only valid with -all or -album all.
-versionfalsePrint the version and exit.

What it writes

Images get the full set below. Supported videos (mp4, mov, m4v) get a compatible subset: description, rating, GPS, location, dates and camera fields.

CategoryTagsNotes
GPSGPSLatitude, GPSLatitudeRef, GPSLongitude, GPSLongitudeRef, XMP-exif:GPSLatitude, XMP-exif:GPSLongitudeRef derived from the coordinate sign; XMP uses signed values.
DescriptionImageDescription, XPComment, XMP-dc:Description, IPTC:Caption-AbstractEXIF, Windows, XMP Dublin Core and IPTC at once.
RatingRating, RatingPercent, XMP-xmp:RatingPercent = rating x 20; skipped when the rating is 0.
LocationIPTC:City, XMP-photoshop:City, IPTC:Province-State, XMP-photoshop:State, IPTC:Country-PrimaryLocationName, XMP-photoshop:CountryDual IPTC and XMP-photoshop.
DateTimeDateTimeOriginal, OffsetTimeOriginal, TimeZoneOffset, XMP-exif:DateTimeOriginal, XMP-xmp:CreateDateXMP uses ISO 8601. See Dates & timezones below.
CameraMake, Model, LensModelOnly written when the file has no existing value.

Dates & timezones

Immich returns ISO 8601 dates like 2025-12-10T16:56:36+00:00. EXIF stores local time plus a separate offset. Immich Exif reconciles the two:

  • If the file has a date but no offset, the local time is kept and the offset is computed from Immich's UTC time, then written as OffsetTimeOriginal and TimeZoneOffset.
  • If the file has no date at all, DateTimeOriginal is written in EXIF format with the offset tags.
  • If everything already matches, the asset is skipped.

Export mode

With -export-dir, modified files are written to disk instead of being re-uploaded. Nothing on the server is touched.

  • One -album: files go to /<export-dir>/<album-id>/.
  • -all or -album all: assets are mirrored per album folder, shared assets appearing in each. Assets with no album go to no-album/ (disable with -include-no-album=false).
  • copyFile refuses to overwrite an existing file, so an export never clobbers what's already there.

Incremental cache

In -all / -album all runs, a local SQLite cache records which assets are done, so the next run skips anything Immich hasn't changed.

  • Stored at ~/.config/immich-exif/state.db (macOS: ~/Library/Application Support/), keyed per server URL.
  • Only finalized outcomes are cached: migrated, replaced in-place, or confirmed already-matching.
  • dry-run, export, and duplicate/replaced statuses are never cached.
  • Use -force to re-check everything; delete state.db to reset.

Safety

Immich Exif re-uploads and deletes real assets, so the destructive path is the careful one.

  • The new asset is uploaded and its associations copied before the old one is removed. An interruption leaves a duplicate, never a hole.
  • Checksum verification is on by default: the uploaded asset is re-fetched and its checksum compared to the local file, and downloads are verified the same way. A mismatch refuses to delete the original.
  • Permanent only when verified: a verified original is deleted permanently, because the new copy is provably byte-identical. With -no-verify-upload the check is skipped and the original is moved to Immich's trash instead, where it stays recoverable.
  • By default a plaintext http:// server URL is rejected so the API key never travels in clear text; pass -allow-http to override that.
  • -dry-run shows every change and writes nothing, so you can confirm exactly what will happen first.

Interactive mode

Immich Exif prints a diff per asset and waits for a single keypress:

[1/5] 2 EXIF mismatch found for IMG_1234.jpg:
    + OffsetTimeOriginal  (none)  -> +01:00
    ~ Rating              3       -> 5

[y] confirm  [s] skip  [q] quit:

No Enter needed. Interactive mode runs single-worker; -y auto-confirms and enables parallel workers.