Skip to content

EXIF

Last Updated: April 21, 2026

EXIF (Exchangeable Image File Format) is a metadata standard for camera and image information โ€” capture date, exposure, GPS coordinates, orientation, camera make/model, and similar fields โ€” embedded directly in the image file. It is supported by virtually every camera manufacturer and image processor, and is by far the most important metadata source for PhotoPrism.

How PhotoPrism Reads EXIF

Unlike XMP, PhotoPrism reads EXIF through a two-stage pipeline that combines a native Go parser with an optional ExifTool overlay. Both stages run against the same media file and write into the same meta.Data struct; the second stage only fills fields that the first stage left empty.

Stage 1: Native EXIF Parser

For every supported container, internal/meta/exif.go extracts the raw EXIF block and assigns the values PhotoPrism cares about to meta.Data. The parser stack is built on Dustin Oprea's go-exif family (v3):

When the file format is not one of the above, or when the format-specific parser fails, internal/meta/exif_parser.go:RawExif() falls back to a brute-force byte search via exif.SearchFileAndExtractExif. The brute-force path is also used when PHOTOPRISM_EXIF_BRUTE_FORCE is set (see below).

ExifSupported() (in internal/photoprism/mediafile.go) reports true for JPEG, RAW, HEIF (HEIC/AVIF), PNG, TIFF, and PSD โ€” the native parser stack either handles these directly or the brute-force fallback kicks in for RAW and PSD.

Stage 2: ExifTool Overlay (Optional)

When ExifTool support is enabled, internal/photoprism/mediafile_meta.go:MetaData() then runs ExifTool via Convert.ToJson() and merges its cached JSON output into the same meta.Data through internal/meta/json_exiftool.go:Exiftool(). This stage:

  • Recognises far more tag variants and container formats than the native parser (video metadata, Maker Notes, vendor-specific IPTC/XMP blocks, Google Motion Photo, Samsung MotionPhoto, etc.).
  • Does not overwrite non-zero values set by the native parser. The reflection loop in json_exiftool.go checks !fieldValue.IsZero() before assigning, so the native parser effectively wins for any field it populated. The overlay fills the gaps.
  • Is a precondition for embedded XMP support โ€” see Adobe XMP.

A regular <filename>.json sidecar (Google Photos export or similar) is read by meta.Data.JSON() between the two stages, and is also subject to the non-overwrite rule.

Configuration

Variable CLI Flag Default Effect
PHOTOPRISM_DISABLE_EXIFTOOL --disable-exiftool false Skips Stage 2. Auto-forced to true when the sidecar path is not writable or ExifToolBin is empty.
PHOTOPRISM_EXIFTOOL_BIN --exiftool-bin autodetected Path to the exiftool binary used by Stage 2.
PHOTOPRISM_EXIF_BRUTE_FORCE --exif-bruteforce false Forces the brute-force byte-search fallback in Stage 1 even when the format-specific parser succeeds.

ExifTool JSON output is cached per file under ExifToolCacheName(hash) (under the configured cache directory, with the media file's SHA-1 as the cache key) so repeated indexing does not re-run exiftool on unchanged files.

Fields Extracted from EXIF

The table below lists every meta.Data field that EXIF and/or ExifTool can populate, along with the specific tag names consulted by each stage. The shape mirrors the XMP overview table so the two pages can be read side by side.

Column meanings:

  • EXIF Tag(s) โ€” the tag names the native parser in internal/meta/exif.go looks up in data.exif (the map populated by dsoprea/go-exif from the file's IFD0 / ExifIFD / GPSInfo block). Listed in priority order where the parser tries multiple aliases.
  • ExifTool JSON Key(s) โ€” the aliases listed in the meta:"..." struct tag on that field in internal/meta/data.go. Stage 2 iterates this list left-to-right and the first non-empty ExifTool JSON value wins.
  • Native (Stage 1) โ€” โœ“ when the native EXIF parser assigns the field; โ€” when it does not. Stage 1 only runs for file formats accepted by ExifSupported() (JPEG, RAW, HEIF/HEIC/AVIF, PNG, TIFF, PSD).
  • ExifTool (Stage 2) โ€” โœ“ when the ExifTool overlay can populate the field; โ€” when it cannot (the field is not driven by a meta:"..." tag). Stage 2 only fills fields that Stage 1 left empty โ€” see How PhotoPrism Reads EXIF.
Data Field EXIF Tag(s) (IFD0 / ExifIFD / GPSInfo) ExifTool JSON Key(s) Native (Stage 1) ExifTool (Stage 2)
Artist Artist Artist, Creator, By-line, OwnerName, Owner โœ“ โœ“
Copyright Copyright Rights, Copyright, CopyrightNotice, WebStatement โœ“ โœ“
Title โ€” Title, Headline โ€” โœ“
Caption ImageDescription Description, ImageDescription, Caption, Caption-Abstract โœ“ (also auto-keyword) โœ“
Subject โ€” Subject, PersonInImage, ObjectName, HierarchicalSubject, CatalogSets โ€” โœ“
Keywords โ€” Keywords โ€” โœ“
Notes โ€” Comment, UserComment โ€” โœ“
License โ€” UsageTerms, License โ€” โœ“
Favorite โ€” Favorite โ€” โœ“
Software Software Software, Producer, CreatorTool, Creator, CreatorSubTool, HistorySoftwareAgent, ProcessingSoftware โœ“ โœ“
CameraMake CameraMake, then Make CameraMake, Make โœ“ โœ“
CameraModel CameraModel, then Model, then UniqueCameraModel CameraModel, Model, CameraID, UniqueCameraModel โœ“ โœ“
CameraOwner CameraOwnerName OwnerName โœ“ โœ“
CameraSerial BodySerialNumber SerialNumber โœ“ โœ“
LensMake LensMake LensMake โœ“ โœ“
LensModel LensModel, then Lens LensModel, Lens, LensID โœ“ โœ“
Exposure ExposureTime (N/M โ†’ 1/โŒŠM/NโŒ‹) ExposureTime, ShutterSpeedValue, ShutterSpeed, TargetExposureTime โœ“ โœ“
FNumber FNumber (N/M โ†’ float) FNumber โœ“ โœ“
Aperture ApertureValue (N/M โ†’ float) ApertureValue, Aperture โœ“ โœ“
FocalLength FocalLengthIn35mmFilm, else FocalLength FocalLength, FocalLengthIn35mmFormat โœ“ โœ“
FocalDistance โ€” HyperfocalDistance โ€” โœ“
Iso ISOSpeedRatings ISO โœ“ โœ“
Flash Flash (low bit == 1) FlashFired โœ“ (also adds flash keyword) โœ“
Width PixelXDimension, else ImageWidth ImageWidth, PixelXDimension, ExifImageWidth, SourceImageWidth โœ“ โœ“
Height PixelYDimension, else ImageLength ImageHeight, ImageLength, PixelYDimension, ExifImageHeight, SourceImageHeight โœ“ โœ“
Orientation Orientation (defaults to 1 when missing) Orientation / Rotation via Data.Rotation โœ“ โœ“ (via Rotation)
Rotation โ€” Rotation โ€” โœ“
Projection ProjectionType (also adds panorama keyword) ProjectionType โœ“ โœ“
ColorProfile โ€” ICCProfileName, ProfileDescription โ€” โœ“
TakenAt DateTimeOriginal, then DateTimeCreated, CreateDate, DateTime, DateTimeDigitized SubSecDateTimeOriginal, SubSecDateTimeCreated, DateTimeOriginal, CreationTime, CreationDate, DateTimeCreated, DateTime, DateTimeDigitized โœ“ โœ“
TakenAtLocal derived from TakenAt SubSecDateTimeOriginal, SubSecDateTimeCreated, DateTimeOriginal, CreationDate, DateTimeCreated, DateTime, DateTimeDigitized โœ“ โœ“
TakenGps GPS sub-IFD timestamp GPSDateTime, GPSDateStamp โœ“ โœ“
TakenNs SubSecTimeOriginal, then SubSecTime, SubSecTimeDigitized (same sub-sec tags consumed by both stages) โœ“ โœ“
CreatedAt โ€” SubSecCreateDate, CreationTime, CreationDate, CreateDate, MediaCreateDate, ContentCreateDate, TrackCreateDate โ€” โœ“
TimeOffset โ€” OffsetTime, OffsetTimeOriginal, OffsetTimeDigitized โ€” โœ“
TimeZone derived from Lat/Lng via tz.Position derived from TakenAtLocal offset; normalised via tz.Name โœ“ (via GPS) โœ“
Lat / Lng GPS sub-IFD โ†’ GpsInfo.Decimal() โ†’ NormalizeGPS GPSPosition or GPSLatitude + GPSLongitude (parsed via GpsToLatLng / GpsToDecimal) โœ“ โœ“
GPSPosition โ€” GPSPosition โ€” โœ“
GPSLatitude โ€” GPSLatitude โ€” โœ“
GPSLongitude โ€” GPSLongitude โ€” โœ“
Altitude GPS sub-IFD GlobalAltitude, GPSAltitude โœ“ โœ“
DocumentID ImageUniqueID (must pass rnd.SanitizeUUID) ContentIdentifier, MediaGroupUUID, BurstUUID, OriginalDocumentID, DocumentID, ImageUniqueID, DigitalImageGUID โœ“ โœ“
InstanceID โ€” InstanceID, DocumentID โ€” โœ“
ImageType โ€” HDRImageType โ€” โœ“
HasThumbEmbedded โ€” ThumbnailImage, PhotoshopThumbnail โ€” โœ“
HasVideoEmbedded โ€” EmbeddedVideoFile, MotionPhoto, MotionPhotoVideo, MicroVideo โ€” โœ“
Duration โ€” Duration, MediaDuration, TrackDuration, PreviewDuration โ€” โœ“
FPS โ€” VideoFrameRate, VideoAvgFrameRate โ€” โœ“
Frames โ€” FrameCount, AnimationFrames โ€” โœ“
Pages โ€” PageCount, NPages, Pages โ€” โœ“
Codec โ€” CompressorID, VideoCodecID, CodecID, OtherFormat, FileType โ€” โœ“
MimeType โ€” MIMEType โ€” โœ“
FileName โ€” FileName โ€” โœ“

Notes

  • Native-reader tag names are not always identical to ExifTool's. In particular, CameraOwnerName (native) โ†” OwnerName (ExifTool), BodySerialNumber (native) โ†” SerialNumber (ExifTool), and ISOSpeedRatings (native) โ†” ISO (ExifTool). The native parser follows the EXIF 2.3 specification names; ExifTool uses its own normalised names. This is why the two columns show different strings for what is semantically the same value.
  • Purely numeric CameraModel / CameraMake / LensMake / LensModel values are rejected by the native parser (txt.IsUInt check). Broken exporters occasionally write things like 42 as a camera model; these are dropped so Stage 2 can supply a real value instead.
  • Orientation falls back to 1 in Stage 1; the ExifTool path can also populate it via Rotation (xmp:Rotation or QuickTime Rotation), which is mapped back to an orientation value in json_exiftool.go.
  • GPS coordinates flow into Lat/Lng directly from the native parser (via exif.GpsInfo). The GPSPosition / GPSLatitude / GPSLongitude string fields in Data are only populated by Stage 2 and then parsed into Lat/Lng if they are still zero.
  • Sub-second tags are consulted by both stages but stored in Data.TakenNs only once; whichever stage writes first wins.

Notable Behaviours

These are the EXIF-reader quirks that most commonly surprise developers when debugging indexing issues:

  • IFD0 wins over IFD1. When the same tag appears in both the primary IFD and the embedded-thumbnail IFD, PhotoPrism keeps the IFD0 value โ€” see issue #2231.
  • Numeric model names are discarded. CameraModel, CameraMake, LensMake, and LensModel are skipped when the value parses as an unsigned integer (seen in the wild from broken exporters).
  • Orientation defaults to 1 when no tag is present; a missing orientation does not block indexing.
  • GPS derives the time zone even when the EXIF block contains an explicit offset tag (OffsetTime, OffsetTimeOriginal, OffsetTimeDigitized) โ€” those offset tags are consumed later by the ExifTool path via Data.TimeOffset.
  • Sub-second precision is merged back into TakenAt and TakenAtLocal after time zone resolution.
  • ImageUniqueID becomes DocumentID, which in turn governs file stacking โ€” if a RAW and a JPEG share an ImageUniqueID, PhotoPrism stacks them.
  • Flash keyword is only added when the EXIF flash flag's low bit is 1 (flash actually fired), not just when the flag is present.
  • Per-file mutex. Data.Exif() takes a package-level sync.Mutex; concurrent calls serialise through it because dsoprea/go-exif's tag index isn't goroutine-safe on write.
  • ExifSupported() gate. mediafile_meta.go:MetaData() only runs Stage 1 for JPEG, RAW, HEIF, PNG, TIFF, and PSD; everything else relies entirely on Stage 2 (ExifTool).

References

Using ExifTool Locally

If you want to reproduce PhotoPrism's Stage 2 output on your laptop, install ExifTool via your package manager:

docker run --rm -v ${PWD}:/test -w /test -ti debian:bookworm bash
apt update && apt install -y exiftool libheif-examples

Then inspect a file the same way PhotoPrism does (-n disables human-readable formatting, -j emits JSON, -g groups tags by source โ€” helpful for disambiguating EXIF/XMP/IPTC aliases):

exiftool -n -j photo.jpg
exiftool -n -g -j photo.jpg | jq '.[0] | keys'
exiftool -n photo.jpg | grep -i orientation

For examples of how other applications display EXIF data, and for recipes to edit EXIF tags while testing, see the Editing & Debugging EXIF Data page.