Skip to content

FlavioCFOliveira/GoMetadata

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

176 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GoMetadata

GoMetadata

Go Reference Go Report Card CI License: MIT Go: 1.26+ codecov OpenSSF Scorecard Release

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.

Installation

go get github.com/FlavioCFOliveira/GoMetadata

Requires Go 1.26 or later. No non-stdlib runtime dependencies.

Usage

Reading common fields

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())
}

Writing and modifying metadata

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)
	}
}

Skipping segments for faster reads

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(),
)

Raw segment access and building from scratch

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

Reading RAW file metadata

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) }

Batch copyright stamping

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 + rename

Stream pipeline (no disk I/O)

examples/stream-transcode — read metadata from stdin, update fields, write to stdout. No temporary files. Works with any io.ReadSeeker and io.Writernet/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.jpg

JSON metadata export

examples/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 = &lat; rec.Longitude = &lon }
if iso, ok := m.ISO();            ok { rec.ISO = &iso }
gallery-sidecar -pretty photo1.jpg photo2.nef photo3.heic

Multi-format round-trip smoke test

examples/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")

Supported features

Feature Details
Metadata standards EXIF 3.0 (CIPA DC-008 / 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 only carried by JPEG and TIFF)
Write All three standards across all 13 container formats; 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 write is not yet supported (ErrWriteNotSupported).
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 27 fuzz targets covering all parsers and format extractors
Race safety Clean under go test -race ./...
Corpus coverage 3,000+ real-world images tested, 0 failures

Supported formats

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

² 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; Canon MakerNote blobs are copied verbatim (blob-relative offsets; move-safe). 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. Use format.SupportsWrite(id) to check programmatically. BigTIFF write is not supported — Write returns ErrWriteNotSupported for BigTIFF sources.

³ 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.

Performance

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.

Reproducing the benchmarks

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.

End-to-end read

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

Write

Operation Time/op Memory/op Allocs/op
JPEG — metadata update 282 ns 264 B 15
PNG — pass-through 188 ns 168 B 17

Metadata format parsers

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

Container format parsers

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.

Internal primitives

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

Design choices behind the numbers

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

API reference

Full documentation is available at pkg.go.dev/github.com/FlavioCFOliveira/GoMetadata.

License

MIT