This guide is part of Web Scraping Spatial Metadata, which itself sits within the broader Mastering Geospatial Data Ingestion in Python reference.
Parsing ISO 19115 Metadata With OWSLib
The Problem: ISO Metadata XML Is Too Nested to Hand-Parse Reliably
Spatial data catalogues — GeoNetwork nodes, national SDI portals, INSPIRE endpoints — describe every dataset with an ISO 19115 metadata record serialized as ISO 19139 XML. That XML nests the bounding box four namespaces deep, splits keywords across typed thesaurus blocks, and encodes the CRS as an authority-and-code pair buried under a reference-system element. Writing XPath by hand against it is a losing game.
Hand-rolled parsing breaks spatial ingestion in predictable ways:
- Namespace fragility: The
gmd,gco, andgmlprefixes are declared per-document and can be remapped; XPath that hard-codes a prefix silently returns nothing when a catalogue uses a different one. - Bounding-box ambiguity: A record may express extent as an axis-aligned
EX_GeographicBoundingBox, agmlpolygon, or both — a parser that only reads one variant drops half the catalogue’s spatial footprints. - CRS encoded as free text: The reference system often arrives as an
RS_Identifiercode like4326with a separate authority; concatenating them wrong yields anEPSGstring thatpyprojcannot resolve. - Keyword type loss: Flattening theme, place, and temporal keywords into one bag discards the thesaurus context downstream discovery needs.
OWSLib’s owslib.iso.MD_Metadata already models all of this, so you extract typed fields instead of chasing namespaces.
Version and Environment Compatibility
| Library | Version | Role | Notes |
|---|---|---|---|
OWSLib |
>=0.31.0 |
ISO/CSW parsing | MD_Metadata and CatalogueServiceWeb stable; earlier 0.2x differs in attribute names |
lxml |
>=5.0 |
XML backend | Required by OWSLib; parses namespaced 19139 documents |
pydantic |
>=2.0 |
Output validation | Coerces extracted fields; rejects malformed records early |
pip install "OWSLib>=0.31.0" "lxml>=5.0" "pydantic>=2.0"Where the Fields Live in an ISO 19139 Record
MD_Metadata reads the whole tree once and exposes each region as a Python attribute: md.identification for the data-identification block, md.referencesystem for the CRS, and md.distribution for download links. You never touch a namespace prefix directly.
The parse_iso19115 Recipe
The function below parses a raw ISO 19139 document into a validated dictionary, handling the multi-record identification list, the axis-aligned bounding box, the authority-plus-code CRS, temporal begin/end, typed keywords, and distribution URLs.
import logging
from typing import Optional
from lxml import etree
from owslib.iso import MD_Metadata
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class SpatialMetadata(BaseModel):
"""Clean, validated view of an ISO 19115 record."""
title: Optional[str] = None
abstract: Optional[str] = None
bbox: Optional[tuple[float, float, float, float]] = None # (w, s, e, n)
crs: Optional[str] = None # e.g. "EPSG:4326"
temporal_extent: Optional[tuple[str, str]] = None # (begin, end)
keywords: list[str] = Field(default_factory=list)
distribution_urls: list[str] = Field(default_factory=list)
def parse_iso19115(xml_bytes: bytes) -> SpatialMetadata:
"""
Parse an ISO 19115/19139 metadata document into a SpatialMetadata model.
Falls back gracefully when optional blocks are absent so a partial record
still yields whatever fields were present rather than raising.
"""
root = etree.fromstring(xml_bytes)
md = MD_Metadata(root)
# A record can carry several identification blocks; the first data
# identification usually holds the descriptive fields we want.
ident = md.identification[0] if md.identification else None
bbox: Optional[tuple[float, float, float, float]] = None
if ident is not None and getattr(ident, "bbox", None) is not None:
b = ident.bbox
try:
bbox = (float(b.minx), float(b.miny), float(b.maxx), float(b.maxy))
except (TypeError, ValueError):
logger.warning("Non-numeric bounding box; skipping bbox extraction")
# Reference system: OWSLib exposes the raw code; prefix with EPSG when numeric.
crs: Optional[str] = None
if md.referencesystem is not None and md.referencesystem.code:
code = md.referencesystem.code
crs = f"EPSG:{code}" if code.isdigit() else code
# Temporal extent lives on the identification block as begin/end positions.
temporal: Optional[tuple[str, str]] = None
begin = getattr(ident, "temporalextent_start", None) if ident else None
end = getattr(ident, "temporalextent_end", None) if ident else None
if begin or end:
temporal = (begin or "", end or "")
# Keywords arrive as typed blocks; flatten to a deduplicated list.
keywords: list[str] = []
if ident is not None:
for kw in getattr(ident, "keywords", []) or []:
keywords.extend(k for k in (kw.keywords or []) if k)
keywords = sorted({k.strip() for k in keywords if k and k.strip()})
# Distribution online resources hold the actual download / service URLs.
urls: list[str] = []
if md.distribution is not None:
for online in md.distribution.online or []:
if online.url:
urls.append(online.url)
return SpatialMetadata(
title=getattr(ident, "title", None) if ident else None,
abstract=getattr(ident, "abstract", None) if ident else None,
bbox=bbox,
crs=crs,
temporal_extent=temporal,
keywords=keywords,
distribution_urls=urls,
)To pull records straight from a catalogue rather than from files on disk, wrap the same parser around a CSW query:
from owslib.csw import CatalogueServiceWeb
def harvest_catalogue(csw_url: str, max_records: int = 50) -> list[SpatialMetadata]:
"""Fetch full ISO records from a CSW endpoint and parse each one."""
csw = CatalogueServiceWeb(csw_url, timeout=60)
csw.getrecords2(esn="full", maxrecords=max_records, outputschema="http://www.isotc211.org/2005/gmd")
parsed: list[SpatialMetadata] = []
for record_id, record in csw.records.items():
# record is already an MD_Metadata; re-serialize is unnecessary here.
xml = record.xml if isinstance(record.xml, bytes) else record.xml.encode("utf-8")
try:
parsed.append(parse_iso19115(xml))
except Exception as exc: # noqa: BLE001
logger.error("Failed to parse record %s: %s", record_id, exc)
return parsedKey Implementation Notes
-
Iterate
md.identification, do not assume index 0 is data. Service records placeSV_ServiceIdentificationfirst, which has no bounding box. WhenbboxisNone, loop the remaining identification blocks before giving up. -
Prefix the CRS code only when it is numeric. EPSG codes arrive bare (
4326), but some catalogues emit full URNs likeurn:ogc:def:crs:EPSG::4326. Guard withcode.isdigit()so you never produceEPSG:urn:.... -
getattrwith a default absorbs OWSLib version drift. Attribute names such astemporalextent_startshifted across OWSLib releases; reading throughgetattr(ident, name, None)keeps the parser resilient rather than raisingAttributeErroron an older node. -
Deduplicate keywords but record their block type if discovery needs it. The flattening above collapses theme and place keywords together; if a downstream search index distinguishes them, capture
kw.typeper block before merging. -
Serve
outputschemaasgmdon CSW. Requesting the Dublin Core schema returns a thin summary with no bounding box or distribution links; the full ISO schema (http://www.isotc211.org/2005/gmd) is whatMD_Metadataneeds. -
Validate with Pydantic at the boundary. Returning a
SpatialMetadatamodel rather than a bare dict means a record missing a required field surfaces immediately, keeping malformed metadata out of the catalogue index.
Troubleshooting ISO Metadata Parsing
| Failure Mode | Root Cause | Mitigation |
|---|---|---|
bbox is None on a valid record |
First identification is a service block, or extent uses a gml polygon |
Iterate all identification records; parse the gml envelope as a fallback |
CRS resolves to an invalid EPSG string |
Code is a URN, not a bare integer | Only prefix EPSG: when code.isdigit(); otherwise pass the URN through |
keywords empty despite XML having them |
Keywords sit in typed blocks the flatten step missed | Iterate every MD_Keywords block and extend from each .keywords list |
| CSW returns records with no distribution URLs | Query used Dublin Core outputschema |
Set outputschema to the ISO gmd namespace and esn="full" |
AttributeError on temporal fields |
OWSLib version renamed the extent attribute | Read through getattr(ident, name, None) and pin OWSLib>=0.31 |
Integration Note
parse_iso19115 is the extraction step for the discovery workflow in Web Scraping Spatial Metadata: feed each parsed record’s bbox and crs into your dataset registry so downstream stages can filter by footprint before fetching a single byte. The distribution_urls field hands directly to a fetcher, and pairing it with change detection via ETag and Last-Modified means the catalogue harvest only re-downloads records whose distribution files actually changed. Reproject the extracted bounding box to a working CRS before spatial filtering, since ISO records commonly report extents in WGS84 regardless of the dataset’s native projection.
Related
- Web Scraping Spatial Metadata — parent guide covering metadata discovery, catalogue crawling, and registry building
- Detecting Dataset Changes With ETag and Last-Modified — skip re-downloading distribution files that have not changed
- Mastering Geospatial Data Ingestion in Python — top-level reference for ingestion architecture and schema validation