How to Publish a GeoJSON API from a GeoDataFrame with FastAPI
Problem statement
Sometimes a static file is not enough: the data changes, the client needs a filtered subset, or the dataset is too large to send whole. A small API answers "give me the features in this box matching this filter".
Four things decide whether it survives contact with real use:
- a bounding-box requirement, or one client asks for everything
- a result cap, or one query returns 59,391 features
- a spatial index, or every request scans the whole layer
- EPSG:4326 output, or nothing renders
Measured, 59,391 building polygons as GeoJSON is 27.04 MB β 2.65 MB gzipped. An unbounded endpoint over that dataset is a denial-of-service button.
Quick answer
import geopandas as gpd
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse
app = FastAPI()
DATA = gpd.read_parquet("buildings.parquet").to_crs(4326)
SINDEX = DATA.sindex
MAX_FEATURES = 5000
@app.get("/features")
def features(
bbox: str = Query(..., description="west,south,east,north"),
limit: int = Query(1000, le=MAX_FEATURES),
):
try:
west, south, east, north = (float(v) for v in bbox.split(","))
except ValueError:
raise HTTPException(400, "bbox must be west,south,east,north")
if east <= west or north <= south:
raise HTTPException(400, "bbox is empty or inverted")
idx = list(SINDEX.intersection((west, south, east, north)))
subset = DATA.iloc[idx]
truncated = len(subset) > limit
return JSONResponse({
**subset.head(limit).__geo_interface__,
"numberMatched": len(subset),
"numberReturned": min(len(subset), limit),
"truncated": truncated,
})
Requiring the bounding box rather than defaulting it is the decision that keeps the service up.
Step-by-step solution
1. Require a bounding box
An endpoint that returns everything when no filter is given will be called that way, by a crawler if not by a user. Make bbox required and validate it β inverted or zero-area boxes are common client bugs and should be a 400, not an empty result.
2. Use the spatial index
idx = list(SINDEX.intersection(bounds))
subset = DATA.iloc[idx]
gdf.cx[...] rebuilds its mask each call, which is linear in the feature count. sindex.intersection is an R-tree lookup. Over 59,391 features that is the difference between milliseconds and tens of milliseconds per request β and it compounds under load.
Note that the R-tree query is on bounding boxes, so it returns candidates. If exact containment matters, follow it with a precise test on the candidates only.
3. Cap the result and say that you did
"numberMatched": len(subset),
"numberReturned": min(len(subset), limit),
A silently truncated response is worse than an error: the client draws a partial map and believes it is complete. Reporting both counts β the OGC API Features convention β lets the client detect it.
4. Return EPSG:4326
GeoJSON is defined in WGS 84 longitude and latitude, and clients assume it. Reproject once at load, not per request.
5. Load the data once, at startup
Reading a GeoParquet file takes 0.06 seconds; reading GeoJSON takes 0.49. Neither belongs inside a request handler. Load at startup and build the spatial index there too.
Code examples
Example 1 β a service with the essentials
import geopandas as gpd
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse
app = FastAPI(title="Features API")
STATE = {}
MAX_FEATURES = 5000
MAX_BBOX_DEGREES = 1.0
@app.on_event("startup")
def load():
gdf = gpd.read_parquet("buildings.parquet").to_crs(4326)
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty]
STATE["data"] = gdf
STATE["sindex"] = gdf.sindex
STATE["fields"] = [c for c in gdf.columns if c != "geometry"]
print(f" loaded {len(gdf):,} features, fields {STATE['fields']}")
def parse_bbox(bbox: str):
try:
west, south, east, north = (float(v) for v in bbox.split(","))
except ValueError:
raise HTTPException(400, "bbox must be four numbers: "
"west,south,east,north")
if east <= west or north <= south:
raise HTTPException(400, "bbox is empty or inverted")
if (east - west) > MAX_BBOX_DEGREES or (north - south) > MAX_BBOX_DEGREES:
raise HTTPException(400, f"bbox exceeds {MAX_BBOX_DEGREES} degrees; "
"request a smaller area")
return west, south, east, north
@app.get("/collections/features/items")
def items(bbox: str = Query(...), limit: int = Query(1000, ge=1, le=MAX_FEATURES),
properties: str | None = None, where: str | None = None):
bounds = parse_bbox(bbox)
gdf = STATE["data"]
idx = list(STATE["sindex"].intersection(bounds))
subset = gdf.iloc[idx]
if where:
try:
subset = subset.query(where)
except Exception as exc:
raise HTTPException(400, f"invalid filter: {exc}")
matched = len(subset)
subset = subset.head(limit)
if properties:
wanted = [p for p in properties.split(",") if p in gdf.columns]
subset = subset[wanted + ["geometry"]]
payload = subset.__geo_interface__
payload["numberMatched"] = matched
payload["numberReturned"] = len(subset)
payload["links"] = [{"rel": "self", "href": f"/collections/features/items"
f"?bbox={bbox}&limit={limit}"}]
return JSONResponse(payload, headers={"Cache-Control": "public, max-age=60"})
Capping the bounding box size as well as the result count is the second line of defence. A client asking for the whole world with limit=1000 still forces an index query over every feature.
Example 2 β cutting the payload
from fastapi import Response
import gzip
import json
def compact_geojson(gdf, decimals=6, drop_columns=()):
"""Round coordinates and drop attributes before serialising."""
out = gdf.drop(columns=[c for c in drop_columns if c in gdf.columns])
out = out.copy()
out["geometry"] = out.geometry.set_precision(10 ** -decimals)
return out.__geo_interface__
@app.get("/features.geojson")
def features_compact(bbox: str, limit: int = 1000, decimals: int = 6):
bounds = parse_bbox(bbox)
subset = STATE["data"].iloc[
list(STATE["sindex"].intersection(bounds))].head(limit)
payload = json.dumps(compact_geojson(subset, decimals=decimals))
compressed = gzip.compress(payload.encode(), 6)
print(f" {len(subset):,} features, {len(payload) / 1e6:.2f} MB, "
f"{len(compressed) / 1e6:.2f} MB gzipped")
return Response(compressed, media_type="application/geo+json",
headers={"Content-Encoding": "gzip"})
Six decimal places is about 11 cm. Measured on a full dataset, going from seven decimals to four took a GeoJSON from 2.83 MB to 1.11 MB gzipped β the compressed saving is larger than the raw one, because shorter numbers are more repetitive.
Example 3 β a summary endpoint for large areas
from fastapi import Query
import numpy as np
@app.get("/summary")
def summary(bbox: str = Query(...), cells: int = Query(20, ge=2, le=100)):
"""Counts on a grid, so a large area returns something small."""
west, south, east, north = parse_bbox_unlimited(bbox)
gdf = STATE["data"]
idx = list(STATE["sindex"].intersection((west, south, east, north)))
subset = gdf.iloc[idx]
if subset.empty:
return {"cells": [], "total": 0}
points = subset.geometry.representative_point()
col = np.clip(((points.x - west) / (east - west) * cells).astype(int),
0, cells - 1)
row = np.clip(((north - points.y) / (north - south) * cells).astype(int),
0, cells - 1)
counts = np.zeros(cells * cells, int)
np.add.at(counts, row * cells + col, 1)
step_x = (east - west) / cells
step_y = (north - south) / cells
features = []
for i, count in enumerate(counts):
if not count:
continue
r, c = divmod(i, cells)
features.append({
"type": "Feature",
"properties": {"count": int(count)},
"geometry": {"type": "Polygon", "coordinates": [[
[west + c * step_x, north - (r + 1) * step_y],
[west + (c + 1) * step_x, north - (r + 1) * step_y],
[west + (c + 1) * step_x, north - r * step_y],
[west + c * step_x, north - r * step_y],
[west + c * step_x, north - (r + 1) * step_y]]]},
})
return {"type": "FeatureCollection", "features": features,
"total": int(counts.sum())}
Giving clients a cheap way to ask about a large area is what stops them asking for it feature by feature. A grid of counts over a whole city is a few kilobytes and answers most "where is the data dense" questions.
Explanation
Why the bounding box must be required
Optional filters get omitted. A crawler, a misconfigured client or a developer testing in a browser will call the bare endpoint, and if that returns everything, the service serialises 27 MB and blocks a worker for seconds.
Making it required turns that into a 422 from FastAPI's validation, before any work happens. Capping its size closes the remaining hole, where a client asks for the whole world.
Why the index matters under load
A single gdf.cx[...] over 59,391 features is fast enough not to notice. At a hundred requests a second it is the whole CPU budget.
The R-tree turns each query into a tree descent proportional to the number of results rather than the size of the dataset. It is built once at startup and shared, since it is read-only.
That also means the service is effectively stateless per request, which is what allows it to scale by running more processes.
Why to report numberMatched
A truncated GeoJSON is indistinguishable from a complete one. The client draws it, the user reads it as the full picture, and nothing anywhere indicates otherwise.
The OGC API Features convention of returning both numberMatched and numberReturned makes truncation detectable. A client that cares can page; one that does not at least has the information.
Why an API instead of tiles
Tiles are better for rendering: they are pre-generated, cacheable, and the client only loads the current view.
An API is better when the client needs the data rather than a picture β attribute queries, exact geometry, arbitrary filters, or a subset defined by something other than the viewport. It is also better when the data changes faster than a pyramid can be regenerated.
Many systems need both: tiles for the map, an API for the query panel and the export button.
Edge cases or notes
- Require the bounding box and cap its size.
- Cap the result count and report
numberMatched. - Build the spatial index at startup, not per request.
- Return EPSG:4326; reproject once at load.
- Round coordinates to six decimals β about 11 cm.
- Gzip the response; GeoJSON compresses about tenfold.
- Validate filter expressions rather than passing them to
queryunchecked. - Offer a summary endpoint so large-area questions have a cheap answer.
Internal links
- How to prepare a GeoDataFrame for the web β the output format rules
- My GeoJSON is too big for the browser β why the cap matters
- How to use the spatial index directly in GeoPandas (sindex) β the index used here
- Spatial web services explained: WFS, WMS and OGC API Features β the standards this imitates
- How to build vector tiles from a GeoDataFrame in Python β the rendering alternative
- How to write partitioned GeoParquet and query it with filters β a larger-than-memory backend
- How to add interactivity and popups to a web map β the client that consumes this
- How to handle credentials and secrets in an automated GIS job β securing the service
FAQ
How do I serve a GeoDataFrame as an API?
Load it once at startup, reproject to EPSG:4326, build a spatial index, and expose an endpoint that requires a bounding box, caps the result count and reports how many matched.
Why require a bounding box?
Because an optional filter will be omitted. Serialising 59,391 features is 27 MB and blocks a worker for seconds.
How do I stop a client asking for too much?
Cap the result count, cap the bounding-box size, and offer a summary endpoint that answers large-area questions cheaply.
Why report numberMatched?
A truncated response looks identical to a complete one. Reporting both counts is the OGC API Features convention and the only way a client can detect truncation.
Should I use .cx or the spatial index?
The index. .cx rebuilds its mask each call, which is linear in the dataset size and becomes the whole CPU budget under load.
How much precision should the API return?
Six decimal places, about 11 cm. Beyond that is invisible on any map and costs real bytes.
API or vector tiles?
Tiles for rendering; an API when the client needs the data itself β attribute queries, exact geometry, or arbitrary filters. Large systems usually have both.