Implementing the ISO 19115-1 Lineage Model in a Spatial Pipeline
ISO 19115-1:2014 defines lineage as a structured object graph — not a free-text paragraph — yet most agencies still emit a single narrative statement and stop there. The standard’s LI_Lineage class actually composes three collaborating types: LI_Source describes the inputs, LI_ProcessStep describes each transformation applied to those inputs, and DQ_Element (from ISO 19157) attaches measurable quality results to the steps that produced them. Populating these classes correctly, with the right cardinalities, is what separates a metadata record that merely mentions processing from one that a downstream system can traverse, validate, and hold up as audit evidence. This guide sits under the Regulatory Compliance & Standards Mapping section and takes a build-oriented view: how to assemble these objects in Python inside a running pipeline and serialize them to conformant XML.
Where the existing overview on mapping ISO 19115 to lineage tracking crosswalks the standard’s elements onto a generic lineage graph, this page goes one level deeper into the object model itself — the exact nesting of LI_ProcessStep inside LI_Lineage, the imagery-specific extensions added by ISO 19115-2, the mandatory-versus-optional cardinality rules, and the two serialization dialects (ISO 19139 and the newer ISO 19115-3 / mdb encoding) you must choose between. Read the overview first for the conceptual crosswalk; return here to implement it.
The diagram captures the containment you will reproduce in code: a single LI_Lineage holds zero-or-more LI_Source and zero-or-more LI_ProcessStep children, each step may reference the sources it consumed, and each step may carry DQ_Element quality reports. The imagery profile of ISO 19115-2 subclasses these into LE_Source and LE_ProcessStep, adding processing-parameter and algorithm detail relevant to raster derivatives.
Prerequisites
Step-by-step
1. Model the lineage objects as typed dataclasses
Before touching XML, capture the standard’s structure as Python types. Making cardinality explicit here — Optional for [0..1], a required field for [1], a list for [0..*] — means the serializer never has to guess. This mirrors the LI_Source / LI_ProcessStep split described in the ISO 19115 lineage mapping overview.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class Source:
"""Maps to LI_Source. description is [0..1], sourceCitation is [0..1]."""
description: str
citation_title: str
identifier: str # persistent URI/UUID for graph edges
scope_level: str = "dataset" # MD_ScopeCode value
@dataclass
class ProcessStep:
"""Maps to LI_ProcessStep. description is MANDATORY [1]."""
description: str # required — validation fails if empty
date_time: datetime
processor_org: str
processor_role: str = "processor" # CI_RoleCode value
rationale: str | None = None # [0..1]
source_ids: list[str] = field(default_factory=list)
@dataclass
class Lineage:
"""Maps to LI_Lineage."""
statement: str | None = None # [0..1] high-level summary
sources: list[Source] = field(default_factory=list)
steps: list[ProcessStep] = field(default_factory=list)
def validate(self) -> list[str]:
errors: list[str] = []
if not self.statement and not self.steps and not self.sources:
errors.append("LI_Lineage requires statement, source, or processStep")
for i, step in enumerate(self.steps):
if not step.description.strip():
errors.append(f"processStep[{i}]: description is mandatory")
return errors
2. Populate the model from pipeline events
Inside your ETL run, append a ProcessStep at each transformation boundary — reprojection, resampling, mosaicking, attribute enrichment. Bind each step to the Source identifiers it consumed so the emitted XML preserves the input-to-output edges. This is the same event capture that feeds transformation logging standards, reused here to build compliant metadata rather than an internal log.
lineage = Lineage(statement="Orthorectified mosaic derived from three Sentinel-2 tiles.")
lineage.sources.append(Source(
description="Sentinel-2 L1C tile T31UDQ",
citation_title="Sentinel-2 MSI Level-1C",
identifier="urn:asset:s2:T31UDQ:20260601",
))
lineage.steps.append(ProcessStep(
description="Reprojected tiles from EPSG:32631 to EPSG:3035 using cubic resampling.",
date_time=datetime(2026, 6, 2, 9, 15, tzinfo=timezone.utc),
processor_org="National Mapping Agency",
source_ids=["urn:asset:s2:T31UDQ:20260601"],
))
problems = lineage.validate()
assert not problems, problems
3. Serialize to ISO 19115-3 XML with lxml
The serializer walks the model and emits properly prefixed elements. ISO 19115-3 places lineage under the mrl namespace and character strings under gco; every codelist value (role, scope) is an element with codeList and codeListValue attributes, not text — a rule the validation companion page checks explicitly.
from lxml import etree
NS = {
"mrl": "http://standards.iso.org/iso/19115/-3/mrl/2.0",
"mcc": "http://standards.iso.org/iso/19115/-3/mcc/1.0",
"cit": "http://standards.iso.org/iso/19115/-3/cit/2.0",
"gco": "http://standards.iso.org/iso/19115/-3/gco/1.0",
}
CODELIST = "http://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml"
def q(prefix: str, tag: str) -> str:
return f"{{{NS[prefix]}}}{tag}"
def _char(parent: etree._Element, prefix: str, tag: str, text: str) -> None:
el = etree.SubElement(parent, q(prefix, tag))
cs = etree.SubElement(el, q("gco", "CharacterString"))
cs.text = text
def serialize(lin: Lineage) -> bytes:
root = etree.Element(q("mrl", "LI_Lineage"), nsmap=NS)
if lin.statement:
_char(root, "mrl", "statement", lin.statement)
for src in lin.sources:
se = etree.SubElement(root, q("mrl", "source"))
li = etree.SubElement(se, q("mrl", "LI_Source"))
_char(li, "mrl", "description", src.description)
for step in lin.steps:
pe = etree.SubElement(root, q("mrl", "processStep"))
ps = etree.SubElement(pe, q("mrl", "LI_ProcessStep"))
_char(ps, "mrl", "description", step.description)
dt = etree.SubElement(ps, q("mrl", "stepDateTime"))
gdt = etree.SubElement(dt, q("gco", "DateTime"))
gdt.text = step.date_time.isoformat()
return etree.tostring(root, pretty_print=True, xml_declaration=True, encoding="UTF-8")
print(serialize(lineage).decode())
4. Emit a legacy ISO 19139 variant when required
Older catalogues and many INSPIRE validators still expect the gmd encoding of ISO 19139. Keep one source model and switch the namespace map and element names at serialization time; do not maintain two hand-edited XML trees. The INSPIRE metadata mandate section covers where the 19139 dialect is still authoritative.
GMD = "http://www.isotc211.org/2005/gmd"
GCO = "http://www.isotc211.org/2005/gco"
def serialize_19139(lin: Lineage) -> bytes:
nsmap = {"gmd": GMD, "gco": GCO}
root = etree.Element(f"{{{GMD}}}LI_Lineage", nsmap=nsmap)
if lin.statement:
stmt = etree.SubElement(root, f"{{{GMD}}}statement")
cs = etree.SubElement(stmt, f"{{{GCO}}}CharacterString")
cs.text = lin.statement
for step in lin.steps:
pe = etree.SubElement(root, f"{{{GMD}}}processStep")
ps = etree.SubElement(pe, f"{{{GMD}}}LI_ProcessStep")
d = etree.SubElement(ps, f"{{{GMD}}}description")
etree.SubElement(d, f"{{{GCO}}}CharacterString").text = step.description
return etree.tostring(root, pretty_print=True, encoding="UTF-8")
5. Publish the record to a discovery catalog
Once the XML is validated, the same source model can be projected into an OGC API - Records GeoJSON record so lineage becomes discoverable through a modern catalog API rather than only as a downloadable metadata file.
Configuration reference — element cardinalities
| Element | Type | Valid values | Default / cardinality |
|---|---|---|---|
LI_Lineage.statement |
CharacterString | Free text summary | none — [0..1] |
LI_Lineage.source |
LI_Source | Nested object | none — [0..*] |
LI_Lineage.processStep |
LI_ProcessStep | Nested object | none — [0..*] |
LI_ProcessStep.description |
CharacterString | Non-empty text | required — [1] |
LI_ProcessStep.stepDateTime |
DateTime / TM_Primitive | ISO 8601 | none — [0..1] |
LI_ProcessStep.processor |
CI_Responsibility | Party + role | none — [0..*] |
LI_Source.description |
CharacterString | Free text | none — [0..1] |
LI_Source.sourceCitation |
CI_Citation | Title, identifier | none — [0..1] |
processor.role |
CI_RoleCode | processor, originator, custodian |
codelist value |
LI_Source.scope.level |
MD_ScopeCode | dataset, series, feature |
codelist value |
A record is only conformant when at least one of statement, source, or processStep is present; an LI_Lineage with all three absent is invalid.
Common failure modes & mitigations
| Failure | Symptom | Mitigation |
|---|---|---|
| Empty processStep description | Schematron rejects LI_ProcessStep; catalog ingest silently drops the step |
Enforce the mandatory [1] description in the dataclass and in validate() before serializing |
| Codelist as text, not attribute | CI_RoleCode renders as <gco:CharacterString> and fails ISO validation |
Emit codelists as empty elements carrying codeList + codeListValue attributes |
| Namespace prefix drift | 19115-3 mrl elements placed under legacy gmd URI; validators report unknown element |
Centralize the namespace map and never string-concatenate prefixes |
| Silent CRS loss in source citation | Reprojection recorded in prose but source extent still tagged old EPSG | Store the CRS on each Source and assert it changes across reprojection steps |
| Non-UTC timestamps | stepDateTime compared incorrectly during audit ordering |
Require timezone-aware datetime and serialize with explicit offset |
Compliance & governance alignment
| Control / framework | Requirement | ISO 19115 lineage field |
|---|---|---|
| INSPIRE Metadata Regulation | Lineage statement mandatory for datasets | LI_Lineage.statement |
| ISO 19157 (data quality) | Report measurable quality results | DQ_Element linked from LI_ProcessStep |
| ISO 19115-2 (imagery) | Record processing algorithm & parameters | LE_ProcessStep.processingInformation |
| FISMA / NIST SP 800-53 (AU family) | Attributable, timestamped processing record | LI_ProcessStep.processor + stepDateTime |
| Reproducibility mandates | Traceable inputs to every output | LI_ProcessStep.source → LI_Source.sourceCitation |
For the FISMA audit-evidence angle in depth, see FISMA compliance for spatial systems; for how these same fields map onto privacy controls, see GDPR for geospatial data. Populate the model once, validate it with the Python validation how-to, and every framework above reads from the same authoritative structure.