A pure Go library for reading and writing EXIF, IPTC, and XMP metadata from any image format. GoMetadata provides a single, unified API over all three metadata standards — EXIF 3.1 (CIPA DC-008-2026, reads 3.0 and 2.x / TIFF 6.0), IPTC IIM 4.2, and XMP (ISO 16684-1) — across 13 container formats including JPEG, TIFF, PNG, WebP, HEIF/AVIF, and the major RAW formats (CR2, CR3, NEF, ARW, DNG, ORF, RW2).
Developers searching for a Go EXIF library, a Go IPTC parser, or a way to read and write XMP metadata in Go will find that GoMetadata handles all three in a single import. Format detection is by magic bytes, not file extension. All parsers are fuzz-tested and race-clean.
go get github.com/FlavioCFOliveira/GoMetadata
Requires Go 1.26 or later. No non-stdlib runtime dependencies.
package main
import (
"fmt"
"log"
gometadata "github.com/FlavioCFOliveira/GoMetadata"
)
func main() {
m, err := gometadata.ReadFile("photo.jpg")
if err != nil {
log.Fatal(err)
}
fmt.Println("Camera:", m.CameraModel())
fmt.Println("Make: ", m.Make())
fmt.Println("Lens: ", m.LensModel())
if lat, lon, ok := m.GPS(); ok {
fmt.Printf("GPS: %.6f, %.6f\n", lat, lon)
}
if t, ok := m.DateTimeOriginal(); ok {
fmt.Println("Shot:", t)
}
if num, den, ok := m.ExposureTime(); ok {
fmt.Printf("Exposure: %d/%d s\n", num, den)
}
if f, ok := m.FNumber(); ok {
fmt.Printf("Aperture: f/%.1f\n", f)
}
if iso, ok := m.ISO(); ok {
fmt.Println("ISO:", iso)
}
fmt.Println("Caption: ", m.Caption())
fmt.Println("Copyright:", m.Copyright())
fmt.Println("Keywords: ", m.Keywords())
}Write and WriteFile preserve all image data and all metadata not explicitly changed. WriteFile performs an atomic in-place update via a temporary file and rename.
package main
import (
"log"
"time"
gometadata "github.com/FlavioCFOliveira/GoMetadata"
)
func main() {
m, err := gometadata.ReadFile("photo.jpg")
if err != nil {
log.Fatal(err)
}
m.SetCaption("Grand Canyon, South Rim")
m.SetCopyright("2024 Jane Smith")
m.SetCreator("Jane Smith")
m.SetKeywords([]string{"landscape", "canyon", "arizona"})
m.SetGPS(36.0544, -112.1401)
m.SetDateTimeOriginal(time.Date(2024, 9, 14, 7, 32, 0, 0, time.UTC))
if err := gometadata.WriteFile("photo.jpg", m); err != nil {
log.Fatal(err)
}
}Use ReadOption helpers to skip segments you do not need. Skipping the MakerNote is the single biggest speed win for cameras with large proprietary blocks.
m, err := gometadata.ReadFile("photo.jpg",
gometadata.WithoutMakerNote(),
gometadata.WithoutIPTC(),
gometadata.WithoutXMP(),
)When you need direct access to the raw bytes of a segment, or want to construct a Metadata value to embed in a new file:
// Raw segment bytes — useful for forwarding to another library or logging.
exifBytes := m.RawEXIF()
xmpBytes := m.RawXMP()
iptcBytes := m.RawIPTC()
// Build a Metadata value from scratch (no source image required).
import "github.com/FlavioCFOliveira/GoMetadata/format"
blank := gometadata.NewMetadata(format.JPEG)
blank.SetCameraModel("Custom Device")
blank.SetCopyright("2024 Example Corp")examples/raw-inspector — extract camera identification, shooting parameters, GPS, and descriptive fields from any RAW format (CR2, CR3, NEF, ARW, DNG, ORF, RW2). Format is detected automatically from magic bytes.
// WithoutMakerNote skips the costliest part of EXIF parsing —
// the manufacturer-specific IFD — when only standard tags are needed.
m, err := gometadata.ReadFile(path, gometadata.WithoutMakerNote())
fmt.Printf("Format: %s\n", m.Format())
fmt.Printf("Make/Model: %s %s\n", m.Make(), m.CameraModel())
fmt.Printf("Lens: %s\n", m.LensModel())
if num, den, ok := m.ExposureTime(); ok { fmt.Printf("Shutter: 1/%d s\n", den/num) }
if f, ok := m.FNumber(); ok { fmt.Printf("Aperture: f/%.1f\n", f) }
if iso, ok := m.ISO(); ok { fmt.Printf("ISO: %d\n", iso) }
if fl, ok := m.FocalLength(); ok { fmt.Printf("Focal: %.0f mm\n", fl) }
if wb, ok := m.WhiteBalance(); ok { fmt.Printf("WB: %d\n", wb) } // 0=auto 1=manual
if fl, ok := m.Flash(); ok { fmt.Printf("Flash fired: %v\n", fl&0x01 != 0) }
if lat, lon, ok := m.GPS(); ok { fmt.Printf("GPS: %.6f, %.6f\n", lat, lon) }
if alt, ok := m.Altitude(); ok { fmt.Printf("Altitude: %.1f m\n", alt) }examples/copyright-stamp — walk a directory tree and embed copyright, creator, caption, and keywords into every image. Setters write to all non-nil metadata components (EXIF, IPTC, XMP) in one call.
m, err := gometadata.ReadFile(path)
// Distinguish corrupt / truncated files from hard I/O errors.
var corrupt *gometadata.CorruptMetadataError
var truncated *gometadata.TruncatedFileError
switch {
case errors.As(err, &corrupt): /* skip */
case errors.As(err, &truncated): /* skip */
}
// Each setter writes to all non-nil metadata layers simultaneously:
// SetCopyright → EXIF tag 0x8298 + IPTC dataset 2:116 + XMP dc:rights
m.SetCopyright("© 2025 Jane Smith. All rights reserved.")
m.SetCreator("Jane Smith")
m.SetCaption("Grand Canyon at sunset")
m.SetKeywords([]string{"landscape", "canyon", "arizona"})
gometadata.WriteFile(path, m) // atomic: temp file + renameexamples/stream-transcode — read metadata from stdin, update fields, write to stdout. No temporary files. Works with any io.ReadSeeker and io.Writer — net/http, bytes.Buffer, object-store streams.
m, err := gometadata.Read(os.Stdin) // *os.File implements io.ReadSeeker
m.SetCaption("...")
m.SetGPS(48.8566, 2.3522) // Paris
// Seek back so Write can re-read the original image bytes from the same handle.
os.Stdin.Seek(0, io.SeekStart)
// PreserveUnknownSegments passes APP/chunk segments the library
// does not recognise through byte-for-byte (e.g. ICC profiles).
gometadata.Write(os.Stdin, os.Stdout, m, gometadata.PreserveUnknownSegments(true))stream-transcode -caption "Night shot" -copyright "2025 J. Smith" < input.jpg > output.jpgexamples/gallery-sidecar — parse images and emit a JSON array for static site generators, search indexes, or API responses. Optional fields use Go pointer types so absent values serialise as null.
type imageRecord struct {
File string `json:"file"`
Format string `json:"format"`
Model *string `json:"model,omitempty"`
CapturedAt *string `json:"captured_at,omitempty"` // RFC3339
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
ISO *uint `json:"iso,omitempty"`
// ...
}
m, _ := gometadata.ReadFile(path, gometadata.WithoutMakerNote())
rec := imageRecord{File: path, Format: m.Format().String()}
if t, ok := m.DateTimeOriginal(); ok { s := t.Format(time.RFC3339); rec.CapturedAt = &s }
if lat, lon, ok := m.GPS(); ok { rec.Latitude = ⪫ rec.Longitude = &lon }
if iso, ok := m.ISO(); ok { rec.ISO = &iso }gallery-sidecar -pretty photo1.jpg photo2.nef photo3.heicexamples/multi-format-roundtrip — read, modify, write, and re-read across all supported formats. Exits non-zero on any mismatch. Useful as a pre-release integration test.
m, _ := gometadata.ReadFile(path)
// Check format before writing — not all container variants support write.
if !format.SupportsWrite(m.Format()) { /* skip */ }
m.SetCaption("roundtrip-test")
m.SetGPS(51.5074, -0.1278)
tmp, _ := os.CreateTemp(filepath.Dir(path), "roundtrip-*"+ext)
defer os.Remove(tmp.Name())
gometadata.WriteFile(tmp.Name(), m)
m2, _ := gometadata.ReadFile(tmp.Name())
fmt.Printf("PASS/FAIL %s (%s): caption=%v\n",
path, m.Format(), m2.Caption() == "roundtrip-test")| Feature | Details |
|---|---|
| Metadata standards | EXIF 3.1 (CIPA DC-008-2026, reads 3.0 and 2.x / TIFF 6.0), IPTC IIM 4.2, XMP (ISO 16684-1) |
| Read | Whichever of EXIF / IPTC / XMP the container carries, across all 13 formats (IPTC is carried by JPEG, the TIFF-based formats, and PNG "Raw profile type iptc" text chunks) |
| Write | All three standards across all 13 container formats, including BigTIFF (magic 0x002B); preserves unmodified metadata byte-for-byte. All TIFF-based formats (TIFF, DNG, CR2, NEF, ARW, ORF, RW2) use a copy-and-relocate serializer that preserves image-data blocks (strips/tiles/MakerNote blobs) at corrected offsets; BigTIFF uses a native 64-bit encoder and a container-width-aware relocator. |
| Atomic writes | WriteFile uses temp file + rename — no partial writes |
| Format detection | Magic bytes only; file extension is never consulted |
| MakerNote (read) | Canon, Nikon, Sony, Sony Ericsson, Olympus, OM System, Epson, Agfa, Panasonic, Pentax, Ricoh, DJI, FujiFilm, Leica, Samsung, Sigma, Minolta, Casio, Kyocera, Sanyo, Apple; the layout is detected from the MakerNote signature, then from the Make |
| Convenience getters | 30+ typed getters with explicit source-priority resolution |
| Convenience setters | 15+ setters that write to all applicable non-nil components simultaneously |
| Priority resolution | Each getter documents its source order (e.g., EXIF > XMP); the caller always gets one answer |
| Lazy parsing | WithoutEXIF(), WithoutIPTC(), WithoutXMP(), WithoutMakerNote() skip unwanted work |
| Allocation budget | Zero/near-zero heap allocation in parsing fast paths; sync.Pool for reusable buffers |
| Fuzz testing | 37 fuzz targets covering all parsers and format extractors/injectors (see SECURITY.md) |
| Race safety | Clean under go test -race ./... |
| Corpus coverage | 3,000+ real-world images tested, 0 failures |
| Format | Extension(s) | Read | Write | EXIF | IPTC | XMP |
|---|---|---|---|---|---|---|
| JPEG | .jpg, .jpeg | Yes | Yes | Yes | Yes | Yes |
| TIFF | .tif, .tiff | Yes | Yes² | Yes | Yes | Yes |
| PNG | .png | Yes | Yes | Yes | Yes⁵ | Yes |
| WebP | .webp | Yes | Yes | Yes | No | Yes |
| HEIF | .heif, .heic | Yes | Yes | Yes | No | Yes |
| AVIF | .avif | Yes | Yes | Yes | No | Yes |
| Canon CR2 | .cr2 | Yes | Yes² | Yes | No | Yes |
| Canon CR3 | .cr3 | Yes | Yes | Yes | No | Yes |
| Nikon NEF | .nef | Yes | Yes² | Yes | No | Yes |
| Sony ARW | .arw | Yes | Yes² | Yes | No | Yes |
| Adobe DNG | .dng | Yes | Yes² | Yes | No | Yes |
| Olympus ORF | .orf | Yes | Yes³ | Yes | No | Yes |
| Panasonic RW2 | .rw2 | Yes | Yes⁴ | Yes | No | Yes |
² Copy-and-relocate write (
format/tiff/relocate.go). Image strips, tiles, and non-thumbnail JPEG blocks are enumerated, copied verbatim to fresh absolute offsets in the output stream, and all offset entries are patched. DNG additionally relocates SubIFD structures and their out-of-line values (RATIONAL, SRATIONAL, etc.). CR2 and NEF route through the same path; MakerNotes are copied verbatim, and those whose value offsets count from the TIFF header (Canon, Sony, Panasonic, Olympus "OLYMP", ...) are rebased when they move. SubIFDs of every chained IFD, SubIFD arrays and SubIFD chains are carried. NEF extends the Nikon Type-3 MakerNote blob to cover PreviewIFD and NikonScanIFD. ARW rebases Sony MakerNote TIFF-absolute offsets and relocates the SR2Private block verbatim. Useformat.SupportsWrite(id)to check programmatically. BigTIFF sources (magic0x002B) are written through the same TIFF row:exif.Encodeemits a native 16-byte-header, 64-bit-offset BigTIFF IFD skeleton, and the relocator's raw-offset scans, image-block enumeration, and SubIFD handling are all container-width-aware, so image data and metadata round-trip byte-for-byte just as they do for classic TIFF.³ ORF copy-and-relocate write: the non-standard IIRO/IIRS magic bytes are patched to standard LE TIFF before relocation and restored in the output. Both IIRO (Olympus DSLRs) and IIRS (older compacts) are supported.
⁴ RW2 copy-and-relocate write: the Panasonic 16-byte device GUID header is preserved and all absolute IFD0 offsets are rebased after GUID insertion.
⁵ PNG IPTC: PNG has no native IPTC chunk. IPTC stored in a
tEXt,zTXtoriTXtchunk keyed "Raw profile type iptc" is read, andWriterewrites that chunk in place;Writenever adds one.
Benchmarks run with go test -bench=. -benchmem -benchtime=2s ./... (Go 1.26, macOS, GOMAXPROCS=10).
All figures are the mean of multiple runs; allocation counts are stable across runs.
Run the full suite:
go test -bench=. -benchmem -benchtime=2s ./...
Scope to a single package (e.g. the EXIF parser):
go test -bench=. -benchmem -benchtime=2s ./exif/...
Run a single named benchmark:
go test -bench=BenchmarkParseEXIF -benchmem -benchtime=2s ./exif/...
Results vary by machine. The figures in this README were collected on macOS with Go 1.26 and GOMAXPROCS=10. Pin GOMAXPROCS to make comparisons across machines more meaningful:
GOMAXPROCS=10 go test -bench=. -benchmem -benchtime=2s ./...
For lower noise, run multiple iterations and pipe through benchstat:
go install golang.org/x/perf/cmd/benchstat@latest
go test -bench=. -benchmem -benchtime=2s -count=5 ./... | benchstat /dev/stdin
benchstat computes the median and confidence interval across the five runs, which is more reliable than any single measurement.
| Scenario | Time/op | Memory/op | Allocs/op |
|---|---|---|---|
| Progressive JPEG (no metadata) | 163 ns | 176 B | 4 |
| JPEG — EXIF + IPTC + XMP combined | 10.6 µs | 22.8 kB | 24 |
| Real-world JPEG corpus file | 1.55 µs | 4.7 kB | 14 |
| Concurrent reads (parallel goroutines) | 11.4 µs | 544 B | 11 |
| Operation | Time/op | Memory/op | Allocs/op |
|---|---|---|---|
| JPEG — metadata update | 282 ns | 264 B | 15 |
| PNG — pass-through | 188 ns | 168 B | 17 |
| Format | Operation | Time/op | Memory/op | Allocs/op |
|---|---|---|---|---|
| EXIF | Parse — minimal TIFF (width, height, orientation) | 121 ns | 257 B | 4 |
| EXIF | Parse — camera tags | 997 ns | 2.4 kB | 8 |
| EXIF | Encode | 121 ns | 240 B | 6 |
| EXIF | IFD tag lookup — 100-entry set (binary search) | 3.8 ns | 0 B | 0 |
| IPTC | Parse | 102 ns | 944 B | 2 |
| IPTC | Encode | 68 ns | 96 B | 1 |
| IPTC | Field accessor | 26 ns | 64 B | 1 |
| XMP | Parse | 1.06 µs | 968 B | 12 |
| XMP | Encode | 650 ns | 3.1 kB | 2 |
| Format | Operation | Time/op |
|---|---|---|
| JPEG | Segment extraction | 102 ns |
| JPEG | Segment injection | 206 ns |
| JPEG | Real corpus file (full parse) | 2.02 µs |
| PNG | Extraction | 192 ns |
| PNG | Extraction — compressed XMP (zlib) |
810 ns |
| TIFF | Extraction | 98 ns |
| WebP | Extraction | 98 ns |
| HEIF / AVIF | Extraction | 271 ns |
| Sony ARW | Extraction | 81 ns |
| Canon CR2 | Extraction | 82 ns |
| Adobe DNG | Extraction | 79 ns |
| Nikon NEF | Extraction | 80 ns |
Canon CR3 and Olympus ORF/Panasonic RW2 benchmarks are covered by the TIFF and BMFF primitive benchmarks; their combined overhead falls within the same 80–100 ns range.
| Component | Operation | Time/op |
|---|---|---|
sync.Pool buffer |
Get + Put (≤4 kB) | 7.0 ns |
sync.Pool buffer |
Get + Put (>64 kB) | 7.2 ns |
| Byte-order | Uint16 little-endian |
0.26 ns |
| Byte-order | Uint32 little-endian |
0.27 ns |
| BMFF | Read box header | 24.8 ns |
| BMFF | Skip box | 27.5 ns |
| RIFF | Read chunk header | 24.4 ns |
| Technique | Effect |
|---|---|
sync.Pool-backed buffers (internal/iobuf) |
Amortises heap allocation to zero after warm-up |
Lazy parsing (WithoutEXIF, WithoutIPTC, WithoutXMP, WithoutMakerNote) |
Skips unwanted segments entirely; MakerNote skip is the largest win on RAW files |
| Binary search in IFD entry set | Tag lookup in a 100-entry IFD costs 3.8 ns and 0 allocations |
| Lazy map init for extended XMP | Map is only allocated when extended XMP is actually present |
| Magic-byte format detection | Dispatch adds no measurable overhead; no string allocation |
Full documentation is available at pkg.go.dev/github.com/FlavioCFOliveira/GoMetadata.
MIT
