How to Add Bounding Box and Attribute Filters to a Spatial API
Problem statement
An endpoint that returns everything is a download with extra latency. The measured difference on a 4,596-feature dataset:
GET /features 54.06 MB (18.05 MB gzipped) 2.3 req/s
GET /features?bbox=โฆ 58.8 kB (20.8 kB gzipped) 105.1 req/s
Nine hundred times less data and forty-five times the throughput, for one query parameter.
Filters are therefore not a feature to add when the API gets slow โ they are the reason it is an API. The work is in doing them safely: parsing untrusted input, choosing the right predicate, deciding when an index is worth having, and refusing requests that would return too much.
Quick answer
Parse, validate, filter, and cap:
from fastapi import FastAPI, HTTPException, Query, Response
import geopandas as gpd
app = FastAPI()
LAYER = gpd.read_file("provinces.gpkg")
MAX_LIMIT = 200
def parse_bbox(raw: str) -> tuple[float, float, float, float]:
try:
x0, y0, x1, y1 = (float(v) for v in raw.split(","))
except ValueError:
raise HTTPException(400, "bbox must be minx,miny,maxx,maxy")
if x0 >= x1 or y0 >= y1:
raise HTTPException(400, "bbox min must be less than max")
if not (-180 <= x0 <= 180 and -180 <= x1 <= 180
and -90 <= y0 <= 90 and -90 <= y1 <= 90):
raise HTTPException(400, "bbox is outside the valid CRS84 range")
return x0, y0, x1, y1
@app.get("/features")
def features(bbox: str | None = None, admin: str | None = None,
limit: int = Query(50, ge=1, le=MAX_LIMIT), offset: int = 0):
subset = LAYER
if bbox:
x0, y0, x1, y1 = parse_bbox(bbox)
subset = subset.cx[x0:x1, y0:y1] # uses the spatial index
if admin:
subset = subset[subset["admin"] == admin]
page = subset.iloc[offset:offset + limit]
return Response(page.to_json(), media_type="application/geo+json",
headers={"X-Total-Count": str(len(subset))})
bbox is minx,miny,maxx,maxy in longitude-first order, matching GeoJSON and OGC API Features.
Step-by-step solution
1. Validate the bounding box before using it
Four failures worth rejecting explicitly, because each one produces a confusing result rather than an error:
- Wrong number of values โ three or five numbers.
- Reversed order โ
maxxbeforeminxselects nothing, silently. - Out of range โ a latitude of 400, or coordinates in metres sent to a degrees API.
- Enormous โ a bounding box covering the world, which is the unfiltered request wearing a filter.
Reject with a 400 and a message that says what was expected. A silently empty response is the least useful outcome available.
2. Use the spatial index โ and know when it stops mattering
GeoPandas' .cx[] indexer uses the layer's spatial index. At small sizes that buys nothing measurable: on 4,596 polygons, an indexed bounding-box selection took 0.43 ms and a brute-force intersects against a box took 0.37 ms.
The index earns its place as the layer grows, and the crossover is well above a few thousand features. Build it once at start-up rather than per request:
LAYER.sindex # touching it builds the index; do this at import time
3. Choose the predicate deliberately
.cx[] selects by bounding box intersection, which is a coarse filter: a feature whose envelope overlaps the box is returned even if the geometry does not.
For a viewport that is exactly right and much cheaper. For "features actually inside this polygon" it is wrong, and the exact test has to follow:
from shapely.geometry import box
candidates = LAYER.cx[x0:x1, y0:y1] # cheap envelope filter
exact = candidates[candidates.intersects(box(x0, y0, x1, y1))]
Two stages, cheap first. That is the same structure a spatial index uses internally.
4. Add attribute filters with an allowlist
Never interpolate a client-supplied column name into a query. Map the parameters you accept to the columns they filter:
FILTERABLE = {"admin": "admin", "type": "type", "region": "region"}
for parameter, column in FILTERABLE.items():
value = request.query_params.get(parameter)
if value is not None:
subset = subset[subset[column] == value]
The dictionary is the API's documented surface. It also stops a filter on an unindexed column becoming a full scan by accident.
5. Cap the response, not just the page
A page limit bounds the row count and not the payload: a page of 1,000 large polygons was 12.4 MB in measurement, and the server sustained 2.3 requests per second. A page of 50 was 857 kB at 32.5 requests per second.
Two additional caps are worth having:
- A maximum matched count without a bounding box โ above it, require one.
- A coordinate precision limit. Rounding to six decimal places took a measured payload from 54.06 MB to 29.56 MB.
6. Tell the client how much matched
headers={"X-Total-Count": str(len(subset))}
or, for an OGC API Features service, numberMatched in the body. Either way the client can decide to narrow the filter rather than paging through four hundred pages.
Code examples
Example 1 โ a filter pipeline with validation and limits
from dataclasses import dataclass
from fastapi import HTTPException
from shapely.geometry import box
import geopandas as gpd
@dataclass
class FilterLimits:
max_bbox_area_deg2: float = 100.0
max_unfiltered_features: int = 1000
max_limit: int = 200
coordinate_precision: int = 6
FILTERABLE = {"admin": "admin", "type": "type"}
def apply_filters(layer: gpd.GeoDataFrame, params: dict, limits: FilterLimits):
subset = layer
if (raw := params.get("bbox")):
x0, y0, x1, y1 = parse_bbox(raw)
area = (x1 - x0) * (y1 - y0)
if area > limits.max_bbox_area_deg2:
raise HTTPException(400, f"bbox covers {area:.1f} degยฒ, maximum is "
f"{limits.max_bbox_area_deg2}")
subset = subset.cx[x0:x1, y0:y1]
if params.get("exact") == "true":
subset = subset[subset.intersects(box(x0, y0, x1, y1))]
for parameter, column in FILTERABLE.items():
if (value := params.get(parameter)) is not None:
if column not in subset.columns:
raise HTTPException(500, f"filterable column {column!r} is missing")
subset = subset[subset[column] == value]
if not params.get("bbox") and len(subset) > limits.max_unfiltered_features:
raise HTTPException(
400, f"{len(subset):,} features match; add a bbox or a filter "
f"(maximum without one is {limits.max_unfiltered_features:,})")
return subset
Example 2 โ reducing the payload before serialising
import shapely
def prepare_response(gdf, precision=6, keep_columns=None, simplify_deg=None):
"""Three cheap reductions, applied in the order that compounds best."""
out = gdf
if keep_columns:
out = out[[*keep_columns, out.geometry.name]]
if simplify_deg:
out = out.copy()
out["geometry"] = out.geometry.simplify(simplify_deg, preserve_topology=True)
if precision is not None:
out = out.copy()
out["geometry"] = shapely.set_precision(out.geometry.values,
10 ** -precision)
return out
Measured on the full 4,596-feature layer, the three reductions individually:
as-is 54.06 MB (18.05 MB gzipped)
1 m coordinate precision 29.56 MB ( 8.85 MB gzipped)
simplified to 0.01ยฐ 16.42 MB ( 5.31 MB gzipped)
Precision alone removed 45% and changes nothing a consumer can detect. Simplification removes more and does change the geometry, so it belongs on a drawing endpoint rather than on a query endpoint.
Example 3 โ pushing the filter into the database instead
import duckdb
def query_features(con, bbox=None, admin=None, limit=50, offset=0):
"""For datasets too large to hold in memory, filter in the engine."""
clauses, params = [], []
if bbox:
x0, y0, x1, y1 = bbox
clauses.append("st_intersects(geom, st_makeenvelope(?, ?, ?, ?))")
params += [x0, y0, x1, y1]
if admin:
clauses.append("admin = ?")
params.append(admin)
where = ("where " + " and ".join(clauses)) if clauses else ""
matched = con.execute(
f"select count(*) from provinces {where}", params).fetchone()[0]
rows = con.execute(f"""
select name, admin, st_asgeojson(geom) as geometry
from provinces {where}
limit ? offset ?
""", params + [limit, offset]).fetchall()
return matched, rows
Parameter placeholders rather than string formatting, for the same reason as in any other SQL: a bounding box is client input.
Explanation
Why the bounding box is worth more than any other optimisation
Every other technique on this page reduces the response by a factor of two or three. The bounding box reduces it by whatever fraction of the world the client is looking at โ measured, from 54.06 MB to 58.8 kB for a city-sized window.
That is not an optimisation, it is a different question being asked. The API returns what the client needs instead of what the dataset contains, and everything downstream โ bandwidth, serialisation, browser memory โ scales with it.
Why the spatial index does not always help
An index turns a linear scan into a tree descent, and at 4,596 features the linear scan is 0.37 ms. Descending a tree, collecting candidates and testing them measured 0.43 ms โ marginally slower.
The crossover depends on the geometry and the machine, and it is well above a few thousand features. The practical rule: build the index once at start-up, because it costs nothing to have and matters a great deal at a hundred thousand features.
Why the envelope filter is not the exact answer
.cx[] compares bounding boxes. A long diagonal river's envelope covers a large rectangle, most of which the river does not touch, so an envelope query returns it for boxes it does not intersect at all.
For a map viewport that is harmless and desirable โ better to draw a feature slightly outside the view than to miss one. For "which features are in this administrative area?", the exact predicate is required, and running it only on the envelope-filtered candidates keeps it cheap.
Why a cap belongs in the first version
Without one, the API's worst case is the whole dataset, and the worst case is what a misbehaving client will find. Measured, the unfiltered endpoint served 2.3 requests per second while returning 12.4 MB each time; ten such clients saturate the service.
A cap converts that into a 400 with an explanation, which is a much better failure. It also documents the API's intended use: an endpoint that requires a bounding box above a thousand features is telling clients what it is for.
Edge cases or notes
bboxis longitude-first โminx,miny,maxx,maxyโ in CRS84.- A bounding box crossing the antimeridian has
minx > maxx; either support it explicitly or reject it. - A bbox in the wrong CRS silently selects nothing; validate the range.
.cx[]is envelope-based. Follow with an exact predicate where correctness requires it.- Attribute filters need an allowlist, never a client-supplied column name.
- Build
sindexat start-up, not per request. X-Total-CountornumberMatchedlets clients avoid pointless paging.- Round coordinates. Six decimal places is 11 cm and halves the payload.
Internal links
- Serving spatial data explained: files, features and tiles โ where filters sit in the design
- OGC API Features explained โ the standard parameter names
- How to paginate a large feature API in Python โ the other half of bounding a response
- How to build a GeoJSON API with FastAPI โ the base implementation
- Fixing a spatial API that is slow under load โ when the filters are not enough
- How to use a spatial index in GeoPandas โ what
.cx[]uses - How to select features by location โ the predicates
- Fixing an API that returns empty or truncated GeoJSON โ when a filter silently matches nothing
FAQ
How much does a bounding box filter save?
Measured on a 4,596-feature dataset: the unfiltered response was 54.06 MB at 2.3 requests per second; a city-sized bounding box returned 58.8 kB at 105 requests per second.
What order are bounding box coordinates in?
minx,miny,maxx,maxy โ longitude first โ following GeoJSON and OGC API Features, which use CRS84 rather than EPSG:4326's official latitude-first order.
Do I need a spatial index?
Build it at start-up because it is free to have, but do not expect a gain at small sizes: on 4,596 polygons an indexed selection took 0.43 ms against 0.37 ms for a brute-force test.
Is .cx[] an exact spatial filter?
No, it compares bounding boxes. For a viewport that is what you want; for exact containment, follow it with an intersects or within test on the candidates.
How do I filter on attributes safely?
With an allowlist mapping accepted query parameters to columns. Never interpolate a client-supplied column name.
What should happen when a request would return too much?
A 400 with a message asking for a bounding box or a narrower filter. The alternative is a 12 MB response at 2.3 requests per second, which any client can trigger.