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 arcpy and the arcpy.metadata module ship only with Pro.
  • The script must run inside ArcGIS Pro’s bundled Python (the arcgispro-py3 conda environment). arcpy is not pip-installable; launch from the Pro Python Command Prompt or point your IDE interpreter at C:\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 ExportMetadata translator names; ISO 19139 export uses the ISO19139_GML32 translation style.
What survives the ArcGIS-to-ISO 19139 translation Internal ArcGIS metadata passes through the ISO 19139 translator; some fields map directly, some are dropped, and lineage process steps must be supplied from the pipeline rather than from the item. ArcGIS item metadata title, extent, CRS contacts, keywords geoprocessing history ISO 19139 translator arcpy.conversion TRANSLATES CLEANLY identification, extent, CRS DOES NOT — SUPPLY YOURSELF LI_ProcessStep with parameters source digests, actor identity CRS pair per transformation The export gives you a valid ISO document with an empty lineage section — which validates, and proves nothing.

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

Assembling one record from two sources The ArcGIS export supplies identification and extent; the provenance store supplies process steps, digests and actors; the two are joined on a stable dataset identifier. ArcGIS export identification, extent, CRS, contacts, keywords Provenance store process steps, digests, actor, CRS pair per step join on dataset id stable, not a file path ISO 19139 with a populated LI_Lineage block The join key must be an identifier the item carries permanently — a path changes the first time data is reorganised.

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

Export on publication, not on a timer A publication-triggered export captures the dataset state that was actually released; a nightly sweep can capture a later state and misdescribe the published artefact. Triggered by publication The XML describes exactly the bytes released Digest in the record matches the artefact Nightly sweep Captures whatever state exists at 02:00 Can describe a version nobody published A catalogue record that describes a different version than the download is worse than no record. Use the sweep only to find datasets that have no record at all — never to refresh existing ones. Bind the export to the same step that writes the artefact, and both move together or neither does.

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 obscure arcpy.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 empty MD_ReferenceSystem block, which many catalogs reject. Guard against silent CRS loss by asserting describe.spatialReference.factoryCode is 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_ProcessStep passes 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.Walk and 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.