Automating ArcGIS Pro Metadata Export to ISO 19139
Part of: Metadata Injection Techniques
When an ArcGIS Pro item is edited by an analyst, its item-level metadata is the authoritative description of what the dataset is and how it was produced, yet that record rarely leaves the geodatabase in a form an external catalog or audit trail can consume. This how-to shows how to export ArcGIS Pro item metadata to ISO 19139 XML with arcpy and capture a matching lineage record in the same run, so every export is traceable. It sits under Metadata Injection Techniques and pairs naturally with Setting Up Transformation Logs for ArcGIS.
Prerequisites
- ArcGIS Pro 3.1+ with an available Standard or Advanced license, since
arcpyand thearcpy.metadatamodule ship only with Pro. - The script must run inside ArcGIS Pro’s bundled Python (the
arcgispro-py3conda environment).arcpyis notpip-installable; launch from the Pro Python Command Prompt or point your IDE interpreter atC:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe. - Read access to the source feature class or raster, and write access to an output directory for the XML and the lineage sidecar.
- Familiarity with the
ExportMetadatatranslator names; ISO 19139 export uses theISO19139_GML32translation style.
That last point is the trap this how-to exists to avoid. arcpy will happily produce a schema-valid ISO 19139 document whose LI_Lineage block is empty or carries a single vague sentence, and a validator will pass it. Validity is a statement about structure, not about content, so a pipeline that exports and validates without also injecting process steps has automated the production of documents that satisfy a checker and answer no audit question.
Geoprocessing history is the partial exception, and it is only partial. ArcGIS records tool invocations in the item’s history when the setting is enabled, and those can be translated into process steps — but they carry no input digests, no actor beyond the desktop user, and nothing from work done outside ArcGIS. Treat the history as a seed to be enriched from your own lineage store, keyed on the dataset identifier, rather than as the lineage itself.
Implementation
The arcpy.metadata.Metadata class reads the item’s synchronized metadata; its exportMetadata() method writes standards-compliant XML. The function below exports one item, hashes the resulting XML for integrity, and writes a JSON lineage record capturing the source, translator, operator, and a UTC timestamp.
from __future__ import annotations
import getpass
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import arcpy # available only inside ArcGIS Pro's arcgispro-py3 environment
from arcpy import metadata as md
def export_iso19139_with_lineage(
source_item: str,
output_dir: str | Path,
translator: str = "ISO19139_GML32",
) -> dict[str, str]:
"""Export an ArcGIS Pro item's metadata to ISO 19139 XML and emit a lineage record.
Args:
source_item: Catalog path to a feature class, raster, or table.
output_dir: Directory that will hold the .xml export and .lineage.json sidecar.
translator: arcpy metadata export translation style name.
Returns:
The lineage record that was written to disk.
"""
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if not arcpy.Exists(source_item):
raise FileNotFoundError(f"ArcGIS item does not exist: {source_item}")
item_name = Path(source_item).name
xml_path = out_dir / f"{item_name}.iso19139.xml"
# Read the item's stored metadata and export it to ISO 19139.
item_md = md.Metadata(source_item)
if item_md.isReadOnly:
raise PermissionError(f"Metadata for {source_item} is read-only")
item_md.exportMetadata(str(xml_path), translator)
# Hash the exported XML so drift in the record is detectable later.
xml_bytes = xml_path.read_bytes()
xml_sha256 = hashlib.sha256(xml_bytes).hexdigest()
describe = arcpy.Describe(source_item)
lineage: dict[str, str] = {
"event": "metadata_export",
"source_item": str(source_item),
"source_catalog_path": describe.catalogPath,
"dataset_type": describe.dataType,
"spatial_reference": getattr(describe, "spatialReference", None).name
if getattr(describe, "spatialReference", None)
else "UNKNOWN",
"output_xml": str(xml_path),
"translator": translator,
"xml_sha256": xml_sha256,
"xml_byte_length": str(len(xml_bytes)),
"operator": getpass.getuser(),
"exported_at": datetime.now(timezone.utc).isoformat(),
}
lineage_path = out_dir / f"{item_name}.lineage.json"
lineage_path.write_text(json.dumps(lineage, indent=2), encoding="utf-8")
return lineage
if __name__ == "__main__":
record = export_iso19139_with_lineage(
source_item=r"C:\gis\parcels.gdb\parcels_2026",
output_dir=r"C:\gis\exports\lineage",
)
print(f"Exported {record['output_xml']} (sha256={record['xml_sha256'][:12]}...)")
The critical detail is that exportMetadata() runs the translator against the stored metadata. If an analyst edited the item’s description in the Catalog pane but never saved, the export reflects the last committed state — which is exactly what you want for an audit record, but it means unsaved edits are silently excluded.
Enriching the Export with Real Lineage
Choosing the join key is the only real design decision here, and file paths are the wrong answer despite being the obvious one. Move a feature class into a different geodatabase and the path changes while the dataset does not, silently detaching the record from its lineage. Write a stable identifier into the item’s metadata once — a UUID in a custom tag — and join on that; it survives reorganisation, format conversion and republication, which is exactly the set of events that make lineage valuable in the first place.
Where an existing corpus has no such identifier, assign one during the first automated export and record the assignment as a lineage event of its own. That gives you a defensible answer to the question of when tracking started for each dataset, rather than an implied claim that it was always tracked.
Scheduling the Export
The sweep still has a legitimate narrow use: finding datasets that have escaped the publication path entirely and have no metadata record at all. Treat what it finds as a gap list to be fixed at source, not as an opportunity to generate a record retroactively — a retroactive record carries today’s digest against yesterday’s publication, and the mismatch will be discovered by whoever next verifies the download.
Verification
Confirm the export produced valid ISO 19139 and that the lineage record matches the file on disk:
import hashlib
from pathlib import Path
from xml.etree import ElementTree as ET
xml_path = Path(r"C:\gis\exports\lineage\parcels_2026.iso19139.xml")
# 1. The XML must parse and carry the ISO 19139 root namespace (gmd/gco).
root = ET.parse(xml_path).getroot()
assert "isotc211" in root.tag, f"Unexpected root element: {root.tag}"
# 2. The recomputed hash must equal the one stored in the lineage sidecar.
import json
lineage = json.loads(Path(str(xml_path).replace(".iso19139.xml", ".lineage.json")).read_text())
recomputed = hashlib.sha256(xml_path.read_bytes()).hexdigest()
assert recomputed == lineage["xml_sha256"], "Lineage hash drift detected"
print("Export verified:", lineage["exported_at"])
A parsed root tag containing the isotc211 namespace confirms the translator emitted ISO 19139 rather than the internal ArcGIS format. The hash assertion proves the record on disk is the one you logged.
Configuration Reference
| Parameter | Value | Why |
|---|---|---|
| translator | ISO19139 (or ISO19139_GML32 for INSPIRE) |
Must match what the receiving catalogue expects; they are not interchangeable |
metadata_removal_option |
REMOVE_ALL_SENSITIVE_INFO for public export |
Strips local paths and user names that otherwise leak into published XML |
| history capture | enabled in the project template | Off by default in every new .aprx; cannot be set centrally |
| lineage source | your own store, keyed on dataset id | Item history alone has no digests and no non-ArcGIS steps |
| export target | a temp path, then atomic move | A half-written XML picked up by a catalogue harvester is worse than none |
| child enumeration | arcpy.da.Walk over feature datasets |
A dataset container’s metadata says nothing about its feature classes |
metadata_removal_option deserves attention on any export that leaves the organisation. ArcGIS metadata routinely embeds absolute paths from the machine that created it, the operating-system user name, and occasionally connection strings. None of that is provenance, all of it is disclosure, and a published catalogue record is exactly the wrong place to discover it. Strip on export and keep the unstripped version internally if the paths have diagnostic value.
The atomic-move pattern matters more here than in most write paths because catalogue harvesters watch directories. Writing directly to the harvested location means a harvester can read a partially serialised document, index it, and cache the result — after which correcting the file does not correct the catalogue until the next full crawl.
Gotchas & edge cases
- Translator name mismatch. Passing
"ISO19139"instead of"ISO19139_GML32"on Pro 3.x raises an obscurearcpy.ExecuteError. The GML 3.2 variant is the one that carries geometry-bearing extent elements; use it whenever downstream consumers validate against the full INSPIRE-aligned schema. - Spatial reference reported as
Unknown. Items whose CRS was never defined export an emptyMD_ReferenceSystemblock, which many catalogs reject. Guard against silent CRS loss by assertingdescribe.spatialReference.factoryCodeis non-zero before export, and treat a zero code as a hard failure rather than exporting an unusable record. - Empty lineage validates. A document with no
LI_ProcessSteppasses schema validation. Assert on the presence and count of process steps separately from schema validity, or the check confirms only that the XML is well formed. - Feature datasets versus feature classes. Running the export against a feature dataset container captures only the container’s metadata, not the child feature classes. Enumerate children with
arcpy.da.Walkand export each one, or your lineage will claim coverage it does not have. Feed these records into your broader transformation logging for ArcGIS so the export event is chained to the edits that preceded it.
Related
- Metadata Injection Techniques — carrier choice and the identifier-only pattern
- Setting Up Transformation Logs for ArcGIS — capturing the steps this export publishes
- ISO 19115 Lineage Implementation — what a populated lineage block must contain
- Automating Metadata Injection with GDAL — the open-source equivalent
- Part of: Metadata Injection Techniques