OGC API Features and the REST Transition

OGC API - Features Part 1: Core re-expresses the decade-old Web Feature Service capability as a resource-oriented REST API built on JSON, GeoJSON, and OpenAPI 3.0. For backend developers who have wrestled with WFS GetFeature requests, XML outputFormat negotiation, and namespace-heavy responses, the shift is significant: feature access becomes a matter of walking predictable URL paths, reading GeoJSON, and following hypermedia links rather than assembling verbose query strings against a single endpoint. This page maps the resource model and its canonical paths, explains conformance classes and the standard query parameters, contrasts the design against WFS, and provides a complete annotated Python client that discovers a service and pages through a collection end to end.

Prerequisites & Architecture Context

Before building against OGC API - Features, engineering teams should be comfortable with:

  • REST resource modelling: nouns as URL paths, HTTP verbs for actions (GET for reads throughout Part 1: Core), and status codes as the primary error channel
  • Content negotiation via the Accept header and the convenience f query parameter (f=json, f=html) that most implementations honour
  • GeoJSON structure (RFC 7946): Feature, FeatureCollection, geometry, and properties, plus the awareness that OGC API extends a FeatureCollection with a links array
  • Hypermedia thinking: treating server-provided links as the source of truth for navigation instead of hard-coding URL templates client-side
  • Python packages: requests (HTTP with connection pooling), and optionally geojson or shapely for geometry handling

OGC API - Features is one member of the wider protocol family surveyed in OGC Standards Architecture & Service Fundamentals. It occupies the same architectural slot as the older Web Feature Service — vector feature retrieval and, through later extension parts, editing — so the WFS Transactional Operations Deep Dive is the natural comparison point throughout this guide. Where the requirement is rendered map imagery rather than raw geometry, the correct tool is a Web Map Service instead, as detailed in Understanding OGC Web Map Service Specifications. Understanding which capability each protocol owns prevents teams from bolting feature queries onto a rendering service or vice versa.

OGC API - Features Resource Path Tree A tree diagram rooted at the landing page slash. The landing page links to the conformance declaration, the api OpenAPI definition, and the collections list. The collections node descends to a single collection by id, which descends to its items path, which descends to an individual feature by featureId. Each node is labelled with its canonical path and default media type. Landing page GET / Conformance GET /conformance OpenAPI definition GET /api Collections list GET /collections Single collection GET /collections/{id} Feature collection (items) GET /collections/{id}/items Single feature GET /collections/{id}/items/{featureId} application/json OpenAPI 3.0 JSON default: application/geo+json

Specification Deep-Dive: The Resource Model

OGC API - Features Part 1: Core defines a small, fixed set of resources reachable by predictable paths. Unlike WFS, where a single endpoint multiplexes every operation through the REQUEST parameter, each resource here has its own URL and is retrieved with a plain GET. A conforming server exposes the following canonical paths.

Path Resource Default media type Purpose
/ Landing page application/json Entry point; a links array pointing to every other resource
/conformance Conformance declaration application/json The conformsTo array of conformance-class URIs
/api API definition OpenAPI 3.0 JSON Machine-readable description of every path and parameter
/collections Collections list application/json Metadata for all published feature collections
/collections/{id} Single collection application/json One collection’s id, extent, CRS list, and item link
/collections/{id}/items Feature collection application/geo+json A paged GeoJSON FeatureCollection of features
/collections/{id}/items/{featureId} Single feature application/geo+json One GeoJSON Feature by its stable identifier

The Landing Page and Hypermedia Navigation

The landing page at / is deliberately thin: it carries service title and description plus a links array. Each link object has href, rel, and usually type fields. A client discovers everything else by matching rel values — conformance, data (the collections list), and service-desc (the OpenAPI document) — rather than assuming path strings. This is the hypermedia principle that separates a REST API from a fixed URL template: the server can relocate resources and well-behaved clients still follow.

Conformance Classes

The /conformance resource returns a conformsTo array of URIs, each naming a conformance class the server implements. The mandatory baseline is the Core class, http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core. Optional classes advertise additional capability: oas30 declares an OpenAPI 3.0 definition, geojson and html declare supported encodings, and the Part 2 CRS class declares support for CRS negotiation beyond the default CRS84. A client should read this array before assuming any non-Core behaviour is available.

GeoJSON as the Default Encoding

Feature responses default to application/geo+json. The body is a GeoJSON FeatureCollection, but OGC API extends the RFC 7946 shape with two members: a links array (carrying paging links) and, commonly, numberMatched and numberReturned counts. Coordinates in the default CRS84 are always longitude, latitude order — a welcome contrast to the axis-order ambiguity that plagues WFS and WMS geographic requests, covered in the SRS and Coordinate Reference System Handling guide.

Standard Query Parameters

The /collections/{id}/items path accepts a small set of standardised query parameters for filtering and paging.

Parameter Example Meaning
bbox bbox=-0.2,51.4,0.0,51.6 Spatial filter; minLon,minLat,maxLon,maxLat in CRS84
datetime datetime=2024-01-01T00:00:00Z/.. Temporal filter; instant or open/closed interval
limit limit=100 Maximum features in this page; server caps the value
<property> status=active Equality filter on a feature property (Core level)
f f=json Convenience format selector, alternative to Accept

The datetime parameter accepts a single RFC 3339 instant, a closed interval start/end, or a half-open interval using .. as the unbounded side (for example 2024-01-01T00:00:00Z/.. means “from that instant onward”). The bbox parameter takes four numbers in CRS84 unless a bbox-crs extension is negotiated. Property filters at the Core level are simple equality tests on top-level properties; richer predicates require the separate OGC API - Features Part 3 (Filtering) with CQL2, which is out of scope for Core.

Contrast With WFS

The capability is the same — retrieve vector features from a server — but almost every surface detail changed. Teams migrating an existing WFS deployment should internalise the following mapping.

Concern WFS 2.0 OGC API - Features
Retrieve features ?SERVICE=WFS&REQUEST=GetFeature&TYPENAMES=... GET /collections/{id}/items
Endpoint model One URL, dispatch on REQUEST Many URLs, one per resource
Default format GML (XML) GeoJSON (application/geo+json)
Format negotiation outputFormat=application/json Accept header or f query parameter
Service metadata GetCapabilities XML Landing page + /collections JSON
Machine contract Capabilities XML, hand-parsed OpenAPI 3.0 at /api
Paging startIndex + count (client computes offsets) limit + server-issued rel="next" links
Errors ServiceExceptionReport XML, HTTP 200 HTTP status codes + JSON problem details
Feature filter XML <Filter> / OGC Filter Encoding Query params (bbox, datetime, property)

The single most consequential change for client code is paging. A WFS client maintained its own startIndex, incrementing it by count and re-issuing GetFeature until fewer than count features returned. An OGC API - Features client does none of that arithmetic: it reads the links array, finds the object with rel="next", and requests that opaque URL. The server owns the cursor. This eliminates a whole class of off-by-one and skipped-feature bugs that arise when offset arithmetic drifts out of sync with a mutating dataset.

Python Implementation

The client below discovers a service from its landing page, enumerates collections, then walks every page of a chosen collection’s items by following rel="next" links. It uses only requests and the standard library, and it handles content negotiation, HTTP errors, and paging termination explicitly.

"""
ogcapi_features_client.py — a discovery-and-paging client for
OGC API - Features Part 1: Core.

Dependencies (pip install):
    requests>=2.31
"""
from __future__ import annotations

from typing import Iterator, Any
import requests

GEOJSON = "application/geo+json"
JSON = "application/json"


class OgcApiFeaturesError(RuntimeError):
    """Raised when the server returns an unexpected status or payload."""


class OgcApiFeaturesClient:
    def __init__(self, landing_url: str, *, timeout: float = 30.0) -> None:
        # A pooled Session reuses TCP connections across the many small
        # GETs that discovery and paging generate.
        self.landing_url = landing_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"User-Agent": "ogcapi-features-client/1.0"})

    # -- low-level GET with content negotiation and status handling --------

    def _get(self, url: str, *, accept: str, params: dict | None = None) -> dict[str, Any]:
        resp = self.session.get(
            url, headers={"Accept": accept}, params=params, timeout=self.timeout
        )
        # Unlike WFS, errors arrive as real HTTP status codes, not as an
        # exception document wrapped in a 200 response.
        if resp.status_code == 404:
            raise OgcApiFeaturesError(f"Resource not found: {url}")
        if resp.status_code >= 400:
            raise OgcApiFeaturesError(
                f"HTTP {resp.status_code} for {url}: {resp.text[:500]}"
            )
        ctype = resp.headers.get("Content-Type", "")
        if "json" not in ctype:
            raise OgcApiFeaturesError(f"Expected JSON, got {ctype!r} for {url}")
        return resp.json()

    # -- discovery: landing page -> typed links ---------------------------

    def _link_href(self, links: list[dict], rel: str) -> str | None:
        for link in links:
            if link.get("rel") == rel:
                return link["href"]
        return None

    def landing_page(self) -> dict[str, Any]:
        return self._get(self.landing_url, accept=JSON)

    def conformance(self) -> list[str]:
        doc = self.landing_page()
        href = self._link_href(doc.get("links", []), "conformance")
        # Fall back to the canonical path if no typed link is advertised.
        url = href or f"{self.landing_url}/conformance"
        return self._get(url, accept=JSON).get("conformsTo", [])

    def collections(self) -> list[dict[str, Any]]:
        doc = self.landing_page()
        href = self._link_href(doc.get("links", []), "data")
        url = href or f"{self.landing_url}/collections"
        return self._get(url, accept=JSON).get("collections", [])

    # -- items: page through a collection following rel=next --------------

    def iter_items(
        self,
        collection_id: str,
        *,
        bbox: tuple[float, float, float, float] | None = None,
        datetime: str | None = None,
        limit: int = 100,
        extra: dict[str, str] | None = None,
    ) -> Iterator[dict[str, Any]]:
        """Yield every feature in a collection, transparently paging."""
        params: dict[str, Any] = {"limit": limit}
        if bbox is not None:
            params["bbox"] = ",".join(str(v) for v in bbox)
        if datetime is not None:
            params["datetime"] = datetime
        if extra:
            params.update(extra)

        url: str | None = f"{self.landing_url}/collections/{collection_id}/items"
        first_request = True
        while url is not None:
            # Only the first request carries our params; subsequent
            # rel=next URLs already encode the server's cursor and params.
            page = self._get(
                url, accept=GEOJSON, params=params if first_request else None
            )
            first_request = False
            for feature in page.get("features", []):
                yield feature
            url = self._link_href(page.get("links", []), "next")

    def get_feature(self, collection_id: str, feature_id: str) -> dict[str, Any]:
        url = f"{self.landing_url}/collections/{collection_id}/items/{feature_id}"
        return self._get(url, accept=GEOJSON)


if __name__ == "__main__":
    client = OgcApiFeaturesClient("https://example.org/ogcapi")

    classes = client.conformance()
    core = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"
    if core not in classes:
        raise SystemExit("Server does not declare the Core conformance class")

    for coll in client.collections():
        print(coll["id"], "-", coll.get("title", ""))

    count = 0
    for feat in client.iter_items(
        "buildings",
        bbox=(-0.2, 51.4, 0.0, 51.6),
        datetime="2024-01-01T00:00:00Z/..",
        limit=500,
    ):
        count += 1
    print(f"Retrieved {count} features across all pages")

Annotated Walkthrough

Pooled session (__init__) — Discovery plus paging can fire dozens of small requests against one host. A single requests.Session reuses the underlying TCP and TLS connection, which removes handshake latency from every request after the first. The landing_url is normalised once by stripping any trailing slash so path concatenation stays predictable.

Status-first error handling (_get) — The method inspects resp.status_code before anything else. A 404 maps to a “resource not found” error and any other 4xx/5xx raises with a truncated body for diagnostics. This is the deliberate inversion of WFS habits, where a client had to parse an XML ServiceExceptionReport out of what was nominally an HTTP 200 success. It also guards the Content-Type so an HTML error page from a misconfigured reverse proxy fails loudly instead of blowing up in resp.json().

Typed-link resolution (_link_href) — Rather than hard-coding /conformance or /collections, the client scans the landing page links array for the standard rel values conformance and data. The canonical path is used only as a fallback when a server omits the typed link. This keeps the client resilient to services that host resources under a non-obvious prefix.

Transparent paging (iter_items) — This is the heart of the REST transition. The first request sends the user’s limit, bbox, datetime, and any property filters. After that, the loop follows the URL carried in the rel="next" link verbatim and passes params=None, because the server has already baked its cursor and the active filters into that URL. Appending our own params again would risk conflicting with the server’s encoded state. The loop ends naturally when a page carries no next link, which is the standard’s signal that the result set is exhausted. The generator yields one feature at a time, so a caller can process millions of features without holding them all in memory.

Single-feature access (get_feature) — Retrieving one feature by its stable featureId is a plain GET on the item path. Because that URL is deterministic and the response is immutable for a given data version, it is an ideal target for HTTP caching, discussed below.

Error Handling & Edge Cases

HTTP status codes replace ServiceException — A malformed bbox yields HTTP 400, an unknown collection or featureId yields 404, and a server fault yields 500. Bodies are usually JSON problem details ({"code": ..., "description": ...}) rather than XML. Client code must branch on the status code first; do not assume a 200 implies success by parsing the body for an exception element as a WFS client would.

Content negotiation surprises — Some servers return HTML by default to browsers via Accept: text/html. A programmatic client that forgets to send Accept: application/geo+json may receive an HTML page and fail JSON parsing. The client above always sends an explicit Accept; sending f=json as a query parameter is a robust belt-and-braces fallback for servers that misprioritise the header.

A rel="next" link that never terminates — A buggy server can emit a next link on the final page, producing an infinite loop. Add a defensive page counter or a visited-URL set in production, and abort if the same next URL repeats or a sane page ceiling is exceeded.

Server-capped limit — Requesting limit=100000 does not guarantee 100000 features. Servers advertise and enforce a maximum (commonly 1000 or 10000) and silently clamp the value, returning numberReturned fewer than requested and a rel="next" link for the remainder. Never treat a short page as the end of data — only the absence of a next link marks the end.

bbox axis order and CRS — The default CRS84 is longitude, latitude order, so bbox=minLon,minLat,maxLon,maxLat. This is the opposite of a strict WMS 1.3.0 EPSG:4326 request. If your data pipeline stores latitude first, transpose before building the bbox string, as covered in Handling Spatial Reference Mismatches in OGC Requests.

Non-string featureId — Feature identifiers may be integers, UUIDs, or slugs. Always URL-encode the value when building /items/{featureId} — an id containing a slash or space otherwise breaks path routing.

Testing & Compliance Verification

"""
test_ogcapi_client.py — unit tests using a mocked transport.
Dependencies: pytest, responses>=0.25
"""
import pytest
import responses
from ogcapi_features_client import OgcApiFeaturesClient, OgcApiFeaturesError

BASE = "https://example.org/ogcapi"


@responses.activate
def test_iter_items_follows_rel_next():
    # Page 1 carries a rel=next link; page 2 does not, ending the walk.
    responses.get(
        f"{BASE}/collections/roads/items",
        json={
            "type": "FeatureCollection",
            "features": [{"type": "Feature", "id": "1", "properties": {}}],
            "links": [{"rel": "next", "href": f"{BASE}/collections/roads/items?cursor=abc"}],
        },
    )
    responses.get(
        f"{BASE}/collections/roads/items?cursor=abc",
        json={
            "type": "FeatureCollection",
            "features": [{"type": "Feature", "id": "2", "properties": {}}],
            "links": [],
        },
        match_querystring=True,
    )
    client = OgcApiFeaturesClient(BASE)
    ids = [f["id"] for f in client.iter_items("roads", limit=1)]
    assert ids == ["1", "2"]


@responses.activate
def test_404_raises():
    responses.get(f"{BASE}/collections/ghost/items", status=404)
    client = OgcApiFeaturesClient(BASE)
    with pytest.raises(OgcApiFeaturesError, match="not found"):
        list(client.iter_items("ghost"))


@responses.activate
def test_conformance_lists_core():
    responses.get(
        BASE,
        json={"links": [{"rel": "conformance", "href": f"{BASE}/conformance"}]},
    )
    responses.get(
        f"{BASE}/conformance",
        json={"conformsTo": [
            "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"
        ]},
    )
    client = OgcApiFeaturesClient(BASE)
    assert any("conf/core" in c for c in client.conformance())

Beyond unit tests, validate the server against its own contract. Fetch the OpenAPI 3.0 document at /api and check request and response shapes against it with a schema validator such as openapi-core. Confirm the /conformance conformsTo array lists the Core class URI before relying on any behaviour. For formal certification, run the OGC CITE (Compliance + Interoperability Testing + Evaluation) test suite for OGC API - Features against the endpoint; its Executable Test Suite exercises the landing page, conformance declaration, collections metadata, paging behaviour, and error responses, and is the authoritative signal that a deployment is standards-compliant.

Performance & Scaling Notes

Cursor paging over offset paging — Where the server offers it, cursor-based rel="next" links (opaque tokens) outperform offset paging on large collections. An offset query (OFFSET 900000 LIMIT 1000 under the hood) forces the database to scan and discard the skipped rows, so deep pages get progressively slower. A keyset cursor anchored on an indexed sort key stays constant-time regardless of depth. Because the client follows the server’s next URL blindly, the paging strategy is a server implementation detail the client never has to change.

limit caps and page sizing — Tune limit to balance round-trip count against per-response size. Very small pages multiply HTTP overhead; very large pages inflate latency and memory. A limit in the 500–1000 range is a common sweet spot, but always respect the server’s advertised maximum rather than assuming your requested value is honoured.

CDN caching of GET item pages — Because every resource is a GET with a deterministic URL, OGC API - Features is far more CDN-friendly than WFS POST queries. Individual feature pages at /collections/{id}/items/{featureId} change only when that feature is edited, so they can carry a long Cache-Control: public, max-age=... and an ETag. Put a CDN in front and item reads are served from the edge. Collection item listings with query parameters are cacheable too, keyed on the canonicalised query string, though shorter TTLs suit frequently-updated data.

Connection reuse and concurrency — Reuse one requests.Session (as the client does) so pooled connections amortise TLS setup across the many small requests that discovery and paging generate. When fetching many independent collections, a bounded thread pool over separate sessions parallelises the work without overwhelming the server; respect any advertised rate limits and back off on HTTP 429.

Gotchas / Frequently Asked Questions

Is OGC API - Features a replacement for WFS?

It is the modern successor rather than a drop-in replacement. OGC API - Features re-expresses the same feature-access capability as WFS using REST, JSON, and OpenAPI instead of XML and key-value or POST requests. The underlying data model of collections and features is deliberately compatible, but the paths, default formats, and paging mechanism all differ. Migrating an existing WFS deployment therefore needs a translation layer, not a transparent proxy — the mapping is set out in the WFS Transactional Operations Deep Dive.

How do I page through more features than the limit returns?

Do not increment an offset yourself. Read the links array on each items response and follow the object whose rel is next. The server encodes its own cursor or offset into that URL, so you request it verbatim without re-appending your own limit or filters. Stop when a response contains no rel="next" link — that absence is the standard’s signal that the result set is exhausted. Treating a short page as the end is a bug, because a server may clamp your limit and still offer a next link.

What Content-Type does OGC API - Features return by default?

For feature and item responses the default is application/geo+json — a GeoJSON FeatureCollection extended with a links array and usually numberMatched/numberReturned counts. The landing page, /conformance, and /collections metadata default to application/json. Many servers also offer HTML representations for browser use, selected through the f query parameter (f=html) or an Accept header. Always send an explicit Accept: application/geo+json from a programmatic client so a browser-first server does not hand you HTML.

How does error reporting differ from WFS ServiceException?

OGC API - Features uses standard HTTP status codes: 400 for a bad parameter, 404 for an unknown collection or feature, 500 for server faults. Response bodies are typically JSON problem details rather than the XML ServiceExceptionReport that WFS returns wrapped inside an HTTP 200. Client code should branch on the status code first and only then inspect the body, which is both simpler and more robust than parsing an exception document out of a nominally successful response.

How do I confirm a server really conforms to the standard?

Fetch /conformance and check that the conformsTo array lists the required class URIs, at minimum http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core. Validate your request and response shapes against the OpenAPI 3.0 document published at /api using a schema validator. For formal certification, run the OGC CITE test suite for OGC API - Features against the endpoint; it exercises discovery, paging, and error handling and is the authoritative compliance signal.


Back to OGC Standards Architecture & Service Fundamentals

Related: