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, and gml prefixes 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, a gml polygon, 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_Identifier code like 4326 with a separate authority; concatenating them wrong yields an EPSG string that pyproj cannot 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

ISO 19139 element structure mapped to OWSLib attributes Tree diagram: MD_Metadata root branches into identification (title, abstract, keywords, bbox), referenceSystemInfo (CRS code), extent (temporal begin/end), and distributionInfo (online resource URLs), each labelled with the OWSLib attribute that reads it. MD_Metadata gmd:MD_Metadata identification title · abstract · keywords · bbox md.identification[0] .bbox / .keywords referenceSystemInfo authority + code → EPSG md.referencesystem extent (temporal) begin / end position identification.temporalextent distributionInfo online resource URLs md.distribution.online

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 parsed

Key Implementation Notes

  • Iterate md.identification, do not assume index 0 is data. Service records place SV_ServiceIdentification first, which has no bounding box. When bbox is None, 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 like urn:ogc:def:crs:EPSG::4326. Guard with code.isdigit() so you never produce EPSG:urn:....

  • getattr with a default absorbs OWSLib version drift. Attribute names such as temporalextent_start shifted across OWSLib releases; reading through getattr(ident, name, None) keeps the parser resilient rather than raising AttributeError on 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.type per block before merging.

  • Serve outputschema as gmd on 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 what MD_Metadata needs.

  • Validate with Pydantic at the boundary. Returning a SpatialMetadata model 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.