Extracting ISO 19115 Metadata from Legacy GeoTIFFs

Extracting ISO 19115 metadata from legacy GeoTIFFs means harvesting whatever provenance survives inside decades-old rasters — embedded TIFF tags, GDAL metadata domains, and orphaned sidecar files — and mapping it into a compliant ISO 19115/19139 record so the archive remains discoverable and audit-defensible after the originating team is gone. This guide is for GIS archivists and compliance teams inheriting scanned topographic sheets, old orthophotos, and government raster deliveries where the metadata is partial, inconsistent, or externalized into .aux.xml and .tfw companions. Generic converters fail because they assume a clean single-file source with a complete header; legacy GeoTIFFs routinely carry a missing CRS, an undocumented processing lineage, and datestamps buried in vendor-specific tags. The extraction has to be forgiving about where metadata lives and strict about where it lands.

Where Legacy Metadata Hides and Where It Must Land

The hard part is not the ISO schema — it is that a single logical record is scattered across the raster header and its companions, and mapping it correctly is what turns scraps into a compliant record:

Mapping legacy GeoTIFF metadata sources to ISO 19115 elements Four GeoTIFF metadata sources on the left map to four ISO 19115/19139 elements on the right: the GDAL default domain feeds identificationInfo, TIFFTAG_DATETIME feeds dateStamp, GeoKeys and the prj definition feed referenceSystemInfo, and the aux.xml sidecar feeds dataQualityInfo lineage. The lineage target is highlighted as commonly absent and filled from the ingest manifest. GeoTIFF sources ISO 19115 / 19139 elements GDAL default domain AREA_OR_POINT · TIFFTAG_SOFTWARE TIFFTAG_DATETIME acquisition / processing date GeoKeys · .prj CRS ProjectedCSTypeGeoKey / WKT .aux.xml / .xml sidecar vendor lineage · contact identificationInfo citation · title · abstract dateStamp CI_Date / gco:Date referenceSystemInfo RS_Identifier · EPSG code dataQualityInfo / lineage often missing → gap-fill

Harvesting Every Metadata Domain

GDAL exposes TIFF tags and sidecar content through named metadata domains, and legacy tools scattered values across all of them. Dump every domain before assuming a field is absent, because the datestamp you need is frequently sitting in a nonstandard domain the original vendor invented.

  1. Enumerate the full metadata surface:
gdalinfo -json -mdd all datasets/imagery/legacy/ortho_1998_tile_042.tif > raw_meta.json

The -mdd all flag forces GDAL to read every metadata domain, not just the default one — including IMAGE_STRUCTURE, GEOLOCATION, and any vendor domain such as ESRI or DERIVED_SUBDATASET. The JSON also carries the coordinateSystem block and the corner coordinates you need for the geographic extent. Run this across the whole archive as a batch harvest before writing any records: a single pass that dumps every raster’s full metadata surface into a staging table lets you profile which fields are consistently present and which are systematically absent, so the gap-fill strategy is designed from evidence rather than discovered one broken record at a time. Legacy collections are rarely uniform — a directory of orthophotos may span three vendors and two decades of tooling conventions — and the batch profile is what surfaces those seams.

  1. Pull the datestamp from whichever tag actually holds it. Legacy writers used TIFFTAG_DATETIME, a sidecar <ProcessDate>, or nothing at all. Prefer the embedded tag, fall back to the sidecar, and record which source won so the lineage is honest:
import json
from datetime import datetime

meta = json.load(open("raw_meta.json"))
tags = meta.get("metadata", {}).get("", {})
raw_date = tags.get("TIFFTAG_DATETIME")  # e.g. "1998:07:23 00:00:00"

if raw_date:
    date_stamp = datetime.strptime(raw_date, "%Y:%m:%d %H:%M:%S").date().isoformat()
    date_source = "TIFFTAG_DATETIME"
else:
    date_stamp, date_source = None, "MISSING"  # trigger gap-fill downstream
  1. Resolve the CRS to an EPSG authority code. GeoKeys often encode the projection without a clean EPSG reference, and a stray .prj may disagree with the embedded GeoKeys. Force a single authoritative answer:
gdalsrsinfo -o epsg datasets/imagery/legacy/ortho_1998_tile_042.tif

If this returns Unknown or an ambiguous WKT, the raster is CRS-orphaned and must be resolved before it can populate referenceSystemInfo — the same discipline enforced upstream by CRS Synchronization in Pipelines.

Mapping to a Compliant ISO 19139 Record

Harvested values are inert until serialized into the ISO 19139 XML encoding of the 19115 model. Map each source to its target element and, critically, mark provenance for anything you infer rather than read.

from lxml import etree

NS = {
    "gmd": "http://www.isotc211.org/2005/gmd",
    "gco": "http://www.isotc211.org/2005/gco",
}
root = etree.Element("{%s}MD_Metadata" % NS["gmd"], nsmap=NS)

def gco(parent, tag, value):
    el = etree.SubElement(parent, "{%s}%s" % (NS["gmd"], tag))
    child = etree.SubElement(el, "{%s}CharacterString" % NS["gco"])
    child.text = value
    return el

# dateStamp — from the harvested tag, or an explicit nil for a genuine gap
ds = etree.SubElement(root, "{%s}dateStamp" % NS["gmd"])
if date_stamp:
    etree.SubElement(ds, "{%s}Date" % NS["gco"]).text = date_stamp
else:
    ds.set("{http://www.isotc211.org/2005/gco}nilReason", "missing")

# referenceSystemInfo — the resolved EPSG authority code
gco(root, "referenceSystemInfo", "urn:ogc:def:crs:EPSG::32610")

# lineage — filled from the ingest manifest when the raster carries none
lineage = gco(root, "lineage", "Digitized from 1998 orthophoto series; "
              "georeferenced by archive ingest 2026-07, source metadata partial.")

tree = etree.ElementTree(root)
tree.write("datasets/imagery/legacy/ortho_1998_tile_042.iso19139.xml",
           pretty_print=True, xml_declaration=True, encoding="UTF-8")

Keep the distinction between the ISO 19115 abstract model and its ISO 19139 XML encoding clear as you build. ISO 19115 defines the content — which elements exist, which are mandatory, and how they relate — while ISO 19139 defines the concrete XML serialization with its gmd/gco namespaces and nilReason machinery. Newer archives may target the ISO 19115-1:2014 model serialized as 19115-3 XML, but the vast majority of legacy raster deliveries were catalogued against the original 19115:2003 model, and 19139 remains the encoding most national spatial data infrastructures still ingest. Pick the profile your regulator or catalog actually consumes and validate against that exact schema; a record that is valid 19139 but wrong-profile is as useless to a harvester as no record at all.

The nilReason="missing" attribute is the honest way to represent an absent field: it keeps the record schema-valid while telling a future auditor the gap was real, not an extraction bug. ISO 19115 defines a controlled vocabulary of nil reasons — missing, unknown, inapplicable, withheld — and choosing the right one carries meaning: an orthophoto with no recorded sensor is unknown, whereas a derived hillshade with no acquisition datetime is inapplicable. Encoding that nuance rather than blanking the field is what separates a defensible harvest from a lossy one. Fill lineage from your ingest manifest rather than leaving it blank — a georeferenced-but-undocumented raster with no lineage statement is the single most common finding when these archives are reviewed. Persist the resulting XML as a sidecar next to the raster and register it in the archive’s index alongside the STAC entries described in Automating STAC Catalog Generation for Archived Imagery; ISO 19139 serves the formal compliance record while STAC serves fast spatial search, and mature archives keep both.

Verifying the Record Is Schema-Valid and Complete

Well-formed XML is not the same as a valid ISO 19139 document. Validate against the official schema and then assert the elements your compliance profile requires are actually populated.

xmllint --noout --schema http://www.isotc211.org/2005/gmd/gmd.xsd \
  datasets/imagery/legacy/ortho_1998_tile_042.iso19139.xml

Expected output on a conformant record:

ortho_1998_tile_042.iso19139.xml validates

Then confirm the mandatory core elements are present and no required field slipped through as an empty string:

xmllint --xpath "count(//*[local-name()='dateStamp']) + \
  count(//*[local-name()='referenceSystemInfo']) + \
  count(//*[local-name()='identificationInfo'])" \
  datasets/imagery/legacy/ortho_1998_tile_042.iso19139.xml

The count must equal 3; a lower number means a mandatory ISO 19115 core element is absent and the record will fail a formal metadata conformance review even though the XML parses.

Resolving Common Extraction Gaps

Symptom Root Cause Resolution
referenceSystemInfo empty GeoKeys present but no EPSG authority match Resolve with gdalsrsinfo -o epsg; if ambiguous, assign the project CRS and note it in lineage
Datestamp is the file’s mtime No TIFFTAG_DATETIME, extractor fell back to filesystem time Treat missing dates as nilReason="missing"; never substitute filesystem timestamps
Sidecar .aux.xml ignored GDAL not reading external domains Re-run gdalinfo with -mdd all and confirm the .aux.xml sits beside the .tif
xmllint reports namespace errors Hand-built XML missing gco/gmd bindings Declare both namespaces in nsmap and qualify every element

Operational Execution Checklist

Part of the Spatial Data Archival knowledge base.