This guide is part of Ingesting WFS & OGC API — Features Services, within the broader Mastering Geospatial Data Ingestion in Python reference.

Converting GML Responses to GeoDataFrames

When a WFS offers no GeoJSON output format, the response is GML — an XML dialect with namespaced properties, nested complex types and a coordinate order that depends on how the CRS was named.

Why Hand-Parsing GML Goes Wrong

  • Namespace prefixes are arbitrary. The prefix ms: in one deployment is topp: in the next, and XPath bound to a prefix quietly matches nothing rather than raising.
  • Coordinates appear in three different elements. gml:pos, gml:posList and the deprecated gml:coordinates all occur in live services, with different separators.
  • srsName may be a URN, a URL or a short code. Each form implies a different axis order convention, and the difference transposes every coordinate.
  • Properties nest. A feature can carry a complex property containing further elements, which has no natural representation in a flat table and needs an explicit flattening rule.

Version and Environment Compatibility

Component Version Note
GDAL >=3.6 GML driver, .gfs schema inference, SWAP_COORDINATES option
GeoPandas >=1.0 read_file with an explicit driver
pyogrio >=0.9 Faster reader; falls back to Fiona if unavailable
pyproj >=3.6 Parses URN CRS forms and exposes axis order
pip install "geopandas>=1.0" "pyogrio>=0.9" "pyproj>=3.6" "lxml>=5.0"

What GDAL Does That Hand-Parsing Does Not

What the GML driver resolves on your behalf A GML payload enters GDAL's GML driver, which performs four jobs before the data reaches a GeoDataFrame: it resolves or infers the application schema, maps namespaced property names onto flat field names, normalises pos, posList and coordinates elements into geometries, and extracts the srsName declaration. Each of these is a separate source of bugs in hand-written parsers. GML payload namespaced XML GDAL GML driver resolves or infers the schema (.gfs) maps ns:property → flat field name normalises pos / posList / coordinates reads srsName and dimension and builds an index for repeat reads GeoDataFrame flat, typed, CRS set

The gml_to_geodataframe Recipe

from __future__ import annotations

import logging
import re
import tempfile
from pathlib import Path

import geopandas as gpd
from pyproj import CRS

logger = logging.getLogger(__name__)

SRS_PATTERN = re.compile(rb'srsName="([^"]+)"')


def _srs_from_payload(payload: bytes) -> str | None:
    """Read the first srsName declaration out of the raw GML."""
    match = SRS_PATTERN.search(payload)
    return match.group(1).decode() if match else None


def _needs_axis_swap(srs: str) -> bool:
    """URN-form EPSG references follow authority axis order (lat, lon for 4326)."""
    if not srs:
        return False
    if "CRS84" in srs.upper():
        return False
    try:
        crs = CRS.from_user_input(srs)
    except Exception:  # noqa: BLE001 - unknown authority strings are common in the wild
        logger.warning("unrecognised srsName %r; assuming lon/lat order", srs)
        return False
    order = [axis.abbrev.lower() for axis in crs.axis_info][:2]
    return order == ["lat", "lon"]


def gml_to_geodataframe(
    payload: bytes,
    target_crs: int = 4326,
    flatten_sep: str = "_",
) -> gpd.GeoDataFrame:
    """Parse a WFS GML payload into a flat, CRS-correct GeoDataFrame.

    The payload is written to a temporary file because GDAL's GML driver
    resolves the application schema from the document and caches it beside it.
    """
    srs = _srs_from_payload(payload)
    swap = _needs_axis_swap(srs or "")

    with tempfile.TemporaryDirectory() as tmpdir:
        gml_path = Path(tmpdir) / "response.gml"
        gml_path.write_bytes(payload)

        # SWAP_COORDINATES=YES makes GDAL apply the authority order itself,
        # which is more reliable than transforming the geometries afterwards.
        gdf = gpd.read_file(
            gml_path,
            driver="GML",
            SWAP_COORDINATES="YES" if swap else "NO",
        )

    if gdf.empty:
        logger.warning("GML payload parsed to zero features (srsName=%s)", srs)
        return gpd.GeoDataFrame(geometry=[], crs=target_crs)

    if gdf.crs is None and srs:
        gdf = gdf.set_crs(CRS.from_user_input(srs), allow_override=True)
    if gdf.crs is None:
        raise ValueError("GML response declared no CRS and none could be inferred")

    # Flatten any nested property columns the driver surfaced as dicts.
    for column in list(gdf.columns):
        if column == gdf.geometry.name:
            continue
        sample = gdf[column].dropna().head(1)
        if len(sample) and isinstance(sample.iloc[0], dict):
            expanded = gdf[column].apply(lambda value: value or {}).apply(pd.Series)
            expanded.columns = [f"{column}{flatten_sep}{sub}" for sub in expanded.columns]
            gdf = gdf.drop(columns=[column]).join(expanded)

    result = gdf.to_crs(target_crs)
    logger.info("parsed %d features from GML (srsName=%s, swap=%s)", len(result), srs, swap)
    return result
import pandas as pd  # imported alongside the recipe for the flattening step

Key Implementation Notes

  • The payload goes to disk. GDAL’s GML driver builds a .gfs schema sidecar during the read; giving it a real file rather than an in-memory buffer is what makes attribute typing work.
  • SWAP_COORDINATES is preferred over swapping geometries afterwards. Doing it at read time keeps the geometry objects untouched and avoids a second pass over every coordinate.
  • srsName is read from the raw bytes, not from the parsed frame. GDAL sometimes leaves the CRS unset for schemas it could not resolve, and the declaration in the document is still the authoritative statement.
  • Zero features is a warning, not an exception. An empty result is legitimate for a bbox that matches nothing; the caller decides whether it is acceptable, using the count check from handling WFS GetFeature requests with OWSLib.
  • Flattening is prefix-based and reversible. A nested address property becomes address_street and address_city, which survives a write to GeoParquet and can be reconstructed if needed.
  • The final to_crs is explicit so that every caller receives the same reference system regardless of what the service served.
Three ways the same coordinates arrive Three fragments of GML encoding the same two positions. The pos element carries one coordinate pair per element. The posList element carries a whitespace-separated run of ordinates whose grouping depends on the declared dimension. The deprecated coordinates element uses configurable decimal, coordinate and tuple separators. All three normalise to the same geometry through the driver. all three appear in production services gml:pos <gml:pos>55.95 -3.19</gml:pos> one pair per element order follows srsName gml:posList 55.95 -3.19 55.96 -3.18 flat run of ordinates grouping needs srsDimension gml:coordinates -3.19,55.95 -3.18,55.96 deprecated, still common separators are configurable A parser that handles only the first form works against one service and fails against the next — which is the whole argument for reading through GDAL.

Troubleshooting GML Parsing

Symptom Likely cause Fix
Attributes all null, geometry fine Schema not resolved; driver fell back to geometry-only Supply the .xsd, or let GDAL write a .gfs from a full read
Identifier column lost leading zeros Type inferred as integer Provide the schema, or force the column to string after read
Coordinates transposed URN srsName with authority axis order Set SWAP_COORDINATES=YES, as the recipe does
posList parsed as one long line srsDimension missing or wrong Read the dimension from the geometry element and pass it through
Read is very slow on a large payload Schema inference scanning the whole document twice Cache the .gfs sidecar between runs of the same layer
What the GML path costs relative to GeoJSON Two pairs of bars for the same fifty thousand features. GML transfers about 340 megabytes and parses in roughly 46 seconds. GeoJSON transfers about 82 megabytes and parses in roughly 7 seconds. The comparison is the practical reason to negotiate an output format rather than accept the default. 50 000 features, same layer, same service GML · bytes 340 MB GeoJSON · bytes 82 MB GML · parse 46 s GeoJSON · parse 7 s Negotiate the format once in capabilities and the whole difference disappears without any change to the parsing code.

Integration Note

This parser sits directly behind the transport function in handling WFS GetFeature requests with OWSLib: the transport returns bytes, this returns a frame, and the split is what lets the parser be tested against a directory of recorded payloads with no network at all. Downstream, the frame goes to the CRS and validity checks described in ingesting WFS and OGC API — Features services before anything writes it.

Keeping the Schema Sidecar

GDAL writes a .gfs file next to the GML it reads, holding the field names and types it inferred. On a repeat read of the same layer that sidecar removes the inference scan entirely, which for a large payload is most of the parse time.

For a pipeline that reads the same layer daily, generate the sidecar once from a representative full payload, commit it alongside the connector configuration, and copy it next to each downloaded response before reading. Two things improve at once: the parse gets faster, and the field types stop depending on whichever values happened to appear in today’s extract — the cause of an identifier column that is a string on Monday and an integer on Tuesday.