This guide is part of Mastering Geospatial Data Ingestion in Python.

Ingesting WFS and OGC API — Features Services in Python

The Problem: A Standards-Compliant Endpoint Is Not a Predictable One

Web Feature Service and its successor, OGC API — Features, are the two interfaces through which most public authorities publish vector data. Both are standardised, both are well documented, and neither behaves the same way twice across publishers. A pipeline that reads one national cadastre successfully will fail against the next one for reasons that are entirely legal under the specification: a different default coordinate order, a server-side feature cap the capabilities document does not mention, an output format list that includes GeoJSON but returns GML anyway.

Naive approaches fail in three specific ways. Reading the whole layer in one request works in development against a test extent and times out against the real one. Trusting the declared CRS produces silently transposed coordinates for half the services in Europe. And paging with a numeric startIndex against a service whose ordering is unstable returns duplicated and missing features in the same run — the pattern described in parsing GeoJSON and shapefile APIs, and worse here because the specification does not require a stable sort.

The ingestion loop this page builds negotiates capabilities first, pins the coordinate order explicitly, pages with a bounded request size, and records enough state to resume.

The two interfaces, side by side A comparison of the request and response shape for each interface. WFS 2.0 uses a GetFeature operation with typeNames, count and startIndex parameters and returns GML by default. OGC API — Features uses a collections items path with limit and bbox parameters and returns GeoJSON with a next link. Both require the client to state the coordinate reference system explicitly rather than rely on the default. WFS 2.0 GET ?service=WFS&request=GetFeature &typeNames=ms:parcels &count=1000&startIndex=2000 &srsName=urn:ogc:def:crs:EPSG::4326 returns GML unless outputFormat set paging by offset — needs a stable sort OGC API — Features GET /collections/parcels/items ?limit=1000 &bbox=-3.3,55.9,-3.1,56.0 &crs=...CRS84 returns GeoJSON, CRS84 by default paging by rel=next link — cursor safe Same data, same publisher, two contracts — the ingestion code should share everything below the transport layer and nothing above it.

Prerequisites and Environment

pip install "owslib>=0.31" "geopandas>=1.0" "pyogrio>=0.9" "requests>=2.32" "shapely>=2.0"

OWSLib handles the WFS capabilities negotiation; pyogrio reads the GeoJSON and GML payloads through GDAL without a temporary file for the JSON case. Verify the GDAL build has the GML driver before relying on it, since minimal wheels sometimes omit it:

import pyogrio

drivers = pyogrio.list_drivers()
assert "GML" in drivers, "GDAL build lacks the GML driver — install libgdal with the full driver set"
assert "GeoJSON" in drivers

Version and Compatibility Matrix

Component Version Behaviour that matters
OWSLib >=0.31 WFS 2.0 capabilities parsing, resultType=hits support
GeoPandas >=1.0 pyogrio default engine; stable CRS round-trip through GeoJSON
GDAL >=3.6 Honours SWAP_COORDINATES for authority-ordered CRSs
pyproj >=3.6 CRS.from_user_input accepts URN forms such as urn:ogc:def:crs:EPSG::4326

Step 1 — Negotiate Capabilities Before Requesting Anything

The capabilities document tells you which layers exist, which output formats they support, which coordinate reference systems they can serve, and — critically — whether the service caps the number of features per request.

import logging
from owslib.wfs import WebFeatureService

logger = logging.getLogger(__name__)


def describe_wfs_layer(url: str, layer: str, version: str = "2.0.0") -> dict:
    """Return the facts an ingestion loop needs before its first GetFeature."""
    wfs = WebFeatureService(url=url, version=version, timeout=60)
    if layer not in wfs.contents:
        raise KeyError(f"layer {layer!r} not in service; available: {sorted(wfs.contents)[:10]}")

    meta = wfs.contents[layer]
    operation = next(op for op in wfs.operations if op.name == "GetFeature")
    formats = operation.parameters.get("outputFormat", {}).get("values", [])

    facts = {
        "title": meta.title,
        "crs_options": [str(c) for c in meta.crsOptions],
        "bbox_wgs84": meta.boundingBoxWGS84,
        "output_formats": formats,
        "prefers_geojson": any("json" in f.lower() for f in formats),
    }
    logger.info("layer %s: %d CRS options, geojson=%s", layer, len(facts["crs_options"]),
                facts["prefers_geojson"])
    return facts

The prefers_geojson flag decides the rest of the pipeline. A service that offers GeoJSON removes the GML parsing step entirely; one that does not requires the approach in converting GML responses to GeoDataFrames.

Step 2 — Count Before You Download

resultType=hits costs one cheap request and changes what the ingestion does. Under a few thousand features, page once and be done; over a few hundred thousand, split by bounding box before paging at all.

import requests
from xml.etree import ElementTree

WFS_NS = {"wfs": "http://www.opengis.net/wfs/2.0"}


def count_features(url: str, layer: str, bbox: tuple[float, float, float, float] | None = None) -> int:
    """Ask the service how many features match, without transferring any of them."""
    params = {
        "service": "WFS", "version": "2.0.0", "request": "GetFeature",
        "typeNames": layer, "resultType": "hits",
    }
    if bbox:
        params["bbox"] = ",".join(str(v) for v in bbox) + ",urn:ogc:def:crs:EPSG::4326"

    response = requests.get(url, params=params, timeout=60)
    response.raise_for_status()
    root = ElementTree.fromstring(response.content)
    matched = root.attrib.get("numberMatched", "unknown")
    return -1 if matched == "unknown" else int(matched)

A service that returns unknown is telling you it cannot count without scanning. Treat that as a signal to partition spatially rather than as a reason to skip the check.

Choosing the read shape from the hit count Three ranges of matched-feature counts map to three ingestion shapes. A small layer is read in a single request. A medium layer is paged sequentially with a bounded page size. A large layer is divided into bounding-box partitions that each page independently and can be retried or parallelised on their own. resultType=hits numberMatched < 5 000 one request, no paging, no state to keep 5 000 – 500 000 sequential paging, resume token persisted per page > 500 000 split the extent into bbox partitions, page each independently The third shape is the only one that survives a mid-run failure without re-reading everything already fetched.

Step 3 — Pin the Coordinate Order Explicitly

This is the single most common source of silently wrong output from a standards-compliant service. WFS 1.1.0 and 2.0.0 follow the axis order defined by the EPSG authority, and for EPSG:4326 that order is latitude first. A client that assumes longitude first plots Edinburgh in the Indian Ocean.

from pyproj import CRS
import geopandas as gpd


def normalize_axis_order(gdf: gpd.GeoDataFrame, requested_crs: str) -> gpd.GeoDataFrame:
    """Return a frame in longitude-latitude order, whatever the service sent."""
    crs = CRS.from_user_input(requested_crs)
    axis_names = [ax.abbrev.lower() for ax in crs.axis_info]

    if axis_names[:2] == ["lat", "lon"]:
        # Authority order — swap into the x=lon, y=lat convention the rest of the pipeline assumes.
        flipped = gdf.geometry.map(lambda geom: shapely.ops.transform(lambda x, y: (y, x), geom))
        gdf = gdf.set_geometry(gpd.GeoSeries(flipped, crs=4326))
    return gdf.set_crs(4326, allow_override=True)

The reliable alternative is to avoid the ambiguity: request urn:ogc:def:crs:OGC:1.3:CRS84, which is WGS 84 with longitude first by definition. Every OGC API — Features service supports it, and most modern WFS deployments do too. Where it is unavailable, verify the result against a known point before trusting a full ingest — a check that costs one assertion and prevents an entire dataset landing transposed, in the same spirit as the CRS invariants set out in CRS normalization across mixed datasets.

Step 4 — Page With Bounded Requests and Persisted State

from pathlib import Path
import json

import geopandas as gpd
import pandas as pd
import requests


def page_ogc_items(items_url: str, page_size: int = 1000,
                   state_path: Path | None = None) -> gpd.GeoDataFrame:
    """Walk an OGC API — Features collection through its next links, resumably."""
    session = requests.Session()
    session.headers["User-Agent"] = "spatial-etl/1.0 (data@example.org)"

    next_url = items_url
    if state_path and state_path.exists():
        next_url = json.loads(state_path.read_text())["next_url"]

    frames: list[gpd.GeoDataFrame] = []
    while next_url:
        response = session.get(next_url, params={"limit": page_size} if next_url == items_url else None,
                               timeout=120)
        response.raise_for_status()
        payload = response.json()

        page = gpd.GeoDataFrame.from_features(payload["features"], crs="OGC:CRS84")
        frames.append(page)

        links = {link["rel"]: link["href"] for link in payload.get("links", [])}
        next_url = links.get("next")
        if state_path:
            state_path.write_text(json.dumps({"next_url": next_url, "pages": len(frames)}))

    if not frames:
        return gpd.GeoDataFrame(geometry=[], crs="OGC:CRS84")
    return gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="OGC:CRS84")

Persisting the next link after each page is what makes the loop resumable: a worker that dies on page 240 restarts on page 240 rather than page 1. The equivalent for WFS is the startIndex offset, which is resumable only when the service applies a stable sortBy — see paging through OGC API — Features with Python for the failure modes when it does not.

Step 5 — Validate What Arrived Before It Leaves the Stage

def assert_ingest_contract(gdf: gpd.GeoDataFrame, expected_count: int,
                           layer: str, tolerance: float = 0.001) -> None:
    """Fail loudly rather than pass a partial or transposed layer downstream."""
    if gdf.crs is None:
        raise ValueError(f"{layer}: no CRS on the ingested frame")

    if expected_count > 0:
        shortfall = abs(len(gdf) - expected_count) / expected_count
        if shortfall > tolerance:
            raise ValueError(f"{layer}: got {len(gdf)} features, service reported {expected_count}")

    minx, miny, maxx, maxy = gdf.total_bounds
    if not (-180 <= minx <= 180 and -90 <= miny <= 90):
        raise ValueError(f"{layer}: bounds {gdf.total_bounds} outside WGS84 — axis order suspect")

    invalid = (~gdf.geometry.is_valid).sum()
    if invalid:
        logging.getLogger(__name__).warning("%s: %d invalid geometries on arrival", layer, invalid)

The bounds check is the axis-order guard: latitude values above 90 cannot appear in a longitude column, so a transposed layer fails here rather than three stages later. Geometry validity is logged rather than raised, because repair belongs in geometry repair with Shapely and GeoPandas rather than in the ingestion boundary.

The contract an ingested layer must satisfy Four checks at the boundary of the ingestion stage. The CRS must be present. The feature count must match the count the service itself reported, within a small tolerance. The bounds must lie inside the target CRS area of use, which is what catches a transposed axis order. Geometry validity is measured and recorded rather than enforced, because repair happens in a later stage. nothing leaves the ingestion stage until all four hold crs is not None — a frame without a CRS is not ingested, it is guessed at len(gdf) ≈ numberMatched — catches a page silently dropped mid-walk bounds inside the area of use — the axis-order guard, and it is free invalid geometry count — recorded as a metric, repaired downstream

Advanced Patterns and Edge Cases

Filtering Server-Side With CQL2 Rather Than in Pandas

An OGC API — Features service advertising the filter conformance class accepts CQL2 expressions, which moves the predicate to the database that holds the data. Filtering land_use = 'residential' server-side over a national layer transfers thousands of features instead of millions, and the difference is entirely in transfer time rather than in cleverness. Check /conformance first, because a filter parameter an endpoint does not implement is usually ignored rather than rejected — and an ignored filter returns everything, which looks like success.

Layers That Change Underneath a Long Walk

A paged read of half a million features can take an hour, and the source may be edited during it. Cursor-based paging protects the walk’s consistency but not its completeness: features added behind the cursor are missed until the next run. Where the service exposes a timestamp property, record the run’s start time and re-query for anything modified after it on the following run, using the overlap-window logic from incremental spatial loading with watermark timestamps.

Services That Cap Silently

Many deployments enforce a maximum feature count per request that the capabilities document does not mention. The symptom is a page that returns exactly 1 000 or 5 000 features regardless of the count you asked for. Detect it by comparing the returned page length against the requested one on the first page and logging the effective cap — then size the paging loop to the real limit rather than the hoped-for one.

Performance Notes

Three measurements dominate WFS ingestion cost, and only one of them is in your control.

Transfer is usually the largest, and GeoJSON over gzip is materially smaller than GML for the same features — request compression explicitly with an Accept-Encoding header, since some servers only compress when asked. Parsing comes second: pyogrio reads GeoJSON several times faster than a pure-Python loop over json.loads, and the gap widens with attribute count. Server-side query time is third and outside your influence, which is the argument for asking for less rather than for asking faster.

Where a layer is read repeatedly, cache the raw response bodies keyed by request URL and validator, exactly as in detecting dataset changes with ETag and Last-Modified. Most public services set validators correctly, and a cached ingest costs one conditional request per page.

Integration Into ETL Pipelines

The ingestion function returns a GeoDataFrame and nothing else — no writes, no side effects — which keeps it testable against recorded fixtures. The orchestration layer decides the schedule, the retry policy and the concurrency budget, along the boundary described in orchestrating spatial ETL pipelines. Failed pages go to the dead-letter path with their request URL attached, so a replay re-issues exactly the request that failed rather than restarting the layer.

Failure-Mode Reference

Failure mode Root cause Mitigation
Coordinates transposed Service honours EPSG authority axis order; client assumes lon/lat Request OGC:CRS84, or swap explicitly and assert bounds
Page returns fewer rows than requested Undocumented server-side feature cap Detect the effective cap on page one, resize the loop
Duplicate and missing features across pages Offset paging over an unsorted result set Add sortBy on a unique property, or use cursor paging
numberMatched reported as unknown Service cannot count without scanning Partition by bounding box and count per partition
Filter appears ignored Endpoint does not implement the filter conformance class Check /conformance at startup, fail if the class is absent
GML parses but attributes are empty Namespace prefix mismatch in the response Read with GDAL’s GML driver rather than hand-rolled XPath

Handling Authentication Where It Exists

Most public WFS endpoints are open, but the interesting ones increasingly are not. Three schemes cover nearly all of them, and each interacts differently with paging.

HTTP basic or bearer tokens are the simplest: set the header once on a requests.Session and every page inherits it. The only trap is a token that expires mid-walk, which for a long paged read is a real risk — refresh proactively rather than on the first 401, as set out in automating government portal downloads.

API keys in the query string survive paging badly, because the next link a service returns may or may not carry the key forward. Check the first next URL for the key and re-append it when the service has stripped it, rather than discovering the problem as a 401 on page two.

Session cookies issued by a login endpoint work but tie the walk to one client. Persist the cookie jar alongside the resume token if the ingestion must survive a process restart, and treat an unexpected redirect to a login page as an error rather than following it — an HTML login form parsed as GeoJSON produces a confusing exception several layers away from its cause.

Choosing Between Bulk Download and Service Reads

A WFS endpoint is not always the right way to obtain a layer, even when it is the documented one. Where the publisher also offers a full extract — a nightly GeoPackage, a regional shapefile archive — that path is usually faster, cheaper for both parties, and immune to paging bugs entirely.

The service read wins in three situations: when you need a small spatial or attribute subset of a large layer, when you need data fresher than the extract cadence, and when the extract is not published for the layer you want. Otherwise the extract wins, and the ingestion becomes a conditional download plus a local read — the pattern in detecting dataset changes with ETag and Last-Modified.

It is worth writing the comparison down per source, because the answer changes as a project grows: a pipeline that started needing one city’s parcels and now needs the country’s has usually crossed the line without anyone revisiting the decision.

Recording the Service Contract Alongside the Data

Everything discovered during negotiation — the effective page cap, the coordinate order actually returned, whether the filter class is implemented, the observed numberMatched — is knowledge that cost real requests to acquire and is lost at the end of the run unless it is written down.

Store it as a small JSON document next to the layer’s configuration and refresh it on a slow schedule rather than on every run. Two things then become possible. A change in the service — a new cap, a dropped conformance class, a different default CRS — shows up as a diff on that document rather than as a mysterious failure. And a new pipeline against the same endpoint starts from the observed contract instead of rediscovering it from scratch.