OGC API Features Explained: The Standard Behind Modern Spatial APIs

On this page

Problem statement

Every organisation invents the same spatial API. The endpoints are /features, /layers/{id}/query, /api/v2/geo; the bounding-box parameter is bbox, extent, envelope or four separate numbers; paging is offset/limit, page/size, or a cursor; and the response is GeoJSON with a differently named metadata block.

Each version is reasonable. Collectively they mean every client is bespoke, no tool can consume a new service without a wrapper, and the questions "how do I get the next page?" and "which coordinate system is this?" have to be answered again per service.

OGC API Features is the standard that settles those questions. It is a small REST specification β€” five resource types, JSON everywhere, GeoJSON as the default output β€” and adopting it costs almost nothing on a service you were going to build anyway.

Quick answer

Five resources, and the URL structure is the whole specification:

GET /                          the landing page: links to everything else
GET /conformance               which parts of the standard this service implements
GET /collections               the datasets available
GET /collections/{id}          one dataset: extent, CRS, item count
GET /collections/{id}/items    the features, filtered and paged
GET /collections/{id}/items/{featureId}    one feature

The query parameters on /items are standardised too:

?bbox=-74.5,40.4,-73.5,41.0        minx,miny,maxx,maxy
?datetime=2025-01-01/2025-12-31    an instant or an interval
?limit=50                          page size, with a documented maximum
?<property>=<value>                simple attribute equality

A client that understands those six URLs and four parameters can consume any conformant service without knowing anything about it in advance.

The five resources of an OGC API Features service. five resources, and the URLs are the spec1/links to everything else2/conformancewhat this service implements3/collectionsthe datasets available4/collections/{id}/itemsfeatures, filtered and paged5/collections/{id}/items/{id}one Feature, not a collectionA client that understands these six URLs can consume any conformant service spatialworkflow.io
QGIS and GDAL already speak this, which is most of the argument for adopting it.

Landing page, conformance, collections, items, paging

Every response carries a links array, and clients navigate by following them rather than by constructing URLs. That is what makes the standard self-describing:

{
  "title": "UK local authority districts",
  "links": [
    {"href": "/",             "rel": "self",        "type": "application/json"},
    {"href": "/conformance",  "rel": "conformance", "type": "application/json"},
    {"href": "/collections",  "rel": "data",        "type": "application/json"}
  ]
}

The rel values are the contract. A client looking for the data follows rel="data"; a client paging follows rel="next".

2. Declare what you conform to

{
  "conformsTo": [
    "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
    "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
    "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30"
  ]
}

This is honesty as an endpoint. A service that implements the core and GeoJSON conformance classes and nothing else says so, and clients adapt rather than guessing.

3. Describe each collection properly

{
  "id": "districts",
  "title": "Local authority districts",
  "extent": {
    "spatial": {"bbox": [[-180, -90, 180, 90]], "crs": "…/CRS84"},
    "temporal": {"interval": [["2025-01-01T00:00:00Z", null]]}
  },
  "itemType": "feature",
  "crs": ["…/CRS84", "http://www.opengis.net/def/crs/EPSG/0/27700"],
  "links": [{"href": "/collections/districts/items", "rel": "items",
             "type": "application/geo+json"}]
}

The extent block is the most useful part for a client: it can zoom to the data before requesting any of it.

4. Implement /items with the standard parameters

bbox, datetime, limit and simple property equality. The reason bbox matters is size: on the UK's 361 local authority districts, measured in Example 4, the whole collection is 20.69 MB of GeoJSON and a box around Birmingham returns 8 districts in 149 kB.

limit needs a documented maximum, because page size is what sets the server's throughput. Under the same ten-client load, pages of 10 districts (162 kB) were served at about 109 requests a second, pages of 50 (1.9 MB) at 14, and pages of 200 (8.5 MB) at 4.

The response carries the next page's URL:

{
  "type": "FeatureCollection",
  "features": [...],
  "numberMatched": 361,
  "numberReturned": 50,
  "links": [
    {"href": "/collections/districts/items?limit=50&offset=50", "rel": "next"},
    {"href": "/collections/districts/items?limit=50", "rel": "self"}
  ]
}

numberMatched and numberReturned tell the client how much is left. The next link means the client never has to know whether the service pages by offset or by cursor β€” which is what lets a service change its paging strategy without breaking anybody.

6. Be explicit about the coordinate system

The default is CRS84 β€” WGS 84 with longitude first, which is the GeoJSON convention and the opposite of EPSG:4326's official axis order. The standard names it explicitly precisely because that ambiguity causes so much damage.

A service supporting other coordinate systems advertises them in crs and accepts ?crs= and ?bbox-crs=.

A client following rel links rather than constructing URLs. follow links, do not build URLslanding pagerel = datacollectionsrel = itemsitemsrel = nextnext pageand so onThe service can change its URLs, its paging or its hosting and conformant clients keep working spatialworkflow.io
A client that constructs URLs is coupled to your URL structure.

A minimal conformant service and a generic client

Example 1 β€” a minimal conformant service

%%writefile service.py
from datetime import datetime, timezone
from fastapi import FastAPI, Query, Request, Response
import geopandas as gpd
import json

app = FastAPI()
COLLECTIONS = {"districts": gpd.read_file("districts.gpkg")}
CRS84 = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
MAX_LIMIT = 200


def link(href, rel, type_="application/json", title=None):
    entry = {"href": href, "rel": rel, "type": type_}
    if title:
        entry["title"] = title
    return entry


@app.get("/")
def landing(request: Request):
    base = str(request.base_url).rstrip("/")
    return {"title": "UK local authority districts",
            "description": "OGC API Features, core + GeoJSON",
            "links": [link(f"{base}/", "self"),
                      link(f"{base}/conformance", "conformance"),
                      link(f"{base}/collections", "data")]}


@app.get("/conformance")
def conformance():
    return {"conformsTo": [
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
    ]}


@app.get("/collections")
def collections(request: Request):
    base = str(request.base_url).rstrip("/")
    return {"collections": [describe(name, base) for name in COLLECTIONS],
            "links": [link(f"{base}/collections", "self")]}


def describe(name, base):
    gdf = COLLECTIONS[name]
    minx, miny, maxx, maxy = gdf.total_bounds
    return {
        "id": name,
        "title": name.replace("_", " ").title(),
        "extent": {"spatial": {"bbox": [[minx, miny, maxx, maxy]], "crs": CRS84}},
        "itemType": "feature",
        "crs": [CRS84],
        "links": [link(f"{base}/collections/{name}/items", "items",
                       "application/geo+json")],
    }


@app.get("/collections/{name}/items")
def items(request: Request, name: str, bbox: str | None = None,
          limit: int = Query(50, ge=1), offset: int = 0):
    limit = min(limit, MAX_LIMIT)                      # clamp, do not reject
    gdf = COLLECTIONS[name]
    if bbox:
        x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
        gdf = gdf.cx[x0:x1, y0:y1]

    matched = len(gdf)
    page = gdf.iloc[offset:offset + limit]
    body = json.loads(page.to_json())
    base = str(request.base_url).rstrip("/")
    query = f"limit={limit}" + (f"&bbox={bbox}" if bbox else "")

    body["numberMatched"] = matched
    body["numberReturned"] = len(page)
    body["timeStamp"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    body["links"] = [link(f"{base}/collections/{name}/items?{query}&offset={offset}",
                          "self", "application/geo+json")]
    if offset + limit < matched:
        body["links"].append(
            link(f"{base}/collections/{name}/items?{query}&offset={offset + limit}",
                 "next", "application/geo+json"))

    return Response(json.dumps(body), media_type="application/geo+json")

Example 2 β€” a client that works against any conformant service

import httpx


def crawl(base_url, collection=None, bbox=None, max_pages=10):
    """Follow links rather than constructing URLs β€” that is the point."""
    with httpx.Client(timeout=30) as client:
        landing = client.get(base_url).json()
        data_link = next(l for l in landing["links"] if l["rel"] == "data")

        collections = client.get(data_link["href"]).json()["collections"]
        chosen = next((c for c in collections if c["id"] == collection),
                      collections[0])
        print(f"collection {chosen['id']}: "
              f"extent {chosen['extent']['spatial']['bbox'][0]}")

        items_link = next(l for l in chosen["links"] if l["rel"] == "items")
        url = items_link["href"] + (f"?bbox={bbox}" if bbox else "")

        total = 0
        for page in range(max_pages):
            body = client.get(url).json()
            total += body.get("numberReturned", len(body["features"]))
            print(f"  page {page + 1}: {body.get('numberReturned')} of "
                  f"{body.get('numberMatched')}")
            next_link = next((l for l in body.get("links", []) if l["rel"] == "next"),
                             None)
            if not next_link:
                break
            url = next_link["href"]
        return total

Example 3 β€” testing conformance

REQUIRED_ENDPOINTS = ["/", "/conformance", "/collections"]
CORE_CLASS = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"


def check_conformance(base_url):
    import httpx

    problems = []
    with httpx.Client(base_url=base_url, timeout=30) as client:
        for path in REQUIRED_ENDPOINTS:
            response = client.get(path)
            if response.status_code != 200:
                problems.append(f"{path} returned {response.status_code}")

        conformance = client.get("/conformance").json().get("conformsTo", [])
        if CORE_CLASS not in conformance:
            problems.append("does not declare the core conformance class")

        collections = client.get("/collections").json()["collections"]
        for collection in collections:
            for field in ("id", "links", "extent"):
                if field not in collection:
                    problems.append(f"collection {collection.get('id')} lacks {field}")

            items = client.get(f"/collections/{collection['id']}/items?limit=1")
            body = items.json()
            if body.get("type") != "FeatureCollection":
                problems.append(f"{collection['id']}/items is not a FeatureCollection")
            if "links" not in body:
                problems.append(f"{collection['id']}/items lacks links")
            for field in ("numberMatched", "numberReturned"):      # optional in the standard
                if field not in body:
                    print(f"  note: {collection['id']}/items has no {field}")

    print(f"{len(problems)} conformance problem(s)")
    for problem in problems:
        print("  !", problem)
    return not problems

Example 4 β€” run it, measure it, and point the client at a real service

The service above, started with uvicorn in a process of its own, holding the UK's 361 local authority districts (ONS, December 2024, generalised) β€” the data is fetched in the setup block. Then the Example 2 client and the Example 3 checker run against it and against a real national service: PDOK's OGC API for the Netherlands' registered crop parcels.

import os
import socket
import subprocess
import sys
import time

import geopandas as gpd
import httpx
import requests

if not os.path.exists("districts.gpkg"):
    reply = requests.get("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
                         "Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
                         params={"where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 4326, "f": "geojson"},
                         timeout=300)
    reply.raise_for_status()
    gpd.read_file(reply.text).to_file("districts.gpkg")
print(len(gpd.read_file("districts.gpkg")), "districts")
361 districts
with socket.socket() as probe:
    probe.bind(("127.0.0.1", 0))
    port = probe.getsockname()[1]
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "service:app", "--port", str(port),
                           "--log-level", "error"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
BASE = f"http://127.0.0.1:{port}"
for _ in range(60):
    try:
        httpx.get(BASE + "/conformance", timeout=1)
        break
    except httpx.TransportError:
        time.sleep(0.5)

whole = len(gpd.read_file("districts.gpkg").to_json().encode())
birmingham = httpx.get(BASE + "/collections/districts/items?bbox=-2.03,52.38,-1.73,52.61&limit=200")
print(f"whole collection as GeoJSON: {whole / 1e6:.2f} MB")
print(f"bbox around Birmingham: {birmingham.json()['numberReturned']} districts, {len(birmingham.content) / 1e3:.0f} kB")
print("limit=500 ->", httpx.get(BASE + "/collections/districts/items?limit=500").json()["numberReturned"], "features (clamped)")
whole collection as GeoJSON: 20.69 MB
bbox around Birmingham: 8 districts, 149 kB
limit=500 -> 200 features (clamped)
from concurrent.futures import ThreadPoolExecutor

def hammer(url, seconds=5, clients=10):
    stop = time.perf_counter() + seconds
    def worker(_):
        done = 0
        with httpx.Client(timeout=60) as client:
            while time.perf_counter() < stop:
                client.get(url)
                done += 1
        return done
    with ThreadPoolExecutor(clients) as pool:
        return sum(pool.map(worker, range(clients))) / seconds

for limit in (10, 50, 200):
    url = f"{BASE}/collections/districts/items?limit={limit}"
    size = len(httpx.get(url).content)
    print(f"limit={limit:<4} page {size / 1e3:7.0f} kB   {hammer(url):6.1f} requests/s  (load {os.getloadavg()[0]:.1f})")
limit=10   page     162 kB     87.2 requests/s  (load 10.0)
limit=50   page    1949 kB     12.4 requests/s  (load 9.4)
limit=200  page    8519 kB      4.0 requests/s  (load 8.4)
print("our service:")
print("  crawl read", crawl(BASE, "districts", max_pages=3), "features")
check_conformance(BASE)

PDOK = "https://api.pdok.nl/rvo/gewaspercelen/ogc/v1"
print("PDOK, BRP crop parcels:")
print("  crawl read", crawl(PDOK, max_pages=2), "features")
check_conformance(PDOK)
server.terminate()
server.wait(timeout=10)
our service:
collection districts: extent [-8.64999583330431, 49.8647926358421, 1.76370560966352, 60.8607658077002]
  page 1: 50 of 361
  page 2: 50 of 361
  page 3: 50 of 361
  crawl read 150 features
0 conformance problem(s)
PDOK, BRP crop parcels:
collection brpgewas: extent [-1.657292, 48.040502, 12.431727, 56.11059]
  page 1: 10 of None
  page 2: 10 of None
  crawl read 20 features
  note: brpgewas/items has no numberMatched
0 conformance problem(s)

The same client read both services without a line changed, which is the argument for the standard in one run. The differences are the ones the standard leaves open: PDOK's default page is 10 features, it pages with a cursor, and it does not report numberMatched β€” so the page count of a query is unknown until the last page. The checker reports that as a note rather than a failure, because the standard makes the field optional; an earlier version of this checker called it a conformance problem, which would have failed a national service for following the specification.

Why a standard pays for itself internally

Why a standard is worth adopting for an internal service

The usual objection is that nobody outside will consume it, so the standard is overhead. The overhead is small β€” the parameter names and the links array β€” and the benefit arrives immediately in the form of tools that already speak it: QGIS, GDAL's OAPIF driver, several JavaScript clients and every future service you build.

It also removes a category of design meetings. Nobody has to decide what to call the bounding-box parameter, how paging works or where the metadata goes.

A client that constructs URLs is coupled to the service's URL structure. A client that follows rel="next" is coupled only to the standard.

That means a service can move from offset paging to cursor paging, change its base path, or put pages behind a CDN with different URLs, and conformant clients keep working. It is the same idea as a hyperlink, applied to an API.

Why CRS84 is spelled out so carefully

GeoJSON coordinates are longitude-first. EPSG:4326 is officially latitude-first. The two conventions describe the same datum and produce coordinates that are silently transposed relative to one another.

The standard therefore names its default CRS as CRS84 β€” explicitly longitude-first β€” rather than saying "WGS 84" and leaving it to be inferred. Services supporting other systems advertise them and accept ?crs= and ?bbox-crs=, so the client never has to guess.

Why numberMatched is the most useful field in the response

It tells a client how much data its query selected before it has fetched any of it. That single number lets a client decide to narrow the filter rather than paging through four hundred pages, show a meaningful progress indicator, or refuse a query that would be unreasonable.

It is optional in the standard, and not only home-made APIs leave it out: the Dutch national BRP crop-parcel service at PDOK pages with a cursor and does not report it, which is conformant and means a client has to page to the end to learn the total.

Why the standard names CRS84 rather than saying WGS 84. the same datum, two axis ordersCRS84 β€” the defaultβœ“[longitude, latitude]βœ“matches GeoJSONβœ“matches every GIS fileβœ“named explicitly for this reasonEPSG:4326 β€” authority orderβœ•[latitude, longitude]βœ•what the registry definesβœ•what DuckDB ST_Transform defaults toβœ•transposed relative to the otherbbox is minx,miny,maxx,maxy β€” longitude first, in the collection’s CRS spatialworkflow.io
Both are correct in their own frame, which is exactly why the standard is explicit.

Bounding boxes, datetimes and documented limits

  • bbox is minx,miny,maxx,maxy in the collection's CRS, defaulting to CRS84 β€” longitude first.
  • A six-value bbox includes elevation; most services can reject it.
  • datetime accepts an instant, an interval, or an open-ended interval with ...
  • limit needs a documented maximum, and the service should clamp rather than error.
  • /items/{featureId} returns a bare Feature, not a FeatureCollection.
  • Content negotiation via Accept and an f=json parameter are both common.
  • pygeoapi implements the standard and is often faster to deploy than writing your own.
  • Conformance classes are a menu. Declaring only core and GeoJSON is respectable.

FAQ

What is OGC API Features?

A REST specification for serving vector features: a landing page, a conformance declaration, collections, items and individual features, with standardised bbox, datetime and limit parameters and GeoJSON responses.

How is it different from WFS?

WFS is XML and SOAP-flavoured; OGC API Features is JSON, REST and link-driven. They serve the same purpose and the newer one is far easier to implement and consume.

Do I have to implement all of it?

No. Conformance classes are a menu, and declaring core plus GeoJSON is a legitimate, useful service. The /conformance endpoint is where you say what you did.

What CRS does it use?

CRS84 by default β€” WGS 84 with longitude first, matching GeoJSON. That is the opposite of EPSG:4326's official axis order, which is exactly why the standard names it explicitly.

Because then the service can change its URL structure, its paging strategy or its hosting without breaking clients. rel="next" is the contract, not the query string.

Is there an implementation I can use?

pygeoapi implements the standard over several backends and is usually quicker to deploy than writing one, though a minimal conformant service is about a hundred lines.