Implementing ISO 19115 Metadata Standards

ISO 19115 is the internationally recognized schema for describing geographic information, covering dataset identification, spatial representation, coordinate reference systems, distribution channels, lineage, and data quality. For GIS platform engineers, spatial data publishers, and government technical teams, strict implementation of this standard is what separates a metadata record that passes catalog ingestion from one that silently breaks spatial discovery. The standard itself defines a conceptual model; the practical engineering challenge is serializing that model into schema-valid XML, running automated validation before publication, and keeping records synchronized as source datasets evolve.

This guide covers the complete production workflow: extracting spatial characteristics from heterogeneous source formats, mapping them to the gmd:MD_Metadata element hierarchy, constructing namespace-correct XML with lxml, validating against authoritative XSD schemas, and publishing to enterprise catalog endpoints. As one of the core standards in broader Spatial Metadata & Catalog Integration pipelines, ISO 19115 sits at the intersection of OGC service contracts, open-data publishing mandates, and enterprise GIS catalog infrastructure.

From a dataset to a catalog record in six steps A six-stage pipeline. Source metadata is introspected from PostGIS or a raster archive into a single typed Python model; the abstract ISO 19115 concepts are mapped onto ISO 19139 element paths in one serialiser rather than scattered through the codebase; lxml produces namespace-correct XML; the record is validated against a locally vendored schema set with the full error log retained; and only a passing record is published through a catalogue transaction. Source dataset PostGIS or raster archive Extract typed Python model Map 19115 concepts to 19139 paths Serialise lxml, namespace-correct Validate XSD, full error log Publish CSW transaction introspect one model one serialiser local XSD on pass

Prerequisites & Architecture Context

Before implementing metadata generation, ensure your environment meets these baseline requirements:

  • Python 3.10+ with pip package management
  • Core libraries: lxml (XML construction and XSD validation), pydantic>=2.0 (structured metadata modeling and type enforcement), geopandas or rasterio (spatial dataset introspection), pyproj>=3.4 (CRS authority resolution and coordinate transformation), requests (catalog endpoint publishing)
  • Schema assets: ISO 19139 XSD files (gmd, gco, gml namespaces) — the authoritative source is the OGC schema repository at http://schemas.opengis.net/iso/19139/20060504/gmd/gmd.xsd
  • Catalog credentials: Endpoints and API keys for GeoNetwork, pycsw, ArcGIS Enterprise, CKAN, or custom CSW/OGC API – Records services
pip install lxml "pydantic>=2.0" geopandas pyproj requests

Understanding where ISO 19115 sits in the broader pipeline is essential context. The Spatial Metadata & Catalog Integration architecture shows how ISO 19115 records feed into CSW 2.0.2 and OGC API – Records endpoints, which in turn power spatial search indexes. The SRS and Coordinate Reference System Handling guide is required reading before this one, because every bounding box extraction and CRS encoding decision in ISO 19115 depends on understanding axis-order rules — getting these wrong produces structurally valid but semantically broken records.

Specification Deep-Dive

ISO 19115 vs ISO 19139 vs ISO 19115-3

These three standards are frequently conflated:

The mandatory core of an ISO 19115 record A seven-row grid listing the ISO 19115 concepts that INSPIRE treats as mandatory, alongside the ISO 19139 element path that encodes each. Every row except the distribution format is unconditionally required, which makes them the right set for a pre-flight check before a record is ever sent to a catalog. Concept ISO 19139 element Required by INSPIRE? File identifier gmd:fileIdentifier yes Title gmd:citation/…/gmd:title yes Abstract gmd:abstract yes Topic category gmd:topicCategory yes Geographic extent gmd:EX_GeographicBoundingBox yes Lineage gmd:LI_Lineage yes Distribution format gmd:MD_Format conditional
Standard Role Namespaces
ISO 19115-1:2014 Conceptual metadata schema — element semantics, mandatory vs conditional None (UML)
ISO 19139:2007 XML encoding of ISO 19115 gmd, gco, gml, srv
ISO 19115-3:2023 Updated XML encoding, successor to 19139 mdb, cit, mri, mcc

Most production catalogs still use ISO 19139 (gmd namespace). ISO 19115-3 is required by some INSPIRE member state implementations from 2024 onward. Build your internal model as a Python dataclass and write separate XML serializers for each target encoding — this avoids duplicating business logic when both are required.

Mandatory element hierarchy

gmd:MD_Metadata is the root element. Its mandatory children in the ISO 19139 encoding are:

Element Description Mandatory?
gmd:fileIdentifier UUID for unique record tracking Conditional (recommended)
gmd:language ISO 639-2 language code (e.g. eng) Mandatory
gmd:characterSet Character encoding code-list value (typically utf8) Conditional
gmd:hierarchyLevel Scope: dataset, series, service, tile Conditional
gmd:contact Responsible party for the metadata record Mandatory
gmd:dateStamp Date the metadata record was created/modified Mandatory
gmd:identificationInfo Title, abstract, keywords, extents, purpose Mandatory
gmd:referenceSystemInfo CRS identifier (EPSG URN) Conditional (mandatory for datasets)
gmd:distributionInfo Format, transfer options, online resources Conditional
gmd:dataQualityInfo Lineage, conformance, DQ reports Conditional

Within identificationInfo, the MD_DataIdentification block carries citation (title, date, identifier), abstract, language, characterSet, and extent (the bounding box via EX_GeographicBoundingBox). These four — title, abstract, language, and at least one geographic extent — are the fields that CSW harvesters will check before admitting a record.

Version divergence and INSPIRE constraints

INSPIRE Technical Guidelines mandate specific element paths that go beyond the base ISO 19115 standard:

  • gmd:MD_Metadata/gmd:identificationInfo/gmd:MD_DataIdentification/gmd:extent/gmd:EX_Extent/gmd:temporalElement — temporal coverage is mandatory under INSPIRE even though ISO 19115 marks it conditional
  • gmd:MD_Metadata/gmd:dataQualityInfo/gmd:DQ_DataQuality/gmd:report/gmd:DQ_DomainConsistency — a INSPIRE conformance report is required, declaring conformance to the relevant INSPIRE data specification
  • The CRS must be expressed as a full OGC URN (urn:ogc:def:crs:EPSG::4326) rather than the shorthand EPSG:4326

If your pipeline targets INSPIRE nodes alongside generic CSW endpoints, version-gate these additions behind a configuration flag rather than hardcoding them into the base serializer.

Python Implementation

Step 1: Extract spatial dataset characteristics

The extraction layer parses the source dataset and captures mandatory metadata elements into a typed Pydantic model, catching malformed or missing spatial references before XML serialization:

import uuid
from datetime import datetime, timezone
from typing import Optional

import geopandas as gpd
from pydantic import BaseModel, Field, field_validator
from pyproj import CRS

class DatasetProfile(BaseModel):
    record_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    title: str
    abstract: str
    publication_date: datetime
    bbox: tuple[float, float, float, float]  # west, south, east, north in EPSG:4326
    crs_epsg: int
    hierarchy_level: str = "dataset"
    language: str = "eng"
    contact_email: str = ""

    @field_validator("crs_epsg")
    @classmethod
    def validate_epsg(cls, v: int) -> int:
        try:
            CRS.from_epsg(v)
        except Exception as exc:
            raise ValueError(f"Unknown EPSG code {v}: {exc}") from exc
        return v

    @field_validator("bbox")
    @classmethod
    def validate_bbox(cls, v: tuple) -> tuple:
        west, south, east, north = v
        if not (-180 <= west <= 180 and -180 <= east <= 180):
            raise ValueError(f"Longitude out of range: ({west}, {east})")
        if not (-90 <= south <= 90 and -90 <= north <= 90):
            raise ValueError(f"Latitude out of range: ({south}, {north})")
        return v

def extract_profile(path: str, title: str, abstract: str,
                    contact_email: str = "") -> DatasetProfile:
    gdf = gpd.read_file(path)
    bounds = gdf.total_bounds  # minx, miny, maxx, maxy
    src_crs = gdf.crs

    # Always reproject bounds to EPSG:4326 for the canonical bounding box.
    # pyproj always_xy=True ensures longitude-first regardless of CRS axis order —
    # without this flag, EPSG:4326 returns (lat, lon) and silently inverts every bbox.
    if src_crs and not src_crs.equals(CRS.from_epsg(4326)):
        from pyproj import Transformer
        transformer = Transformer.from_crs(src_crs, CRS.from_epsg(4326), always_xy=True)
        west, south = transformer.transform(bounds[0], bounds[1])
        east, north = transformer.transform(bounds[2], bounds[3])
    else:
        west, south, east, north = bounds[0], bounds[1], bounds[2], bounds[3]

    return DatasetProfile(
        title=title,
        abstract=abstract,
        publication_date=datetime.now(timezone.utc),
        bbox=(west, south, east, north),
        crs_epsg=src_crs.to_epsg() if src_crs and src_crs.to_epsg() else 4326,
        contact_email=contact_email,
    )

The always_xy=True flag in Transformer.from_crs is not optional — see the SRS and Coordinate Reference System Handling guide for a detailed explanation of why EPSG:4326 axis order inverts bounding boxes in pipelines that omit it.

Step 2: Map to the ISO 19115 element hierarchy

Understanding which fields are mandatory and where they live in the hierarchy prevents the most common cause of catalog rejection — missing elements in the identificationInfo block. When cross-walking to DCAT-AP for Spatial Data Portals, this mapping layer is also where you decide which ISO fields have DCAT-AP equivalents and which require custom RDF properties.

Keep a clear separation between the Pydantic model (business logic) and the XML serializer (presentation). This allows you to target ISO 19139 and ISO 19115-3 from the same profile object by swapping serializers without touching extraction logic.

Step 3: Construct XML with strict namespace compliance

ISO 19115 records depend on four coordinated XML namespaces: gmd (metadata schema), gco (primitive types like CharacterString and Date), gml (geometry and CRS references), and xsi (schema location). Mishandling prefixes is the most common cause of harvester rejection — lxml will auto-generate ns0, ns1 prefixes if you don’t declare the namespace map explicitly, and many CSW implementations treat unexpected prefixes as parse errors.

Using lxml.builder.ElementMaker is more maintainable than manual f"{{{namespace}}}ElementName" string concatenation, especially when the document tree is deep:

from lxml import etree
from lxml.builder import ElementMaker

GMD = "http://www.isotc211.org/2005/gmd"
GCO = "http://www.isotc211.org/2005/gco"
GML = "http://www.opengis.net/gml"
XSI = "http://www.w3.org/2001/XMLSchema-instance"

NSMAP = {"gmd": GMD, "gco": GCO, "gml": GML, "xsi": XSI}
SCHEMA_LOCATION = (
    "http://www.isotc211.org/2005/gmd "
    "http://schemas.opengis.net/iso/19139/20060504/gmd/gmd.xsd"
)

gmd = ElementMaker(namespace=GMD, nsmap=NSMAP)
gco = ElementMaker(namespace=GCO, nsmap=NSMAP)

def build_iso19115_xml(profile: DatasetProfile) -> etree._Element:
    west, south, east, north = profile.bbox

    root = gmd.MD_Metadata(
        {f"{{{XSI}}}schemaLocation": SCHEMA_LOCATION},
        gmd.fileIdentifier(
            gco.CharacterString(profile.record_id)
        ),
        gmd.language(
            gco.CharacterString(profile.language)
        ),
        gmd.characterSet(
            gmd.MD_CharacterSetCode(
                codeList="http://standards.iso.org/ittf/PubliclyAvailableStandards/"
                         "ISO_19139_Schemas/resources/codelist/ML_gmxCodelists.xml"
                         "#MD_CharacterSetCode",
                codeListValue="utf8",
            )
        ),
        gmd.hierarchyLevel(
            gmd.MD_ScopeCode(
                codeList="http://standards.iso.org/ittf/PubliclyAvailableStandards/"
                         "ISO_19139_Schemas/resources/codelist/ML_gmxCodelists.xml"
                         "#MD_ScopeCode",
                codeListValue=profile.hierarchy_level,
            )
        ),
        gmd.dateStamp(
            gco.DateTime(profile.publication_date.strftime("%Y-%m-%dT%H:%M:%SZ"))
        ),
        gmd.referenceSystemInfo(
            gmd.MD_ReferenceSystem(
                gmd.referenceSystemIdentifier(
                    gmd.RS_Identifier(
                        gmd.code(
                            gco.CharacterString(
                                f"urn:ogc:def:crs:EPSG::{profile.crs_epsg}"
                            )
                        )
                    )
                )
            )
        ),
        gmd.identificationInfo(
            gmd.MD_DataIdentification(
                gmd.citation(
                    gmd.CI_Citation(
                        gmd.title(gco.CharacterString(profile.title)),
                        gmd.date(
                            gmd.CI_Date(
                                gmd.date(
                                    gco.Date(
                                        profile.publication_date.strftime("%Y-%m-%d")
                                    )
                                ),
                                gmd.dateType(
                                    gmd.CI_DateTypeCode(
                                        codeList=(
                                            "http://standards.iso.org/ittf/"
                                            "PubliclyAvailableStandards/ISO_19139_Schemas/"
                                            "resources/codelist/ML_gmxCodelists.xml"
                                            "#CI_DateTypeCode"
                                        ),
                                        codeListValue="publication",
                                    )
                                ),
                            )
                        ),
                    )
                ),
                gmd.abstract(gco.CharacterString(profile.abstract)),
                gmd.language(gco.CharacterString(profile.language)),
                gmd.extent(
                    gmd.EX_Extent(
                        gmd.geographicElement(
                            gmd.EX_GeographicBoundingBox(
                                gmd.extentTypeCode(gco.Boolean("true")),
                                gmd.westBoundLongitude(gco.Decimal(str(west))),
                                gmd.eastBoundLongitude(gco.Decimal(str(east))),
                                gmd.southBoundLatitude(gco.Decimal(str(south))),
                                gmd.northBoundLatitude(gco.Decimal(str(north))),
                            )
                        )
                    )
                ),
            )
        ),
    )
    return root

def serialize(element: etree._Element) -> bytes:
    return etree.tostring(
        element,
        pretty_print=True,
        xml_declaration=True,
        encoding="UTF-8",
    )

The codeListValue attributes on MD_ScopeCode, MD_CharacterSetCode, and CI_DateTypeCode are code-list references required by the XSD. Omitting them or using incorrect values (e.g. "Dataset" instead of "dataset") produces XSD validation errors even though the XML is syntactically well-formed.

Error Handling & Edge Cases

Missing or partial source data

Geospatial datasets frequently lack publication dates, authoritative abstracts, or precise bounding boxes. Implement deterministic fallbacks in the extraction layer rather than relying on downstream exception handling:

  • Use the file modification timestamp (pathlib.Path.stat().st_mtime) as publicationDate when creation dates are unavailable, and set a lineage statement noting the inferred date
  • Generate a machine-readable abstract from layer name and attribute dictionary if no human-authored text exists: f"Vector dataset '{layer_name}' with {len(fields)} attribute fields."
  • Default to WGS84 global extent only as a last resort (-180, -90, 180, 90), and mark the record with a gmd:dataQualityInfo scope note flagging it for manual review

Namespace prefix conflicts

If lxml generates ns0 or ns1 prefixes, your record will likely be rejected by harvesters even if it passes XSD validation. The cause is usually creating subelements without the root nsmap context. Always pass nsmap=NSMAP to the root ElementMaker and never use etree.SubElement on a tree built with ElementMaker — the two approaches manage namespace registration differently and combining them corrupts the prefix table.

CRS encoding mismatches

Several enterprise catalogs (GeoNetwork 3.x, ArcGIS Enterprise) accept both EPSG:4326 and the full OGC URN, but INSPIRE nodes and some pycsw configurations require the URN form (urn:ogc:def:crs:EPSG::4326). Note the double colon before the code — a single colon is a common typo that produces a URN the catalog cannot resolve. The handling spatial reference mismatches in OGC requests page documents CRS encoding conventions across OGC protocol versions in detail.

Testing & Compliance Verification

Unit test skeleton

import pytest
from lxml import etree
from pathlib import Path

from your_catalog.iso19115 import build_iso19115_xml, serialize, DatasetProfile
from datetime import datetime, timezone

SCHEMA_PATH = Path("schemas/iso19139/gmd/gmd.xsd")
GMD_NS = "http://www.isotc211.org/2005/gmd"
GCO_NS = "http://www.isotc211.org/2005/gco"

@pytest.fixture
def sample_profile() -> DatasetProfile:
    return DatasetProfile(
        title="Flood Risk Zones — Rhine Basin",
        abstract="Vector dataset of 100-year flood inundation extents for the Rhine catchment.",
        publication_date=datetime(2024, 3, 15, tzinfo=timezone.utc),
        bbox=(6.0, 47.0, 15.0, 52.0),
        crs_epsg=4326,
        contact_email="[email protected]",
    )

def test_validates_against_xsd(sample_profile: DatasetProfile) -> None:
    xml_bytes = serialize(build_iso19115_xml(sample_profile))
    with SCHEMA_PATH.open("rb") as f:
        schema = etree.XMLSchema(etree.parse(f))
    doc = etree.fromstring(xml_bytes)
    errors = [str(e) for e in schema.error_log]
    assert not errors, "XSD validation failed:\n" + "\n".join(errors)

def test_title_roundtrips(sample_profile: DatasetProfile) -> None:
    xml_bytes = serialize(build_iso19115_xml(sample_profile))
    doc = etree.fromstring(xml_bytes)
    ns = {"gmd": GMD_NS, "gco": GCO_NS}
    title = doc.findtext(".//gmd:title/gco:CharacterString", namespaces=ns)
    assert title == sample_profile.title

def test_bbox_values_correct(sample_profile: DatasetProfile) -> None:
    xml_bytes = serialize(build_iso19115_xml(sample_profile))
    doc = etree.fromstring(xml_bytes)
    ns = {"gmd": GMD_NS, "gco": GCO_NS}
    west = doc.findtext(".//gmd:westBoundLongitude/gco:Decimal", namespaces=ns)
    assert float(west) == pytest.approx(6.0)

Pre-compile the XSD schema tree once per test session using a pytest session-scoped fixture rather than parsing it in every test — the schema document is large and parsing it repeatedly adds 2–5 seconds to a test run.

Validating ISO 19115 XML Against XSD Schemas with lxml covers extended patterns for caching schema trees, handling network-fallback XSD resolution, and generating human-readable error reports for use in CI pipelines.

OGC CITE compliance

The OGC CITE test suite does not test ISO 19115 document structure directly, but it does test whether CSW endpoints return schema-valid records in response to GetRecordById with OUTPUTSCHEMA=http://www.isotc211.org/2005/gmd. Running your serializer output through the CITE CSW test suite against a local pycsw instance is the most reliable end-to-end validation path. Use the TEAM Engine Docker image for local runs:

docker run --rm -p 8080:8080 ogccite/teamengine:latest

Performance & Scaling Notes

Pre-compile XSD schemas

Parsing the ISO 19139 XSD on every validation call is expensive — the gmd.xsd imports several dependent schemas (gco, gml, gmd sub-schemas) and the total parse time can reach 800ms on cold start. Initialize the etree.XMLSchema object once at application startup and share it across worker threads:

Schema compilation is the cost; validation is not A four-stage chain for bulk ISO 19139 validation. Compiling the modular ISO schema set resolves dozens of imports and takes seconds, so it happens once; the compiled XMLSchema object is reused for every record in the process; records are independent so validation parallelises cleanly; and each record's structured error log is kept rather than collapsing the result to a single boolean. Load the XSD once module resolution is slow Reuse XMLSchema one compiled object Validate in a pool records are independent Collect error logs per record, structured seconds per process parallel
from lxml import etree
from pathlib import Path
from functools import lru_cache

@lru_cache(maxsize=1)
def get_iso19139_schema(schema_path: str = "schemas/iso19139/gmd/gmd.xsd") -> etree.XMLSchema:
    with Path(schema_path).open("rb") as f:
        doc = etree.parse(f)
    return etree.XMLSchema(doc)

Memory management for large batches

When generating thousands of ISO 19115 records concurrently, lxml element trees accumulate in memory unless explicitly freed. Call root.clear() after serializing each record to a byte string, and avoid holding references to intermediate element objects. For pipeline throughput above 500 records per second, consider streaming XML output via lxml.etree.xmlfile rather than building full trees in memory.

Connection pooling for catalog publishing

The publish_to_csw function below uses requests.Session with retry logic. Initialize the session once and reuse it across all records in a batch — creating a new session per record leaves TCP connections dangling and can exhaust ephemeral port ranges under sustained load:

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def make_session(retries: int = 3) -> requests.Session:
    session = requests.Session()
    retry = Retry(
        total=retries,
        backoff_factor=1.5,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["POST"],
    )
    adapter = HTTPAdapter(
        max_retries=retry,
        pool_connections=4,
        pool_maxsize=20,
    )
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session

def publish_to_csw(endpoint: str, xml_bytes: bytes,
                   api_key: str, session: requests.Session) -> dict:
    headers = {
        "Content-Type": "application/xml; charset=utf-8",
        "Authorization": f"Bearer {api_key}",
    }
    response = session.post(endpoint, data=xml_bytes, headers=headers, timeout=30)
    response.raise_for_status()
    return {"status": response.status_code, "body": response.text[:200]}

After initial publication, metadata drift is inevitable as source datasets update their extents, CRS, or distribution URLs. Integrating with Automated Metadata Harvesting Workflows ensures changes are detected and synchronized without manual intervention — compare checksums of generated XML before triggering catalog updates to avoid unnecessary API load.

Gotchas / Frequently Asked Questions

What is the difference between ISO 19115 and ISO 19139?

ISO 19115 defines the conceptual schema — element semantics, mandatory vs conditional status, and the UML model. ISO 19139 (and its successor ISO 19115-3) defines the XML encoding of that schema, including the gmd/gco namespace structure and the XSD files used for validation. In practice, when building Python pipelines you work with ISO 19139 XML, but the field semantics (what abstract means, when extent is mandatory) come from ISO 19115.

Why do downstream CSW harvesters reject records that appear structurally valid?

The most common causes are: namespace prefix conflicts (lxml generating ns0/ns1 instead of gmd/gco), missing schemaLocation attributes on the root element, and invalid codeListValue attributes on code-list elements such as MD_ScopeCode. Pre-compile the XSD schema tree once at startup and validate every record before publishing — never assume well-formed XML is schema-compliant.

Should I target ISO 19139:2007 or ISO 19115-3 for new pipelines?

ISO 19139:2007 (gmd namespace) remains the most widely supported encoding across legacy GeoNetwork, pycsw, and INSPIRE infrastructure. ISO 19115-3 (mdb namespace) is correct for new deployments and required by some national INSPIRE nodes from 2024 onward. Build your canonical model as a Python dataclass and write separate XML serializers for each target encoding — this avoids duplicating business logic when both are required.

What is the correct EPSG CRS encoding inside gmd:referenceSystemInfo?

Use an RS_Identifier with a code element containing the OGC URN form: urn:ogc:def:crs:EPSG::4326 (double colon before the code, no version segment). The plain EPSG:4326 string is widely accepted but the URN form is mandated by several INSPIRE Technical Guidelines and is unambiguous about the authority. Note the double colon — a single colon is a common typo that produces an unresolvable URN.

How do I handle datasets that lack a publication date?

Use the file modification timestamp as a fallback publicationDate and include a gmd:dataQualityInfo lineage statement noting the date was inferred. Mark these records in your catalog store so data stewards can review them. Never omit the date element entirely — most XSD validators treat a missing CI_Date as a mandatory-field violation, which sends the record to quarantine on the first harvest.


Back to Spatial Metadata & Catalog Integration

Related