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.

ISO 19115-1 LI_Lineage object model and cardinalities LI_Lineage + statement [0..1] LI_Source + description + sourceCitation LI_ProcessStep + description [1] + dateTime, processor DQ_Element + nameOfMeasure + result LE_ProcessStep 19115-2 imagery ext. source 0..* processStep 0..* report

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

ISO 19139 versus ISO 19115-3 namespaces The same lineage concepts carry different namespace prefixes and element paths in the legacy and modern encodings; a reader must accept both. ISO 19139 (2007) — legacy still what most catalogues accept ISO 19115-3 (2016+) — modern what new profiles are written against SAME CONCEPT, DIFFERENT PATH lineage container gmd:LI_Lineage mrl:LI_Lineage process step gmd:LI_ProcessStep mrl:LI_ProcessStep responsible party gmd:CI_ResponsibleParty cit:CI_Responsibility Responsible party is a rename AND a restructure — not a namespace swap.

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

Where ISO cardinality and profile cardinality diverge Elements grouped by whether ISO and the applicable profile agree on optionality and repeatability, highlighting the cases that pass base validation and fail profile validation. ELEMENT ISO BASE TYPICAL PROFILE statement optional mandatory processStep 0..* 1..* in practice source 0..* repeat per input processor 0..* at least one, named

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.sourceLI_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.