This guide is part of Ingesting WFS & OGC API — Features Services, within the broader Mastering Geospatial Data Ingestion in Python reference.
Handling WFS GetFeature Requests with OWSLib
A GetFeature request that works interactively in a browser frequently fails in a pipeline, because the pipeline asks for the whole layer rather than a demo extent, and because it asks repeatedly.
Why WFS Reads Break in Production
- The unpaged request times out. Gateways cut the connection at 60 or 120 seconds and the client sees a truncated body, not an error.
- Offset paging without a sort returns inconsistent windows. Features appear twice and others never appear at all, with no signal that anything went wrong.
- The default output format is GML. A parser written against GeoJSON silently receives XML and fails several stages later, or worse, parses partially.
- A
bboxwithout a CRS suffix is interpreted in the layer’s native CRS. A degree bounding box against a metre-based layer matches nothing, and an empty result is indistinguishable from a legitimate one.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| OWSLib | >=0.31 |
WFS 2.0 support including sortby and resulttype |
| GeoPandas | >=1.0 |
pyogrio engine reads the response payload directly |
| GDAL | >=3.6 |
GML and GeoJSON drivers; needed for the fallback path |
| Python | 3.10+ | Union syntax used in the recipe below |
pip install "owslib>=0.31" "geopandas>=1.0" "pyogrio>=0.9" "pandas>=2.2"What the Capabilities Document Decides
The fetch_wfs_layer Recipe
from __future__ import annotations
import io
import logging
import geopandas as gpd
import pandas as pd
from owslib.wfs import WebFeatureService
logger = logging.getLogger(__name__)
GEOJSON_HINTS = ("geojson", "json")
def _pick_output_format(wfs: WebFeatureService) -> tuple[str | None, str]:
"""Return (outputFormat, driver) preferring GeoJSON, falling back to GML."""
operation = next(op for op in wfs.operations if op.name == "GetFeature")
formats = operation.parameters.get("outputFormat", {}).get("values", [])
for fmt in formats:
if any(hint in fmt.lower() for hint in GEOJSON_HINTS):
return fmt, "GeoJSON"
logger.warning("service offers no GeoJSON output; falling back to GML")
return None, "GML"
def fetch_wfs_layer(
url: str,
layer: str,
sort_property: str,
page_size: int = 1000,
bbox: tuple[float, float, float, float] | None = None,
crs: str = "urn:ogc:def:crs:OGC:1.3:CRS84",
max_pages: int = 5000,
) -> gpd.GeoDataFrame:
"""Read a whole WFS 2.0 layer as a GeoDataFrame, paged and order-stable.
sort_property must be unique across the layer: offset paging is only
reproducible when the service returns rows in a deterministic order.
"""
wfs = WebFeatureService(url=url, version="2.0.0", timeout=120)
if layer not in wfs.contents:
raise KeyError(f"{layer!r} not published by this service")
output_format, driver = _pick_output_format(wfs)
bbox_arg = (*bbox, crs) if bbox else None
frames: list[gpd.GeoDataFrame] = []
collected = 0
for page_no in range(max_pages):
response = wfs.getfeature(
typename=[layer],
maxfeatures=page_size,
startindex=page_no * page_size,
sortby=[sort_property],
bbox=bbox_arg,
srsname=crs,
outputFormat=output_format,
)
payload = response.read()
if not payload:
break
page = gpd.read_file(io.BytesIO(payload), driver=driver)
if page.empty:
break
frames.append(page)
collected += len(page)
logger.debug("page %d: %d features (%d total)", page_no, len(page), collected)
if len(page) < page_size:
break # short page means the layer is exhausted
else:
raise RuntimeError(f"{layer}: exceeded {max_pages} pages — paging is not terminating")
if not frames:
return gpd.GeoDataFrame(geometry=[], crs=4326)
result = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True))
if result.crs is None:
result = result.set_crs(4326)
logger.info("%s: %d features over %d pages", layer, len(result), len(frames))
return resultKey Implementation Notes
sortbyis not optional. Without it,startindexaddresses an undefined ordering. Services differ on whether they reject an unsortable property or silently ignore the parameter, so verify by requesting page one twice and comparing the first feature id.- The short-page break is the terminator, not an explicit count. It works whether or not the service reports
numberMatched, and it costs one extra request at most. CRS84is requested by default because it removes the axis-order ambiguity entirely; when a service rejects it, fall back to the URN form and swap explicitly.- The GML path is a fallback, not an equal branch. GDAL’s GML driver handles namespaces and schema resolution that hand-written parsing does not, which is why the payload goes to
read_filerather than to an XML parser. max_pagesconverts a non-terminating service into a loud failure instead of an overnight loop that fills a disk.- The response is read into memory per page. For pages above a few hundred megabytes, stream to a temporary file and let GDAL read from disk instead.
Filtering Server-Side
Two filters matter for ingestion, and both reduce transfer rather than just reducing what you keep.
The bounding box filter takes four ordinates plus a CRS identifier, and the CRS suffix is mandatory in practice even where the specification treats it as optional. Omit it and the service interprets the ordinates in the layer’s native reference system, which for a projected layer means a degree box matches nothing at all.
Filter Encoding predicates express attribute conditions as XML. OWSLib builds them through owslib.fes2, which is verbose but avoids hand-assembling namespaces:
from owslib.fes2 import PropertyIsEqualTo, And, PropertyIsGreaterThan
query = And([
PropertyIsEqualTo(propertyname="land_use", literal="residential"),
PropertyIsGreaterThan(propertyname="area_sqm", literal="500"),
])
filter_xml = query.toXML()Pass the serialised filter as the filter parameter alongside typename. Where a service rejects the predicate it usually returns an exception report rather than an empty set — but verify, because a service that ignores an unsupported filter returns the whole layer, and the pipeline will happily ingest it.
Troubleshooting WFS Reads
| Symptom | Likely cause | Fix |
|---|---|---|
| Response body is XML when GeoJSON was requested | Service ignored outputFormat |
Check the advertised list; fall back to the GML driver |
| Empty result with a valid bbox | Missing CRS suffix on the bbox | Append the CRS identifier to the bbox tuple |
| Features repeat across pages | No stable sort applied | Add sortby on a unique property, or partition by bbox |
| Paging never terminates | Service ignores startindex |
Detect identical first-feature ids and fail fast |
| Coordinates transposed | Authority axis order on EPSG:4326 |
Request CRS84, or swap and assert the bounds |
| Exception report about the sort property | Property is not sortable server-side | Sort on the feature id, or partition spatially |
Integration Note
fetch_wfs_layer belongs in the ingestion stage described in ingesting WFS and OGC API — Features services, directly upstream of the CRS and geometry checks. Where the service offers only GML, chain it into the parsing recipe in converting GML responses to GeoDataFrames rather than passing the raw payload downstream. The retry and concurrency budget belong to the orchestrator, not to this function.
Caching Capabilities Between Runs
GetCapabilities responses are large — a service publishing hundreds of layers can return several megabytes of XML — and they change rarely. Fetching one on every pipeline run wastes both parties’ time and adds a failure point at the very start of the job, where a transient network error aborts everything downstream.
Cache the parsed facts rather than the document: the layer’s output formats, CRS options, bounding box, and the sort property you settled on. Refresh weekly, or whenever a request fails in a way that suggests the contract moved — an unexpected output format, a rejected sort property, a layer name that no longer resolves. Treat a change in those cached facts as an event worth logging, because it usually explains whatever broke immediately afterwards.