How to Implement OGC API Features in Python
Problem statement
You need a feature service and you would rather not invent one. OGC API Features gives you the URL structure, the parameter names, the paging convention and the response shape โ and a conformant service is consumable by QGIS, GDAL and several JavaScript clients without a line of custom code.
Two routes:
- Write it. A conformant core service is about a hundred and fifty lines of FastAPI. You control the backend, the filters and the performance.
- Deploy pygeoapi. A configuration file over an existing backend. Faster to stand up, and less flexible when the data does not fit its providers.
The measurements that matter are the same either way. A page of 50 features from a real polygon dataset was 857 kB and the server sustained 32.5 requests per second; a page of 1,000 was 12.4 MB at 2.3 requests per second. Whichever route you take, the limits decide whether the service works.
Quick answer
The five endpoints, minimally:
from fastapi import FastAPI, Query, Request, Response
import geopandas as gpd, json, datetime
app = FastAPI()
COLLECTIONS = {"provinces": gpd.read_file("provinces.gpkg")}
CRS84 = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
MAX_LIMIT = 200
@app.get("/")
def landing(request: Request):
base = str(request.base_url).rstrip("/")
return {"title": "Features service", "links": [
{"href": f"{base}/", "rel": "self", "type": "application/json"},
{"href": f"{base}/conformance", "rel": "conformance", "type": "application/json"},
{"href": f"{base}/collections", "rel": "data", "type": "application/json"}]}
@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"]}
The /collections, /collections/{id} and /collections/{id}/items endpoints follow the same pattern, and /items is where all the real work is.
Step-by-step solution
1. Build the collection description from the data
Everything in a collection's metadata can be derived, which means it cannot go stale:
def describe(name, gdf, base):
minx, miny, maxx, maxy = gdf.total_bounds
return {
"id": name,
"title": name.replace("_", " ").title(),
"extent": {"spatial": {"bbox": [[float(minx), float(miny),
float(maxx), float(maxy)]],
"crs": CRS84}},
"itemType": "feature",
"crs": [CRS84],
"links": [{"href": f"{base}/collections/{name}/items",
"rel": "items", "type": "application/geo+json"}],
}
The extent is the field clients use most: it lets a map zoom to the data before requesting any features.
2. Implement /items with the standard parameters
bbox, datetime, limit, offset and simple property equality. The bounding box is the one that matters: measured, it took a response from 54.06 MB to 58.8 kB on a 4,596-feature dataset.
@app.get("/collections/{name}/items")
def items(request: Request, name: str,
bbox: str | None = None, datetime_: str | None = Query(None, alias="datetime"),
limit: int = Query(50, ge=1, le=MAX_LIMIT), offset: int = Query(0, ge=0)):
gdf = COLLECTIONS[name]
if bbox:
x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
gdf = gdf.cx[x0:x1, y0:y1]
if datetime_:
gdf = filter_datetime(gdf, datetime_)
for key, value in request.query_params.items():
if key in FILTERABLE:
gdf = gdf[gdf[FILTERABLE[key]] == value]
matched = len(gdf)
page = gdf.iloc[offset:offset + limit]
body = json.loads(page.to_json())
body |= {"numberMatched": matched, "numberReturned": len(page),
"timeStamp": datetime.datetime.now(datetime.UTC).isoformat(),
"links": paging_links(request, offset, limit, matched)}
return Response(json.dumps(body), media_type="application/geo+json")
3. Get the paging links right
Clients follow rel="next" rather than constructing URLs, so the links are the paging contract:
def paging_links(request, offset, limit, matched):
self_url = str(request.url)
base = str(request.url.remove_query_params(["offset"]))
joiner = "&" if "?" in base else "?"
links = [{"href": self_url, "rel": "self", "type": "application/geo+json"}]
if offset + limit < matched:
links.append({"href": f"{base}{joiner}offset={offset + limit}",
"rel": "next", "type": "application/geo+json"})
if offset > 0:
previous = max(0, offset - limit)
links.append({"href": f"{base}{joiner}offset={previous}",
"rel": "prev", "type": "application/geo+json"})
return links
4. Handle datetime in its three forms
The standard allows an instant, a closed interval and an open-ended one:
?datetime=2025-06-01T00:00:00Z
?datetime=2025-01-01/2025-12-31
?datetime=2025-01-01/..
def filter_datetime(gdf, spec, column="observed_at"):
if "/" not in spec:
return gdf[gdf[column] == spec]
start, end = spec.split("/", 1)
if start not in ("", ".."):
gdf = gdf[gdf[column] >= start]
if end not in ("", ".."):
gdf = gdf[gdf[column] <= end]
return gdf
5. Serve the single-feature endpoint
/collections/{name}/items/{featureId} returns a bare GeoJSON Feature โ not a FeatureCollection. Clients rely on that difference.
@app.get("/collections/{name}/items/{feature_id}")
def item(name: str, feature_id: str):
gdf = COLLECTIONS[name]
match = gdf[gdf["id"].astype(str) == feature_id]
if match.empty:
raise HTTPException(404, f"no feature {feature_id!r} in {name!r}")
feature = json.loads(match.to_json())["features"][0]
return Response(json.dumps(feature), media_type="application/geo+json")
6. Decide between writing it and deploying pygeoapi
Write it when the backend is unusual, when the filters are domain-specific, or when performance matters enough that you want control of the query.
Deploy pygeoapi when the data sits in something it already supports โ PostGIS, a file, an OGC service โ and the requirement is a standards-compliant endpoint rather than a bespoke one. It is a YAML file and a container.
Either way, the limits are yours to set, and the measurements above are the reason to set them low.
Code examples
Example 1 โ the complete core service
import datetime
import json
from fastapi import FastAPI, HTTPException, Query, Request, Response
import geopandas as gpd
app = FastAPI(title="Features service")
CRS84 = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
MAX_LIMIT = 200
FILTERABLE = {"admin": "admin", "type": "type"}
COLLECTIONS = {"provinces": gpd.read_file("provinces.gpkg")}
for _gdf in COLLECTIONS.values():
_gdf.sindex # build the spatial index at start-up
def base_of(request):
return str(request.base_url).rstrip("/")
@app.get("/collections")
def collections(request: Request):
base = base_of(request)
return {"collections": [describe(name, gdf, base)
for name, gdf in COLLECTIONS.items()],
"links": [{"href": f"{base}/collections", "rel": "self",
"type": "application/json"}]}
@app.get("/collections/{name}")
def collection(request: Request, name: str):
if name not in COLLECTIONS:
raise HTTPException(404, f"no collection {name!r}")
return describe(name, COLLECTIONS[name], base_of(request))
Example 2 โ testing conformance in CI
import pytest
from fastapi.testclient import TestClient
CORE = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"
@pytest.fixture(scope="module")
def client():
from service import app
return TestClient(app)
def test_landing_page_links(client):
body = client.get("/").json()
rels = {link["rel"] for link in body["links"]}
assert {"self", "conformance", "data"} <= rels
def test_conformance_declares_core(client):
assert CORE in client.get("/conformance").json()["conformsTo"]
def test_items_shape(client):
body = client.get("/collections/provinces/items?limit=5").json()
assert body["type"] == "FeatureCollection"
assert body["numberReturned"] == len(body["features"]) == 5
assert "numberMatched" in body
def test_paging_follows_links(client):
body = client.get("/collections/provinces/items?limit=5").json()
next_link = next(l for l in body["links"] if l["rel"] == "next")
second = client.get(next_link["href"]).json()
first_ids = {f["id"] for f in body["features"]}
second_ids = {f["id"] for f in second["features"]}
assert not (first_ids & second_ids), "pages overlap"
def test_limit_is_capped(client):
assert client.get("/collections/provinces/items?limit=100000").status_code == 422
def test_single_feature_is_a_feature(client):
items = client.get("/collections/provinces/items?limit=1").json()
feature_id = items["features"][0]["id"]
body = client.get(f"/collections/provinces/items/{feature_id}").json()
assert body["type"] == "Feature"
test_paging_follows_links is the one that catches the most: it follows the link the service produced rather than a URL the test constructed, which is exactly what a real client does.
Example 3 โ a pygeoapi configuration for the same data
server:
bind: {host: 0.0.0.0, port: 5000}
url: https://example.org/oapif
limits: {default_items: 50, max_items: 200}
resources:
provinces:
type: collection
title: Administrative provinces
extents:
spatial: {bbox: [-180, -90, 180, 90], crs: "โฆ/CRS84"}
providers:
- type: feature
name: PostgreSQL
data:
host: db
dbname: gis
user: reader
id_field: id
table: provinces
geom_field: geom
docker run -p 5000:80 -v $(pwd)/config.yml:/pygeoapi/local.config.yml \
geopython/pygeoapi:latest
Note max_items: 200. The default in most deployments is higher, and the measurements at the top are the reason to lower it.
Explanation
Why a conformant service pays for itself immediately
QGIS, GDAL's OAPIF driver and several JavaScript clients consume conformant services with no configuration beyond a URL. That removes the wrapper each of those would otherwise need.
It also removes design decisions. The bounding-box parameter is bbox, paging is limit and offset, the count is numberMatched, and nobody has to have an opinion.
Why the paging links are the part to get exactly right
A client that follows links is coupled only to the standard; one that builds URLs is coupled to your URL structure. The link-driven design lets a service move to cursor paging, change its base path, or sit behind a CDN with different URLs without breaking a single client.
The bug to avoid is a next link that drops the other query parameters. A next link without the original bbox starts paging the whole dataset โ and it looks like a paging bug rather than a filter bug.
Why numberMatched matters more than it looks
It lets a client decide not to page. A query matching 4,596 features at a page size of 50 is ninety-two requests; a client that knows the number can narrow the filter instead.
It is also what makes progress reporting possible, and it is the field most often omitted from home-made services, because the implementer already knows how large the dataset is.
Why the limits are the most important configuration
Measured on a real polygon dataset: 50 features per page was 857 kB at 32.5 requests per second, and 1,000 features was 12.4 MB at 2.3 requests per second.
A service that allows limit=10000 has a worst case fourteen times worse than that, reachable by any client with a URL bar. max_items is not a tuning parameter; it is the difference between a service and an outage.
Edge cases or notes
/items/{id}returns a Feature, not a FeatureCollection.- The
nextlink must carry every other parameter, especiallybbox. - CRS84 is longitude-first. Do not label it EPSG:4326 and hope.
limitshould clamp or 422, never be unbounded.datetimehas three forms including open-ended intervals with...f=jsonas a query parameter is far friendlier for browser testing than anAcceptheader.- Build the spatial index at start-up, not per request.
- pygeoapi's defaults are generous โ lower
max_itemsbefore deploying.
Internal links
- OGC API Features explained โ the standard itself
- Serving spatial data explained: files, features and tiles โ whether a feature API is the right shape
- How to paginate a large feature API in Python โ the paging details
- How to add bounding box and attribute filters to a spatial API โ the filters
- How to test a spatial API with pytest and httpx โ the test suite
- How to build a GeoJSON API with FastAPI โ a simpler service
- Spatial web services explained โ WFS and the older standards
- How to set cache headers on a spatial API and tile service โ making it fast
FAQ
Should I write an OGC API Features service or deploy pygeoapi?
Deploy pygeoapi when the data sits in a backend it supports and you want a standard endpoint. Write it when the backend is unusual, the filters are domain-specific, or you need control of the queries.
How much code is a conformant service?
About 150 lines of FastAPI for the core plus GeoJSON conformance classes: five endpoints, the paging links and the standard parameters.
What is the most important limit to set?
max_items. A page of 50 features was 857 kB at 32.5 requests per second; a page of 1,000 was 12.4 MB at 2.3 requests per second on the same server.
What is the commonest implementation bug?
A next link that drops the other query parameters, so paging silently starts returning the whole dataset instead of the filtered result.
Does the single-feature endpoint return a FeatureCollection?
No โ a bare Feature. Clients depend on the difference.
How do I test conformance?
Follow the links the service produces rather than constructing URLs, and assert the response shapes: landing links, conformance classes, numberMatched, non-overlapping pages and a capped limit.