WFS 2.0 vs 1.1.0 Breaking Changes for Backend Devs

TL;DR

Migrating from WFS 1.1.0 to 2.0 requires changes across four distinct layers: the GML output namespace shifts from http://www.opengis.net/gml to http://www.opengis.net/gml/3.2, the filter encoding prefix changes from ogc: to fes:, pagination parameters rename from maxFeatures/startPosition to count/startIndex (zero-based), and transaction response elements are restructured around wfs:InsertResults. An unmodified 1.1.0 client aimed at a strict 2.0 server will trigger 400, 406, or empty-result failures at all four layers simultaneously, making the root cause difficult to isolate without systematic per-layer testing.


Core Challenge: Four Independent Breaking Surfaces

The difficulty of this migration is not any single change — each one is straightforward in isolation. The challenge is that all four breaking surfaces activate at the same time against a real server, and each produces a different failure mode. A namespace mismatch in the filter returns an InvalidParameterValue service exception. A missing Accept header silently falls back to text/xml with GML 3.1.1 structure. A maxFeatures/startPosition pair is either ignored or triggers a 400. A transaction response parsed for WFS 1.1.0 result elements yields null references against a 2.0 payload.

The diagram below maps each breaking surface to its failure mode so you can triage a failing migration systematically:

WFS 1.1.0 → 2.0 Breaking Change Surfaces Four horizontal lanes showing each breaking surface (GML namespace, filter encoding, pagination, transactions), the change required, and the failure mode if not updated. BREAKING SURFACE CHANGE REQUIRED FAILURE IF SKIPPED GML Namespace 3.1.1 → 3.2.1 http://www.opengis.net/gml Update xmlns to .../gml/3.2 in parsers, XPath, and XSLT Empty node sets Silent "no data" rather than a parse error Filter Encoding OGC 1.1 → FES 2.0 ogc: prefix → fes: prefix Replace xmlns:ogc with xmlns:fes=".../fes/2.0" and all element prefixes InvalidParameterValue Server rejects filter with HTTP 400 Pagination maxFeatures → count startPosition → startIndex (0-based) Swap params; read numberMatched and numberReturned attrs Off-by-one skipped page First feature dropped or empty last page returned Transactions InsertResult → InsertResults wfs:native removed Parse wfs:InsertResults/ fes:ResourceId; add handle attr to wfs:Transaction Null ref on insert IDs Referential integrity lost; duplicate inserts on retry

Production-Ready Migration Code

The script below handles the four breaking surfaces in a single, self-contained module. It targets a WFS 2.0 GetFeature request with a spatial filter, parses the GML 3.2.1 response, and pages through results using count/startIndex. Copy it as the foundation for any WFS 2.0 migration, replacing the endpoint URL and type name.

"""wfs2_client.py — WFS 2.0 GetFeature with FES 2.0 filtering and pagination.

Dependencies (all pip-installable):
    pip install requests lxml

Python 3.10+ required.
"""
from __future__ import annotations

import sys
from typing import Iterator

import requests
from lxml import etree

# ── Namespace map ────────────────────────────────────────────────────────────
# WFS 2.0 mandates GML 3.2.1 and FES 2.0.  Both namespaces changed from 1.1.0.
NSMAP: dict[str, str] = {
    "wfs": "http://www.opengis.net/wfs/2.0",       # was http://www.opengis.net/wfs
    "fes": "http://www.opengis.net/fes/2.0",        # replaces ogc: entirely
    "gml": "http://www.opengis.net/gml/3.2",        # was http://www.opengis.net/gml
    "xsi": "http://www.w3.org/2001/XMLSchema-instance",
}

# ── Request builder ──────────────────────────────────────────────────────────
def build_get_feature_xml(
    type_name: str,
    bbox: tuple[float, float, float, float],
    srs_name: str = "urn:ogc:def:crs:EPSG::4326",
    count: int = 100,
    start_index: int = 0,
) -> bytes:
    """Build a WFS 2.0 GetFeature XML body with a FES 2.0 BBOX filter.

    Args:
        type_name:   Qualified feature type name (e.g. 'ns:rivers').
        bbox:        (minLon, minLat, maxLon, maxLat) in the target SRS.
        srs_name:    URN-format SRS identifier. WFS 2.0 requires explicit srsName.
        count:       Max features per page (replaces 1.1.0 maxFeatures).
        start_index: Zero-based page offset (replaces 1.1.0 startPosition).
    """
    # Root element: wfs:GetFeature with mandatory SERVICE, VERSION, count, startIndex
    root = etree.Element(
        "{http://www.opengis.net/wfs/2.0}GetFeature",
        attrib={
            "service": "WFS",
            "version": "2.0.0",
            "count": str(count),
            "startIndex": str(start_index),   # zero-based — 1.1.0 used 1-based
        },
    )

    # wfs:Query specifies the feature type
    query = etree.SubElement(
        root,
        "{http://www.opengis.net/wfs/2.0}Query",
        attrib={"typeNames": type_name},
    )

    # fes:Filter with a fes:BBOX spatial predicate
    # Note the fes: prefix — ogc:BBOX is not valid in WFS 2.0
    fes_filter = etree.SubElement(
        query,
        "{http://www.opengis.net/fes/2.0}Filter",
    )
    bbox_elem = etree.SubElement(
        fes_filter,
        "{http://www.opengis.net/fes/2.0}BBOX",   # ogc:BBOX → fes:BBOX
    )

    # gml:Envelope replaces gml:Box from GML 3.1.1; srsName is required
    envelope = etree.SubElement(
        bbox_elem,
        "{http://www.opengis.net/gml/3.2}Envelope",
        attrib={"srsName": srs_name},
    )
    lower = etree.SubElement(
        envelope, "{http://www.opengis.net/gml/3.2}lowerCorner"
    )
    lower.text = f"{bbox[0]} {bbox[1]}"

    upper = etree.SubElement(
        envelope, "{http://www.opengis.net/gml/3.2}upperCorner"
    )
    upper.text = f"{bbox[2]} {bbox[3]}"

    return etree.tostring(root, xml_declaration=True, encoding="UTF-8")


# ── Response parser ──────────────────────────────────────────────────────────
def parse_feature_collection(
    response_bytes: bytes,
) -> tuple[int, int, list[etree._Element]]:
    """Parse a WFS 2.0 FeatureCollection response.

    Returns:
        (number_matched, number_returned, list_of_member_elements)

    Raises:
        ValueError if the server returns a service exception.
    """
    # CRITICAL: use fromstring() for bytes, NOT parse() which needs a file-like object
    root = etree.fromstring(response_bytes)

    # Check for OWS service exception before proceeding
    exc_ns = "http://www.opengis.net/ows/1.1"
    if root.tag == f"{{{exc_ns}}}ExceptionReport":
        codes = root.findall(f".//{{{exc_ns}}}Exception[@exceptionCode]")
        code = codes[0].get("exceptionCode", "Unknown") if codes else "Unknown"
        text_elems = root.findall(f".//{{{exc_ns}}}ExceptionText")
        msg = text_elems[0].text if text_elems else "(no detail)"
        raise ValueError(f"WFS ServiceException [{code}]: {msg}")

    # numberMatched: total matching features across all pages
    number_matched_raw = root.get("numberMatched", "0")
    # WFS 2.0 allows "unknown" for streaming servers — treat as -1
    number_matched = -1 if number_matched_raw == "unknown" else int(number_matched_raw)

    # numberReturned: features in this response (may be less than count)
    number_returned = int(root.get("numberReturned", "0"))

    # wfs:member wraps each feature in WFS 2.0 (wfs:featureMember in 1.1.0)
    members = root.findall(".//wfs:member", namespaces=NSMAP)

    return number_matched, number_returned, members


# ── Paginated iterator ───────────────────────────────────────────────────────
def iter_features(
    endpoint: str,
    type_name: str,
    bbox: tuple[float, float, float, float],
    page_size: int = 500,
) -> Iterator[etree._Element]:
    """Yield every feature matching the BBOX filter, paging automatically.

    Uses count + startIndex pagination contract from WFS 2.0.
    """
    session = requests.Session()
    session.headers["Accept"] = "application/gml+xml; version=3.2"

    start_index = 0
    total_fetched = 0

    while True:
        payload = build_get_feature_xml(
            type_name=type_name,
            bbox=bbox,
            count=page_size,
            start_index=start_index,
        )
        resp = session.post(
            endpoint,
            data=payload,
            headers={"Content-Type": "application/xml"},
            timeout=30,
        )
        resp.raise_for_status()

        number_matched, number_returned, members = parse_feature_collection(resp.content)

        for member in members:
            yield member
            total_fetched += 1

        # Stop conditions: no more features returned, or all matched features fetched
        if number_returned == 0:
            break
        if number_matched >= 0 and total_fetched >= number_matched:
            break

        start_index += number_returned  # advance by actual returned count, not page_size


# ── CLI smoke-test ───────────────────────────────────────────────────────────
if __name__ == "__main__":
    WFS_ENDPOINT = "https://your-server/geoserver/wfs"
    TYPE_NAME = "your_namespace:your_layer"
    BBOX = (-180.0, -90.0, 180.0, 90.0)  # world extent in EPSG:4326

    count = 0
    for feature in iter_features(WFS_ENDPOINT, TYPE_NAME, BBOX, page_size=200):
        count += 1

    print(f"Total features fetched: {count}")

Step-by-Step Walkthrough

Namespace map declaration

NSMAP is declared once at module level and passed to every xpath() and findall() call. The three namespaces that changed between versions are wfs (adds /2.0 suffix), fes (entirely new, replacing ogc), and gml (adds /3.2 path segment). Missing any one of them causes XPath expressions to return empty lists rather than raising an exception — the most common source of silent “no data” bugs.

Building the request: build_get_feature_xml

The count attribute on wfs:GetFeature replaces maxFeatures. Setting VERSION=2.0.0 explicitly on the root element is required; omitting it allows the server to respond with its default schema, which may be 1.1.0. The fes:BBOX element now requires an explicit srsName on gml:Envelope — unlike WFS 1.1.0 where the SRS was inherited from the query context. The SRS and Coordinate Reference System Handling guide explains why URN-format SRS identifiers (urn:ogc:def:crs:EPSG::4326) are preferable to EPSG shorthand in OGC 2.x requests.

Response parsing: parse_feature_collection

etree.fromstring(response_bytes) is the correct call for bytes coming directly from requests. The parse() function requires a file-like object or a path string — passing response.content (bytes) to parse() raises a TypeError in lxml. Checking for an OWS exception report before reading the feature collection avoids a secondary AttributeError when numberMatched is absent on an exception payload.

numberMatched can return the string "unknown" from streaming implementations. The parser converts that to -1 so downstream pagination logic can detect it and fall back to stopping on empty numberReturned.

Pagination: iter_features

startIndex is zero-based. The first page uses startIndex=0; subsequent pages advance by number_returned, not by page_size. Using page_size instead of the actual returned count causes gaps when a server returns fewer features than requested (for example when the result set is not evenly divisible by page_size). Reading numberMatched from the first response allows computing total pages for a progress indicator without issuing a separate GetPropertyValue count query.


Verification

Run this against a public WFS 2.0 endpoint to confirm the client works end-to-end:

python wfs2_client.py

Expected output (feature count will vary by layer):

Total features fetched: 1247

To confirm namespace registration is correct, you can also run a quick parse check against a known response fixture:

from lxml import etree

# Minimal WFS 2.0 FeatureCollection fixture
xml = b"""<?xml version="1.0"?>
<wfs:FeatureCollection
  xmlns:wfs="http://www.opengis.net/wfs/2.0"
  xmlns:gml="http://www.opengis.net/gml/3.2"
  numberMatched="1" numberReturned="1"
  timeStamp="2026-06-23T00:00:00Z">
  <wfs:member><gml:Feature gml:id="f.1"/></wfs:member>
</wfs:FeatureCollection>"""

NSMAP = {
    "wfs": "http://www.opengis.net/wfs/2.0",
    "gml": "http://www.opengis.net/gml/3.2",
}
root = etree.fromstring(xml)
members = root.findall(".//wfs:member", namespaces=NSMAP)
assert len(members) == 1, f"Expected 1 member, got {len(members)}"
print("Namespace map verified:", len(members), "member found")

Expected output:

Namespace map verified: 1 member found

Gotchas & Edge Cases

1. The fes:BBOX operator requires an explicit srsName on gml:Envelope. WFS 1.1.0 allowed the SRS to be implied from the query context. WFS 2.0 treats a missing srsName as ambiguous and strict implementations return MissingParameterValue. Always supply it as a URN — urn:ogc:def:crs:EPSG::4326 not EPSG:4326 — because the short form is deprecated in WFS 2.0.

2. wfs:featureMember (singular) does not exist in WFS 2.0 responses. WFS 1.1.0 wraps each feature in wfs:featureMember. WFS 2.0 uses wfs:member (no “feature” prefix). An XPath query for .//wfs:featureMember against a 2.0 response returns empty results without error. This is the second most common silent failure after namespace mismatch. For background on how WFS Transactional Operations use the same wfs:member container in transaction responses, see the parent cluster.

3. Accept header misalignment causes format fallback, not an error. When the Accept header is absent or set to */*, many GeoServer and MapServer instances fall back to serving text/xml with GML 3.1.1 structure rather than GML 3.2.1. The HTTP status is 200, so the error is invisible at the transport layer. The parser then hits the wrong namespace and returns empty results. Always send Accept: application/gml+xml; version=3.2 explicitly, or use outputFormat=application/json if your parser handles GeoJSON.

4. wfs:native was removed — do not use it for vendor extensions. WFS 1.1.0 allowed a wfs:native element inside transactions for vendor-specific extensions. WFS 2.0 removes it entirely. If your 1.1.0 transaction payloads included wfs:native for GeoServer-specific hints (for example <wfs:native vendorId="GEOSERVER" safeToIgnore="true">FORCE_DECLARED</wfs:native>), those elements will cause an InvalidParameterValue error on a strict WFS 2.0 server. Replace them with vendor extension points or GeoServer REST API calls. The Automating GeoServer with the Python REST API guide covers programmatic server-side configuration as a replacement.


Back to WFS Transactional Operations Deep Dive

Related