Mapping ISO 19115 to ISO 19139 XML with Python

ISO 19115 describes what geospatial metadata to record; ISO 19139 describes how to serialise it as XML. Confusing the two is the root cause of most non-compliant records: developers write ad-hoc XML that “looks like ISO metadata” but omits the gco:CharacterString wrappers, codelist attributes, and namespace discipline the encoding actually mandates. This guide maps each element of the abstract model onto its concrete gmd:MD_Metadata encoding and produces a valid document from a plain Python dataclass with lxml.etree.

TL;DR: Hold your metadata in a typed dataclass, then walk it into a gmd:MD_Metadata tree where every property element (gmd:title, gmd:abstract) wraps a primitive (gco:CharacterString, gco:Decimal, gco:Date), and every enumerated value (CI_RoleCode, MD_TopicCategoryCode, MD_ScopeCode) is emitted as a codelist reference. Use lxml with an explicit namespace map for gmd, gco, gml, and gmx, and reach for gco:nilReason rather than emitting empty strings.

The Core Challenge: Model Versus Encoding

The abstract model and its encoding live at different levels. ISO 19115 is a set of UML classes — MD_Metadata aggregates MD_DataIdentification, which carries a CI_Citation, an EX_Extent, and one or more CI_ResponsibleParty roles. Nothing in that model says “namespace” or “element”. ISO 19139 is the binding that turns each class into XML: class names become elements in the gmd namespace, attribute values become gco-wrapped primitives, geometry and time borrow the gml namespace, and shared codelists resolve through gmx.

The mapping is mechanical but unforgiving. Miss the gco:CharacterString wrapper and a validator rejects the property; put text directly under gmd:title and the document fails against the XSD even though it is human-readable. Getting this right by hand is why teams that need to guarantee conformance also run the output through an XSD pass, as covered in the validating ISO 19115 XML against XSD schemas with lxml guide.

Mapping the ISO 19115 model to the ISO 19139 XML encoding Five Python dataclass fields on the left flow through a central lxml serialiser that adds gco wrappers and codelist references, producing the corresponding gmd, gco, gml and gmx XML elements on the right. ISO 19115 model Python dataclass ISO 19139 encoding gmd:MD_Metadata XML lxml.etree serialiser + gco wrappers + codelists + nilReason title: str gmd:title › gco:CharacterString bbox: BoundingBox gmd:EX_GeographicBoundingBox › gco:Decimal temporal_*: date gmd:EX_TemporalExtent › gml:TimePeriod contact.role: str gmd:CI_RoleCode @codeListValue topic_category: str gmd:MD_TopicCategoryCode One dataclass field can expand into a whole XML subtree.

Production-Ready Code

The script below defines the ISO 19115 elements as dataclasses, then serialises them into a gmd:MD_Metadata document. Every builder handles exactly one mapping concern — a primitive wrapper, a codelist reference, or a nested extent — so the structure stays readable. The only dependency is lxml (pip install lxml).

from __future__ import annotations

import uuid
from dataclasses import dataclass
from datetime import date

from lxml import etree

# --- The ISO 19139 namespace map: how the ISO 19115 model is encoded --------
NSMAP: dict[str, str] = {
    "gmd": "http://www.isotc211.org/2005/gmd",   # metadata property elements
    "gco": "http://www.isotc211.org/2005/gco",   # primitive-type wrappers
    "gml": "http://www.opengis.net/gml/3.2",     # geometry and time primitives
    "gmx": "http://www.isotc211.org/2005/gmx",   # shared codelists and anchors
    "xlink": "http://www.w3.org/1999/xlink",
}

# Every codeListValue resolves against this public ISO codelist catalogue.
CODELIST_CAT = (
    "http://standards.iso.org/ittf/PubliclyAvailableStandards/"
    "ISO_19139_Schemas/resources/Codelist/gmxCodelists.xml"
)


def qn(prefix: str, local: str) -> str:
    """Build a Clark-notation {namespace}local name from a NSMAP prefix."""
    return f"{{{NSMAP[prefix]}}}{local}"


# --- ISO 19115 abstract model as plain dataclasses --------------------------
@dataclass
class ResponsibleParty:
    organisation: str
    role: str                 # a CI_RoleCode value, e.g. "pointOfContact"
    individual: str | None = None
    email: str | None = None


@dataclass
class BoundingBox:
    west: float
    east: float
    south: float
    north: float


@dataclass
class Iso19115Metadata:
    identifier: str           # -> gmd:fileIdentifier
    title: str
    abstract: str | None      # None -> gco:nilReason, not an empty string
    date_published: date
    topic_category: str       # an MD_TopicCategoryCode enumeration value
    bbox: BoundingBox
    contact: ResponsibleParty
    lineage: str | None = None
    temporal_start: date | None = None
    temporal_end: date | None = None
    language: str = "eng"
    scope: str = "dataset"    # an MD_ScopeCode value


# --- Element builders: one mapping concern each -----------------------------
def _char_string(parent: etree._Element, prefix_local: str, value: str | None):
    """gmd:<field> wrapping a gco:CharacterString, or a nilReason if absent."""
    prefix, local = prefix_local.split(":")
    el = etree.SubElement(parent, qn(prefix, local))
    if value is None:
        el.set(qn("gco", "nilReason"), "missing")
        return el
    cs = etree.SubElement(el, qn("gco", "CharacterString"))
    cs.text = value
    return el


def _decimal(parent: etree._Element, prefix_local: str, value: float):
    """gmd:<field> wrapping a gco:Decimal (used for bounding-box ordinates)."""
    prefix, local = prefix_local.split(":")
    el = etree.SubElement(parent, qn(prefix, local))
    dec = etree.SubElement(el, qn("gco", "Decimal"))
    dec.text = repr(value)
    return el


def _codelist(parent: etree._Element, prefix_local: str, code_type: str, value: str):
    """gmd:<field> wrapping a codelist element with codeList/codeListValue."""
    prefix, local = prefix_local.split(":")
    wrapper = etree.SubElement(parent, qn(prefix, local))
    code = etree.SubElement(wrapper, qn("gmd", code_type))
    code.set("codeList", f"{CODELIST_CAT}#{code_type}")
    code.set("codeListValue", value)
    code.text = value
    return wrapper


def _responsible_party(parent: etree._Element, prefix_local: str, rp: ResponsibleParty):
    """Encode a CI_ResponsibleParty with an optional email and a CI_RoleCode."""
    prefix, local = prefix_local.split(":")
    wrapper = etree.SubElement(parent, qn(prefix, local))
    party = etree.SubElement(wrapper, qn("gmd", "CI_ResponsibleParty"))
    if rp.individual:
        _char_string(party, "gmd:individualName", rp.individual)
    _char_string(party, "gmd:organisationName", rp.organisation)
    if rp.email:
        contact = etree.SubElement(party, qn("gmd", "contactInfo"))
        ci = etree.SubElement(contact, qn("gmd", "CI_Contact"))
        address = etree.SubElement(ci, qn("gmd", "address"))
        ci_addr = etree.SubElement(address, qn("gmd", "CI_Address"))
        _char_string(ci_addr, "gmd:electronicMailAddress", rp.email)
    _codelist(party, "gmd:role", "CI_RoleCode", rp.role)
    return wrapper


def _temporal_extent(parent: etree._Element, start: date, end: date):
    """gmd:EX_TemporalExtent wrapping a gml:TimePeriod (needs a gml:id)."""
    tex = etree.SubElement(parent, qn("gmd", "temporalElement"))
    ex_te = etree.SubElement(tex, qn("gmd", "EX_TemporalExtent"))
    extent = etree.SubElement(ex_te, qn("gmd", "extent"))
    period = etree.SubElement(extent, qn("gml", "TimePeriod"))
    period.set(qn("gml", "id"), f"tp-{uuid.uuid4().hex[:8]}")
    etree.SubElement(period, qn("gml", "beginPosition")).text = start.isoformat()
    etree.SubElement(period, qn("gml", "endPosition")).text = end.isoformat()


def serialise(md: Iso19115Metadata) -> etree._Element:
    """Map an Iso19115Metadata instance to a gmd:MD_Metadata element tree."""
    root = etree.Element(qn("gmd", "MD_Metadata"), nsmap=NSMAP)

    _char_string(root, "gmd:fileIdentifier", md.identifier)
    _char_string(root, "gmd:language", md.language)
    _codelist(root, "gmd:hierarchyLevel", "MD_ScopeCode", md.scope)
    _responsible_party(root, "gmd:contact", md.contact)

    stamp = etree.SubElement(root, qn("gmd", "dateStamp"))
    etree.SubElement(stamp, qn("gco", "Date")).text = date.today().isoformat()

    id_info = etree.SubElement(root, qn("gmd", "identificationInfo"))
    data_id = etree.SubElement(id_info, qn("gmd", "MD_DataIdentification"))

    citation = etree.SubElement(data_id, qn("gmd", "citation"))
    ci_cit = etree.SubElement(citation, qn("gmd", "CI_Citation"))
    _char_string(ci_cit, "gmd:title", md.title)
    date_wrap = etree.SubElement(ci_cit, qn("gmd", "date"))
    ci_date = etree.SubElement(date_wrap, qn("gmd", "CI_Date"))
    inner = etree.SubElement(ci_date, qn("gmd", "date"))
    etree.SubElement(inner, qn("gco", "Date")).text = md.date_published.isoformat()
    _codelist(ci_date, "gmd:dateType", "CI_DateTypeCode", "publication")

    _char_string(data_id, "gmd:abstract", md.abstract)
    _responsible_party(data_id, "gmd:pointOfContact", md.contact)

    topic = etree.SubElement(data_id, qn("gmd", "topicCategory"))
    # MD_TopicCategoryCode is an enumeration: the value is the element text.
    etree.SubElement(topic, qn("gmd", "MD_TopicCategoryCode")).text = md.topic_category

    extent = etree.SubElement(data_id, qn("gmd", "extent"))
    ex_extent = etree.SubElement(extent, qn("gmd", "EX_Extent"))
    geo = etree.SubElement(ex_extent, qn("gmd", "geographicElement"))
    bbox = etree.SubElement(geo, qn("gmd", "EX_GeographicBoundingBox"))
    _decimal(bbox, "gmd:westBoundLongitude", md.bbox.west)
    _decimal(bbox, "gmd:eastBoundLongitude", md.bbox.east)
    _decimal(bbox, "gmd:southBoundLatitude", md.bbox.south)
    _decimal(bbox, "gmd:northBoundLatitude", md.bbox.north)
    if md.temporal_start and md.temporal_end:
        _temporal_extent(ex_extent, md.temporal_start, md.temporal_end)

    dq = etree.SubElement(root, qn("gmd", "dataQualityInfo"))
    dq_dq = etree.SubElement(dq, qn("gmd", "DQ_DataQuality"))
    scope = etree.SubElement(dq_dq, qn("gmd", "scope"))
    dq_scope = etree.SubElement(scope, qn("gmd", "DQ_Scope"))
    _codelist(dq_scope, "gmd:level", "MD_ScopeCode", md.scope)
    lineage = etree.SubElement(dq_dq, qn("gmd", "lineage"))
    li = etree.SubElement(lineage, qn("gmd", "LI_Lineage"))
    _char_string(li, "gmd:statement", md.lineage)

    return root


if __name__ == "__main__":
    record = Iso19115Metadata(
        identifier="a3f1c2e0-uk-coastline-2024",
        title="UK Coastline Vector Dataset",
        abstract="High-resolution vector coastline of the United Kingdom.",
        date_published=date(2024, 1, 15),
        topic_category="oceans",
        bbox=BoundingBox(west=-8.65, east=1.77, south=49.86, north=60.86),
        contact=ResponsibleParty(
            organisation="Ordnance Survey",
            role="pointOfContact",
            individual="A. Cartographer",
            email="[email protected]",
        ),
        lineage="Digitised from 1:25,000 survey tiles and generalised.",
        temporal_start=date(2023, 1, 1),
        temporal_end=date(2023, 12, 31),
    )
    tree = serialise(record)
    print(etree.tostring(tree, pretty_print=True, xml_declaration=True,
                         encoding="UTF-8").decode())

Step-by-Step Walkthrough

The namespace map is the encoding contract. NSMAP binds the four ISO 19139 namespaces plus xlink. gmd carries every metadata property element (gmd:title, gmd:abstract, gmd:EX_GeographicBoundingBox); gco carries the value primitives (gco:CharacterString, gco:Decimal, gco:Date); gml supplies time and geometry constructs; gmx is reserved for shared codelists and gmx:Anchor links. The qn() helper turns a prefix into the {namespace}local form lxml expects, so every SubElement call lands the element in the right namespace. Passing nsmap=NSMAP on the root element is what makes the serialised document declare xmlns:gmd, xmlns:gco, and the rest once at the top rather than repeating them.

Primitive wrappers are not optional. _char_string() is the workhorse: it creates the gmd property element and, unless the value is None, nests a gco:CharacterString to hold the text. _decimal() follows the same shape for numeric ordinates using gco:Decimal. This two-level structure — property element then typed value — is the single most important rule of the ISO 19139 encoding and the one most often skipped by hand-rolled generators.

Codelists carry three pieces of information. _codelist() emits, for example, gmd:role containing a gmd:CI_RoleCode. The code element gets a codeList attribute pointing at the ISO catalogue, a codeListValue attribute holding the machine-readable token, and matching text content for human readers. The same helper serves MD_ScopeCode (for gmd:hierarchyLevel and the data-quality gmd:level) and CI_DateTypeCode. Note the deliberate exception: MD_TopicCategoryCode is an XSD enumeration, not a codelist, so in serialise() its value is written as element text with no codeList attributes.

Nested constructs expand a single field into a subtree. _responsible_party() turns one ResponsibleParty dataclass into a gmd:CI_ResponsibleParty with an organisation name, an optional gmd:individualName, a full gmd:contactInfo / gmd:CI_Contact / gmd:CI_Address chain for the email, and a CI_RoleCode. _temporal_extent() similarly maps two date fields into an EX_TemporalExtent wrapping a gml:TimePeriod; the gml:id attribute is mandatory on every GML object, hence the generated tp- identifier.

serialise() assembles the document in schema order. ISO 19139 fixes the order of children under MD_Metadata and MD_DataIdentification, so the builder writes fileIdentifier, language, hierarchyLevel, contact, and dateStamp before descending into identificationInfo, then closes with dataQualityInfo for lineage. Emit elements out of order and the XSD validator will reject the document even though every element is individually correct.

Verification

Run the script and confirm the wrapper and codelist patterns appear in the output.

python map_iso19115.py

Expected output (truncated):

<?xml version='1.0' encoding='UTF-8'?>
<gmd:MD_Metadata xmlns:gmd="http://www.isotc211.org/2005/gmd" xmlns:gco="http://www.isotc211.org/2005/gco" xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:gmx="http://www.isotc211.org/2005/gmx" xmlns:xlink="http://www.w3.org/1999/xlink">
  <gmd:fileIdentifier>
    <gco:CharacterString>a3f1c2e0-uk-coastline-2024</gco:CharacterString>
  </gmd:fileIdentifier>
  <gmd:hierarchyLevel>
    <gmd:MD_ScopeCode codeList="http://standards.iso.org/ittf/PubliclyAvailableStandards/ISO_19139_Schemas/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="dataset">dataset</gmd:MD_ScopeCode>
  </gmd:hierarchyLevel>
  <gmd:identificationInfo>
    <gmd:MD_DataIdentification>
      <gmd:citation>
        <gmd:CI_Citation>
          <gmd:title>
            <gco:CharacterString>UK Coastline Vector Dataset</gco:CharacterString>
          </gmd:title>
          ...
      <gmd:extent>
        <gmd:EX_Extent>
          <gmd:geographicElement>
            <gmd:EX_GeographicBoundingBox>
              <gmd:westBoundLongitude>
                <gco:Decimal>-8.65</gco:Decimal>
              </gmd:westBoundLongitude>
              ...

Confirm three things: every free-text property nests a gco:CharacterString, every ordinate nests a gco:Decimal, and each CI_RoleCode / MD_ScopeCode carries both codeList and codeListValue. Once the shape looks right, feed the document into an XSD pass — see the validating ISO 19115 XML against XSD schemas with lxml guide — before publishing the record to a catalogue.

Gotchas and Edge Cases

What is the difference between ISO 19115 and ISO 19139?

ISO 19115 is the abstract, technology-neutral metadata model expressed as UML classes such as MD_Metadata, MD_DataIdentification, and CI_ResponsibleParty. It says nothing about file formats. ISO 19139 is the XML implementation of that model: it defines how each class and attribute is serialised, using the gmd namespace for metadata property elements and gco for primitive-type wrappers. In short, 19115 tells you what to record; 19139 tells you how to encode it. The broader modelling context is covered in Implementing ISO 19115 Metadata Standards.

Why does every text value need a gco:CharacterString wrapper?

ISO 19139 keeps property elements (in gmd) separate from their values (in gco). A gmd:title element does not carry text directly; it contains a gco:CharacterString child that holds the string. That indirection is exactly what lets the encoding attach gco:nilReason to an empty property, swap in a gmx:Anchor for a linked value, or carry a gco:Decimal or gco:Date instead of a string — all without changing the gmd property element. Emitting text directly under gmd:title produces a document that reads fine but fails XSD validation.

How do I encode a missing or unknown metadata value?

Do not emit an empty gco:CharacterString. Leave the gmd property element empty and set gco:nilReason to one of the allowed tokens — missing, unknown, inapplicable, withheld, or template — or a URI justifying the absence. In the code above, _char_string() does this automatically when the dataclass field is None: an unknown abstract becomes <gmd:abstract gco:nilReason="missing"/>. This keeps the record schema-valid while signalling why the value is absent, which downstream harvesters and the CSW Catalog Service Integration layer can act on.

How does ISO 19115-3 relate to ISO 19139?

ISO 19115-3 is the newer XML encoding of the revised ISO 19115-1:2014 model. It replaces the single gmd namespace with a family of namespaces — mdb (metadata base), cit (citation), mri (resource identification), and others — and moves codelists to a new catalogue. The underlying principles are unchanged: property elements still wrap gco primitives, and codelists still use codeList and codeListValue. Code written against 19139, like the serialiser here, ports to 19115-3 mostly by remapping the namespace map and element names rather than rethinking the structure.


Back to Implementing ISO 19115 Metadata Standards

Related