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.
Landing page, conformance, collections, items, paging
1. Start with the landing page and the links
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.
5. Page with links, not with arithmetic
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 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.
Why link-driven navigation matters more than it looks
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.
Bounding boxes, datetimes and documented limits
bboxisminx,miny,maxx,maxyin the collection's CRS, defaulting to CRS84 β longitude first.- A six-value
bboxincludes elevation; most services can reject it. datetimeaccepts an instant, an interval, or an open-ended interval with...limitneeds a documented maximum, and the service should clamp rather than error./items/{featureId}returns a bare Feature, not a FeatureCollection.- Content negotiation via
Acceptand anf=jsonparameter 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.
Internal links
- Serving spatial data explained: files, features and tiles β where the feature shape fits
- How to implement OGC API Features in Python β the full implementation
- How to paginate a large feature API in Python β the paging links
- How to add bounding box and attribute filters to a spatial API β the filters
- Spatial web services explained β WFS, WMS and the older standards
- How to build a GeoJSON API with FastAPI β a simpler starting point
- Fixing coordinates in the wrong order in an API response β the CRS84 trap
- How to test a spatial API with pytest and httpx β the conformance test
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.
Why should a client follow links instead of building URLs?
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.