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 bbox without 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

What to read from capabilities before the first GetFeature The capabilities response feeds three decisions. The advertised output formats decide whether the response is parsed as GeoJSON or GML. The CRS options decide which coordinate reference system is requested and whether an axis swap is required. The availability of a unique sortable property decides whether the layer can be paged by offset or must be partitioned spatially instead. GetCapabilities one call, cached outputFormat list → decides the parser: GeoJSON path or GML path crsOptions → decides srsName and whether an axis swap is needed a unique sortable property → decides offset paging versus bbox partitioning

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 result

Key Implementation Notes

  • sortby is not optional. Without it, startindex addresses 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.
  • CRS84 is 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_file rather than to an XML parser.
  • max_pages converts 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.
What an unstable sort does to offset paging Two request sequences over the same six features. With sortBy applied, the ordering is identical across requests and the two offset windows cover the set exactly once. Without sortBy, the second request returns a different ordering, so the second window repeats one feature already collected and omits another that was never returned. with sortBy · windows are disjoint a b c d e f page 1 = a b c · page 2 = d e f without sortBy · the second request reorders a b c c f a page 2 repeats c, and d and e are never returned at all The totals can still look right, which is what makes this failure survive review.

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
Server-side filtering versus filtering after transfer Two bars representing the same query over a national layer. Filtering client-side transfers 2.4 million features and keeps 18 000. Filtering server-side with a bounding box and an attribute predicate transfers 18 000 features and keeps all of them. The work performed is identical; only the position of the filter differs. same query, same result, different transfer filter after download 2.4 M features over the wire 18 000 kept filter server-side 18 000 features over the wire, all of them wanted The saving is not only bandwidth: a smaller response also removes the paging loop and the timeout risk that came with it.

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.