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:
| Permission | Why |
|---|---|
| server.about | Server-version detection. Optional with a forced -immich-api. |
| asset.read | Read asset metadata and page the library and albums. |
| asset.download | Download the original file. |
| asset.upload | Re-upload the metadata-corrected file. |
| asset.copy | Copy associations (albums, favorites, …) to the new asset. |
| asset.update | Restore visibility for archived or hidden assets. |
| asset.delete | Remove the old original after a verified replacement. |
| album.read | Resolve -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
| Flag | Default | Description |
|---|---|---|
| -url | $IMMICH_URL | Immich server URL. |
| -api-key | $IMMICH_API_KEY | API key from Account Settings → API Keys. |
| -immich-api | auto | API contract: auto (detects; assumes v3 when unsure), v3, or legacy. |
| -dry-run | false | Embed tags into a local copy and show the diff, but write nothing back. |
| -y | false | Auto-confirm every change (no prompts). Required for parallel workers. |
| -workers | 1 | Number of assets processed in parallel. Only applies with -y. |
| -album | — | Album ID to process. Repeatable, or all to cover every album. |
| -all | false | Process the whole library. Same selector as -album all. |
| -export-dir | — | Write modified files to a directory instead of re-uploading. |
| -include-no-album | true | In mirrored export, put assets with no album under no-album/. |
| -no-verify-upload | false | Skip the post-upload checksum verification; the original is moved to Immich's trash instead of being permanently deleted. |
| -allow-http | false | Allow a plaintext http:// server URL. The API key is sent in clear text. |
| -list-albums | false | List your albums (ID and name) and exit. |
| -resolve-duplicate | false | On a duplicate upload, move associations to the duplicate and trash the old asset. |
| -force | false | Ignore the state cache and re-check every asset. Only valid with -all or -album all. |
| -version | false | Print 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.
| Category | Tags | Notes |
|---|---|---|
| GPS | GPSLatitude, GPSLatitudeRef, GPSLongitude, GPSLongitudeRef, XMP-exif:GPSLatitude, XMP-exif:GPSLongitude | Ref derived from the coordinate sign; XMP uses signed values. |
| Description | ImageDescription, XPComment, XMP-dc:Description, IPTC:Caption-Abstract | EXIF, Windows, XMP Dublin Core and IPTC at once. |
| Rating | Rating, RatingPercent, XMP-xmp:Rating | Percent = rating x 20; skipped when the rating is 0. |
| Location | IPTC:City, XMP-photoshop:City, IPTC:Province-State, XMP-photoshop:State, IPTC:Country-PrimaryLocationName, XMP-photoshop:Country | Dual IPTC and XMP-photoshop. |
| DateTime | DateTimeOriginal, OffsetTimeOriginal, TimeZoneOffset, XMP-exif:DateTimeOriginal, XMP-xmp:CreateDate | XMP uses ISO 8601. See Dates & timezones below. |
| Camera | Make, Model, LensModel | Only 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.