A CLI tool that synchronizes metadata from an Immich photo server back into the original files.
Immich stores rich metadata (GPS, descriptions, ratings, camera info, dates, named people) in its database, but this metadata isn't always embedded in the file itself. This tool bridges that gap by downloading each asset, embedding the missing tags via exiftool, and re-uploading the modified file.
For each asset:
1. Fetch metadata from Immich API
2. Skip early if Immich has no metadata fields to embed
3. Download the original file (checksum-verified against the server)
4. Read existing metadata tags (exiftool)
5. Diff Immich metadata vs file metadata
6. Show the diff, ask to confirm / skip / quit
7. Write missing tags into the file (exiftool)
8. Re-upload, copy associations, restore visibility
9. Verify the re-uploaded asset's checksum, then move the original to Immich's trash
Assets that already have matching metadata are skipped automatically, making it safe to run repeatedly.
Video metadata writing is supported for mp4, mov, and m4v. Other video containers are skipped.
The destructive path is the careful path. By default the tool will not delete an original it cannot prove was replaced intact:
- Downloads are checksum-verified — a corrupt or truncated download is rejected before any tag is written or uploaded.
- Uploads are checksum-verified by default — the re-uploaded asset is re-fetched and its checksum compared to the local file before the original is touched. A mismatch refuses to delete the original.
- Trash, never permanent delete — the replaced original always goes to Immich's trash, where it stays recoverable. Checksum verification proves the transfer was intact, not that exiftool produced a valid file, so the trash window is kept as the last-resort recovery path.
- Live photos stay paired — the still's
livePhotoVideoIdis forwarded on re-upload, and hidden videos (live-photo motion parts) are skipped rather than replaced, which would sever the pair. - Mid-run edits are respected — the asset is re-checked right before upload; if its metadata changed on the server while the tool was working, it is skipped instead of overwritten.
- External libraries are protected — replacing an external-library asset would migrate a copy into Immich's internal library (and duplicate it at the next scan), so those assets are skipped on replace runs.
-dry-runand-export-dirstill cover them. - Face regions are opt-in —
-facesreplaces the file'sXMP-mwg-rsregion structure with Immich's named people; without the flag, region metadata is never touched. - Trash disabled on the server? The tool warns you loudly: without the trash feature there is no recovery window.
-dry-runwrites nothing and shows every change first.- The API key is refused over plaintext
http://unless you pass-allow-http.
An interruption (Ctrl-C) or a failed step leaves a duplicate, never a hole: the original is removed only after a successful replacement.
| Scenario | Command |
|---|---|
| I want to test one photo safely | immich-exif -dry-run <asset-id> |
| I want to process one album interactively | immich-exif -album <album-id> |
| I want to process everything without prompts | immich-exif -y -all |
| I want to process everything faster (4 workers) | immich-exif -y -workers 4 -all |
| I want eligible files exported to album folders (no upload) | immich-exif -y -export-dir ./out -all |
| I want to export one album in its own folder | immich-exif -y -export-dir ./export -album <album-id> |
| I want to export all albums | immich-exif -y -export-dir ./export -album all |
| I want duplicates auto-resolved | immich-exif -y -resolve-duplicate -album <album-id> |
| I want to ignore cache and re-check everything | immich-exif -y -force -all |
| I want named people written as face regions too | immich-exif -y -faces -all |
When using -export-dir with exactly one -album, files are exported to /<export-dir>/<album-id>/.
With -export-dir and -all or -album all, exported assets are mirrored per album folder (/<export-dir>/<album-id>/...), including shared assets in each album folder. Assets with no album go to /<export-dir>/no-album/ by default and can be omitted with -include-no-album=false.
With -export-dir and multiple explicit -album flags, assets are mirrored per album folder (/<export-dir>/<album-id>/...).
This is not a full-library backup mode: assets with no writable metadata to embed and assets whose metadata already matches are still skipped.
Messaging apps like WhatsApp and Signal strip EXIF on send, so a shared photo loses the date this tool just fixed. Add -rename (export mode only) to also stamp the corrected capture date into the filename, keeping the date readable even after the metadata is gone:
immich-exif -y -export-dir ./out -rename -all
# IMG_45698.JPG -> 20040605-142532.JPGThe name is built from the same corrected date and time zone written into the file's EXIF. The default pattern is %Y%m%d-%H%M%S; override it with -rename-pattern (or RENAME_PATTERN) using the tokens %Y %y %m %d %H %M %S (and %% for a literal percent). The original extension is always preserved. If the server has no date for an asset, its original name is used. If the destination name already exists, a -001, -002 suffix is added so nothing is overwritten.
When using --all or -album all, the tool maintains a local SQLite state cache that tracks which assets have already been processed. On subsequent runs, assets whose Immich metadata hasn't changed are skipped entirely, avoiding the expensive download/compare/upload cycle.
- State is stored in
~/.config/immich-exif/state.db(macOS:~/Library/Application Support/) - Cache is keyed per server URL, so multiple Immich instances don't interfere
- Only assets with finalized outcomes are cached:
- uploaded and migrated successfully
- replaced in-place (new ID equals old ID)
- confirmed as already matching metadata
dry-run,export-dir, andduplicate/replacedupload statuses are never cached- Use
--forceto ignore the cache and re-process everything (state is still saved for the next run) - Delete
state.dbto fully reset the cache
- An Immich server with a valid API key. The full replace flow needs Immich v2.2+ (
/api/assets/copy); auto-detection needs 1.113+ (/api/server/about, or force-immich-api); read-only modes (-list-albums,-dry-run,-export-dir) work from 1.106 - exiftool on your
PATH(the Docker image already bundles it) - Go 1.24+ only if you build from source
Download the archive for your OS and architecture from the latest release, extract it, and put immich-exif on your PATH. Install exiftool separately.
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>Mount a volume (-v "$PWD/out:/out") when using -export-dir /out. The container runs as uid 1000, so mounted directories must be writable by that user.
For incremental -all runs, persist the state cache with -v immich-exif-state:/home/appuser.
cd src
go build -o immich-exif .The tool reads credentials from CLI flags or environment variables. A .env file is also supported.
# .env
IMMICH_URL=https://your-immich-server.com
IMMICH_API_KEY=your-api-key
# Optional: rename exported files from the corrected capture date (export mode)
RENAME=true
RENAME_PATTERN=%Y%m%d-%H%M%SOn Immich 1.113+ you can scope the API 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 |
Trash the old original after a verified replacement |
album.read |
Resolve -album / -album all selections |
face.read |
Fetch face boxes — only needed with -faces |
Read-only modes need less: -dry-run and -export-dir never write to the server, so they only require server.about, asset.read, asset.download, and album.read (drop album.read too if you only pass asset IDs; add face.read if you combine them with -faces).
immich-exif [flags] [asset-ids...]
| Flag | Default | Description |
|---|---|---|
-url |
$IMMICH_URL |
Immich server URL |
-api-key |
$IMMICH_API_KEY |
API key |
-immich-api |
auto |
API contract: auto (detect; assumes v3 when unsure), v3, or legacy |
-workers |
1 |
Number of parallel workers |
-dry-run |
false |
Embed EXIF locally but skip re-upload |
-export-dir |
Save modified files to a directory instead of re-uploading (fails if file exists) | |
-y |
false |
Auto-confirm all changes |
-no-verify-upload |
false |
Skip the post-upload checksum verification that gates the replacement |
-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 duplicate upload status, copy associations to the duplicate asset and trash the old one |
-include-no-album |
true |
With album-mirrored export, include assets with no album under no-album/ |
-all |
false |
Select the all-assets mode (timeline, archived and hidden; external-library assets only on read-only runs) |
-force |
false |
Ignore the state cache and re-process (only valid with -all / -album all) |
-faces |
false |
Also write named Immich people as MWG face regions (XMP-mwg-rs), readable by digiKam and others |
-rename |
false |
With -export-dir, rename exported files from the corrected capture date (env: RENAME=true) |
-rename-pattern |
%Y%m%d-%H%M%S |
strftime-style pattern for -rename (env: RENAME_PATTERN) |
-album |
Album ID to process (repeatable), or all as an alias of -all |
|
-version |
false |
Print the version and exit |
One of these is required:
# Process specific assets
immich-exif asset-id-1 asset-id-2
# Process all assets in an album
immich-exif -album <album-id>
# Process multiple albums
immich-exif -album <id1> -album <id2>
# Process assets from all albums
immich-exif -album all
# Select the all-assets mode
immich-exif -allTo find album IDs, list them first:
immich-exif -list-albums
# 4c1f… Vacation 2024 (312)
# 9ab2… Family (87)-all and -album all are equivalent selectors. The tool still only exports/processes assets that pass its normal filters.
# Interactive dry-run on a single asset
immich-exif -dry-run abc123
# Non-interactive, export eligible files to album folders
immich-exif -y -export-dir ./out -all
# Auto-confirm everything, 4 workers
immich-exif -y -workers 4 -all
# Force re-process everything (ignore cache)
immich-exif -y -force -allConsole output with interactive single-keypress prompts. While an asset is processed, a live counter line updates in place through each step — the percentage tracks the download of that file (skipped assets tick the counter too):
[37/128] Downloading IMG_1234.jpg... 45%
Each asset with changes then shows a diff and waits for input (the counter line is erased first):
[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 key needed. Use -y to auto-confirm. When output is piped or redirected, the counter prints one line per step instead of updating in place, and percent-only updates are omitted.
Interactive mode forces single-worker to avoid mixed prompts; parallel workers apply when using -y. Under -y on a terminal, the live counter still shows between diff blocks — one line per file in flight when running several workers; the per-step upload logs stay omitted. When -y output is piped or redirected, each asset prints only its diff block, and each block prints atomically, so multiple workers never interleave their output. Final outcomes and any failures are reported in the closing summary.
Output is colorized when stdout is a terminal (added tags in green, changed tags in amber, failures in red). Colors are disabled automatically when the output is piped or redirected, and when NO_COLOR is set.
Images use the full tag set below. Supported video containers (mp4, mov, m4v) use a compatible subset: description, rating, GPS, XMP location, dates, and camera fields.
| Category | Tags | Notes |
|---|---|---|
| GPS | GPSLatitude, GPSLatitudeRef, GPSLongitude, GPSLongitudeRef, XMP-exif:GPSLatitude, XMP-exif:GPSLongitude |
Ref derived from coordinate sign; XMP uses signed |
| Description | ImageDescription, XPComment, XMP-dc:Description, IPTC:Caption-Abstract |
EXIF + Windows + XMP Dublin Core + IPTC |
| Rating | Rating, RatingPercent, XMP-xmp:Rating |
Percent = rating x 20; an explicit rating of 0 clears stale rating tags (null = never rated = untouched) |
| Location | IPTC:City, XMP-photoshop:City, IPTC:Province-State, XMP-photoshop:State, IPTC:Country-PrimaryLocationName, XMP-photoshop:Country |
Dual IPTC + XMP-photoshop |
| DateTime | DateTimeOriginal, OffsetTimeOriginal, TimeZoneOffset, XMP-exif:DateTimeOriginal, XMP-xmp:CreateDate |
See below; XMP uses ISO 8601 |
| Camera | Make, Model, LensModel |
Only written if file has no existing value |
| Faces | XMP-mwg-rs:RegionInfo (MWG face regions) |
Opt-in via -faces; images and supported videos, named people only; see below |
With -faces, every person you have named in Immich is written into the file as an MWG face region (XMP-mwg-rs:RegionInfo) — the standard read by digiKam, XnView and others. Details worth knowing:
- Only named, visible people are written. Unnamed ML clusters and people you hid in Immich are left out — the same rule Immich applies when importing regions from files.
- Regions are written in the stored image's coordinate space, applying the exact inverse of the orientation transform Immich uses on import, so rotated photos round-trip correctly.
- When the file's regions disagree with Immich, the whole
RegionInfostructure is replaced — Immich is the source of truth, like for every other synced tag. When Immich has no named faces for an asset, existing file regions are left untouched (never cleared). - Round-trip bonus: with Immich's "Import faces from metadata" server setting enabled, the names embedded by
-facesare re-imported when the replaced file is scanned — face names survive the replace. Re-imported regions (sourceType: exif) are recognized as echoes of the file's own content and never counted next to a detected face of the same name (names are how the importer links regions to people), so repeated runs converge instead of duplicating regions. - Supported videos (
mp4,mov,m4v) embed the same MWG regions as images — exiftool writes XMP into the container, and Immich re-imports them when "Import faces from metadata" is enabled, exactly as for photos. Regions are anchored through the video's displayRotation(0°, 90°, 270°); a 180° or non-cardinal rotation is left un-embedded rather than risk a misplaced box, and other containers are skipped. - The key needs the
face.readpermission (see API key permissions). - Incremental-cache caveats: the
-allstate cache keys on the set of names, so correcting a face's box or reassigning a face between two already-named people does not invalidate it — use-forceto re-check. Toggling-faceson or off changes the cache key, so the first run after a toggle re-checks the library once.
Immich returns ISO 8601 dates (e.g. 2025-12-10T16:56:36+00:00). EXIF stores local time with a separate offset (e.g. 2025:12:10 17:56:36 + OffsetTimeOriginal: +01:00).
The tool handles this carefully:
- The asset's own time zone is honored: when Immich knows it (
exifInfo.timeZone, eitherEurope/RomeorUTC+2form), the UTC date is re-anchored so the wall-clock time written into the file matches what you see in Immich. - If the file already has
DateTimeOriginalbut no offset: the existing local time is preserved. The offset is computed from the difference between the file's local time and Immich's UTC time, then written asOffsetTimeOriginalandTimeZoneOffset. - If the file has no date at all:
DateTimeOriginalis written in EXIF format (YYYY:MM:DD HH:MM:SS) along with the offset tags. - If everything matches: the asset is skipped.
See ARCHITECTURE.md for upload strategy, processing pipeline, file structure, and API endpoints.
Licensed under the GNU General Public License v3.0.