Implementing the ISO 19115-1 Lineage Model in a Spatial Pipeline
Part of: Regulatory Compliance & Standards Mapping
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.
Ordering, Nesting and the Shape of a Real Chain
ISO’s lineage model is deliberately loose about structure, and that looseness is where implementations diverge in ways that make records hard to compare across agencies. Three questions come up on every implementation and the specification leaves all three to you.
Are process steps ordered? The element is a repeated sequence, and nothing in the schema requires that sequence to be chronological. Most readers assume it is, so emit in execution order and populate dateTime on every step regardless — a consumer that needs ordering can then derive it rather than trusting document order, and a consumer that trusts document order is not misled.
Do steps nest? A model run that internally performs a reprojection, a clip and a resample can be expressed as one step with a rich description, or as three steps, or as a parent step with sources pointing at intermediate products. The specification permits all three. Choose one convention and hold to it, because a catalogue holding all three shapes cannot be queried consistently — and prefer the flatter option, since a reader can always summarise upward and cannot decompose downward.
How much detail belongs in description? Enough to reproduce the step, and nothing that duplicates a structured element. Parameters that have a home in the model belong there; free text is for what the model cannot express. Descriptions that restate the algorithm name already carried elsewhere inflate records without adding recoverable information, and they are the first thing to drift when the structured field is updated and the prose is not.
The underlying principle in all three cases is that ISO 19115 is a serialisation target, not a design. Decide the shape in your own internal model — where these three questions all have crisp answers, because your own queries depend directly on them — and then let the published ISO record be nothing more than a faithful projection of that single decision. Implementations that let the standard’s flexibility propagate into the internal model inherit three unresolved questions instead of answering them once, and every downstream query then has to cope with all three shapes at run time — which in practice means it copes with whichever shape the author happened to test against and then silently mishandles the other two shapes the very first time a record produced by a different pipeline happens to arrive.
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.
The Two Namespaces You Will Meet
The bottom row is the one that defeats naive migration scripts. Most of the change between encodings is a namespace substitution that a search-and-replace handles, and the responsible-party model is not: CI_ResponsibleParty collapses a party and its role into one element, while CI_Responsibility separates the role from one or more parties. A converter that treats it as a rename produces documents that validate structurally and lose the distinction between an organisation and the role it played.
Generate both encodings from your internal model rather than converting between them. The model holds role and party separately regardless, so emitting the legacy form is a flattening — lossy in a direction you control — while emitting the modern form is direct. Converting legacy to modern, by contrast, requires inventing structure that the source did not express, and every such invention is a decision nobody recorded.
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 |
Cardinality Is Where Records Fail Quietly
The gap between the ISO base cardinality and the profile’s is where records slip through internal checks and fail external ones. Validating against the base schema alone confirms that what you emitted is well formed; it says nothing about whether the profile that actually binds you requires an element the base marks optional. Run both validations in the same job, and treat the profile as the gate — the base schema is a prerequisite, not the standard you are judged against.
The source row deserves emphasis for spatial work specifically. It is repeatable precisely so that a product derived from many inputs can name all of them, and it is routinely emitted once with a summary because that is easier. A mosaic built from four hundred tiles should carry four hundred source entries; collapsing them into “various aerial imagery” produces a record that validates and cannot support impact analysis when one tile turns out to be bad.
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.
Frequently Asked Questions
Should we store ISO XML as the internal format?
No. XML is an interchange encoding, not a working model — querying it requires XPath over documents, and updating it means rewriting whole records. Keep typed objects or database rows internally and serialise to ISO on publication, which is also what makes emitting both encodings and multiple profiles tractable.
How do we represent a step that used software we do not control?
As a process step with the description and rationale populated and the parameters marked unknown, plus a processor naming the responsible party rather than the software. This is honest and validates; inventing plausible parameters to fill the element is the alternative, and it is worse than an acknowledged gap.
What goes in rationale versus description?
description is what was done, rationale is why. Pipelines populate the first automatically and almost always leave the second empty, which is a missed opportunity — the reason a particular resampling method or tolerance was chosen is exactly the context an auditor asks for and nobody remembers three years later. Capture it once as a property of the pipeline configuration and emit it per step.
Can we omit lineage for datasets we merely redistribute?
You can, and you should not. A redistribution is itself a process step, with the upstream provider as the source and your acquisition as the activity. Records that omit it imply you produced the data, which is both inaccurate and unhelpful to anyone trying to reach the authoritative version.
How do we handle a step whose output was later superseded?
Record both, linked. Superseding is a new step producing a new product, not an edit of the old one, so the earlier record stays exactly as it was and the newer one references it. Rewriting the earlier step to describe the current state destroys the ability to explain any analysis that used the superseded version.
Does every dataset need its own record?
Every dataset that is published or consumed outside the producing team, yes. Intermediates within a single pipeline run do not, provided they are reproducible from recorded inputs and parameters. The test is the same one used for scoping generally: if someone else can build on it, it needs a record.
Related
- Validating ISO 19115 Lineage with Python — schema and profile validation in CI
- Mapping OGC API - Records to Lineage — serving these records for discovery
- Mapping ISO 19115 to Lineage Tracking — reading existing records into a graph
- INSPIRE Metadata Mandate — the profile most likely to bind EU publishers
- Part of: Regulatory Compliance & Standards Mapping