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 istopp:in the next, and XPath bound to a prefix quietly matches nothing rather than raising. - Coordinates appear in three different elements.
gml:pos,gml:posListand the deprecatedgml:coordinatesall occur in live services, with different separators. srsNamemay 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
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 resultimport pandas as pd # imported alongside the recipe for the flattening stepKey Implementation Notes
- The payload goes to disk. GDAL’s GML driver builds a
.gfsschema sidecar during the read; giving it a real file rather than an in-memory buffer is what makes attribute typing work. SWAP_COORDINATESis preferred over swapping geometries afterwards. Doing it at read time keeps the geometry objects untouched and avoids a second pass over every coordinate.srsNameis 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
addressproperty becomesaddress_streetandaddress_city, which survives a write to GeoParquet and can be reconstructed if needed. - The final
to_crsis explicit so that every caller receives the same reference system regardless of what the service served.
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 |
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.