OGC API Features Explained: The Standard Behind Modern Spatial APIs
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.
Step-by-step solution
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": "Provinces service",
"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": "provinces",
"title": "Administrative provinces",
"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/provinces/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 measured reason bbox matters: on a 4,596-feature dataset, the whole collection was 54.06 MB of GeoJSON and a city-sized bounding box returning two features was 58.8 kB.
limit needs a documented maximum. Measured, a page of 1,000 features was 12.4 MB and the server sustained 2.3 requests per second; a page of 50 was 857 kB at 32.5 requests per second.
5. Page with links, not with arithmetic
The response carries the next page's URL:
{
"type": "FeatureCollection",
"features": [...],
"numberMatched": 4596,
"numberReturned": 50,
"links": [
{"href": "/collections/provinces/items?limit=50&offset=50", "rel": "next"},
{"href": "/collections/provinces/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=.
Code examples
Example 1 โ a minimal conformant service
from fastapi import FastAPI, Query, Request, Response
import geopandas as gpd
import json
app = FastAPI()
COLLECTIONS = {"provinces": gpd.read_file("provinces.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": "Provinces service",
"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, le=MAX_LIMIT), offset: int = 0):
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"] = __import__("datetime").datetime.utcnow().isoformat() + "Z"
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")
for field in ("numberMatched", "numberReturned", "links"):
if field not in body:
problems.append(f"{collection['id']}/items lacks {field}")
print(f"{len(problems)} conformance problem(s)")
for problem in problems:
print(" !", problem)
return not problems
Explanation
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 also the field most often omitted from home-made APIs, because the implementer already knows how big the dataset is.
Edge cases or notes
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.