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.
Before implementing metadata generation, ensure your environment meets these baseline requirements:
pip package managementlxml (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)gmd, gco, gml namespaces) — the authoritative source is the OGC schema repository at http://schemas.opengis.net/iso/19139/20060504/gmd/gmd.xsdpip 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.
These three standards are frequently conflated:
| 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.
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.
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 conditionalgmd: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 specificationurn:ogc:def:crs:EPSG::4326) rather than the shorthand EPSG:4326If 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.
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.
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.
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.
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:
pathlib.Path.stat().st_mtime) as publicationDate when creation dates are unavailable, and set a lineage statement noting the inferred datef"Vector dataset '{layer_name}' with {len(fields)} attribute fields."-180, -90, 180, 90), and mark the record with a gmd:dataQualityInfo scope note flagging it for manual reviewIf 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.
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.
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.
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
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:
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)
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.
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.
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.
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.
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.
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.
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