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.0 (CIPA DC-008 / 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.0 (CIPA DC-008 / TIFF 6.0), IPTC IIM 4.2, XMP (ISO 16684-1) |
| Read | All three standards across all 13 container formats |
| Write | All three standards; preserves unmodified metadata byte-for-byte |
| 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, Olympus, Panasonic, Pentax, DJI, FujiFilm, Leica, Samsung, Sigma, Minolta, Casio |
| 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 | 18 fuzz targets covering all parsers |
| 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 | No | 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 |
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
