How to Add Bounding Box and Attribute Filters to a Spatial API

On this page

Problem statement

An endpoint that returns everything is a download with extra latency. Measured on Natural Earth's 4,596 states and provinces, one client making requests back to back (Example 4):

GET /features?limit=200                  3,380.2 kB  (1,174.3 kB gzipped)   14.2 req/s
GET /features?limit=50                     859.3 kB  (  300.9 kB gzipped)   47.6 req/s
GET /features?bbox=<London>                116.8 kB  (   36.0 kB gzipped)  118.7 req/s

The whole layer — what an endpoint without a cap would send — is 54.29 MB (Example 2). A London-sized bounding box is 116.8 kB of it, and served eight times as many requests per second as the largest page the cap allows.

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:

%%writefile bbox_app.py
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. The service is a file so that it can run under uvicorn and be measured; Example 4 does that.

Throughput with and without a bounding box filter on the same endpoint. one query parameterpage of 200 · 3.4 MB14.2 req/spage of 50 · 859 kB47.6 req/sLondon bbox · 117 kB118.7 req/s0120 req/sOne client, requests back to back, against 4,596 Natural Earth provinces spatialworkflow.io
Filters are not an optimisation to add later — they are why the service exists.

What you get — from 2. Use the spatial index — and know when it stops mattering

4,596 polygons: .cx 0.39 ms, brute-force intersects 0.38 ms

Validate the box, use the index, choose the predicate

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 — maxx before minx selects 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 little:

import timeit

import geopandas as gpd
from shapely.geometry import box

LAYER = gpd.read_file("provinces.gpkg")
LONDON = (-0.51, 51.28, 0.33, 51.69)
x0, y0, x1, y1 = LONDON
indexed = timeit.timeit(lambda: LAYER.cx[x0:x1, y0:y1], number=200) / 200
brute = timeit.timeit(lambda: LAYER[LAYER.intersects(box(*LONDON))], number=200) / 200
print(f"{len(LAYER):,} polygons: .cx {indexed * 1000:.2f} ms, brute-force intersects {brute * 1000:.2f} ms")
4,596 polygons: .cx 0.39 ms, brute-force intersects 0.38 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))]
print(f"{len(candidates)} candidates, {len(exact)} actually intersect the box")
39 candidates, 39 actually intersect the box

Here every candidate passed: provinces are compact, so their envelopes hug their shapes. The difference appears with long or diagonal features, as in the explanation below.

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"}
query_params = {"admin": "United Kingdom", "type": "London Borough", "column": "anything"}

subset = LAYER
for parameter, column in FILTERABLE.items():
    value = query_params.get(parameter)
    if value is not None:
        subset = subset[subset[column] == value]
print(len(subset), "London boroughs; the unlisted 'column' parameter was ignored")
28 London boroughs; the unlisted 'column' parameter was ignored

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: provinces vary so much in detail that a page of 200 and a page of 50 differ by more than the factor of four in their counts would suggest — both are in the table at the top of the page.

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 the whole layer from 54.29 MB to 32.38 MB (Example 2).

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.

Four invalid bounding boxes and what each one does if not rejected. reject these explicitlywhat arriveswhat happens without a checkthree or five valuesa ValueError deep in the handlermaxx before minxselects nothing, silentlycoordinates in metresout of range, matches nothinga bbox covering the worldthe unfiltered request wearing afilterA silently empty response is the least useful outcome available spatialworkflow.io
Reject with a 400 and a message that says what was expected.

A validated filter pipeline over world provinces

The data used on this page

The layer is Natural Earth's 1:10m admin-1 states and provinces — 4,596 polygons with their name, country (admin), type and region — saved as provinces.gpkg, and loaded into DuckDB for Example 3.

import io
import os
import zipfile

import duckdb
import geopandas as gpd
import requests

if not os.path.exists("provinces.gpkg"):
    archive = requests.get("https://naciscdn.org/naturalearth/10m/cultural/"
                           "ne_10m_admin_1_states_provinces.zip", timeout=300)
    with zipfile.ZipFile(io.BytesIO(archive.content)) as z:
        for ext in (".shp", ".shx", ".dbf", ".prj", ".cpg"):
            open(f"ne_provinces{ext}", "wb").write(z.read(f"ne_10m_admin_1_states_provinces{ext}"))
    gpd.read_file("ne_provinces.shp")[["name", "admin", "type", "region", "geometry"]].to_file("provinces.gpkg")
if not os.path.exists("provinces.duckdb"):
    build = duckdb.connect("provinces.duckdb")
    build.execute("install spatial; load spatial;")
    build.execute("create table provinces as select name, admin, geom from st_read('provinces.gpkg')")
    build.close()
print(len(gpd.read_file("provinces.gpkg")), "provinces")
4596 provinces

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
from bbox_app import parse_bbox

limits = FilterLimits()
print(len(apply_filters(LAYER, {"bbox": "-0.51,51.28,0.33,51.69", "exact": "true"}, limits)), "provinces in the London box")
for params in ({"admin": "Brazil"}, {"bbox": "-180,-60,180,80"}, {}):
    try:
        print(len(apply_filters(LAYER, params, limits)), "for", params)
    except HTTPException as exc:
        print(exc.status_code, exc.detail)
39 provinces in the London box
27 for {'admin': 'Brazil'}
400 bbox covers 50400.0 deg², maximum is 100.0
400 4,596 features match; add a bbox or a filter (maximum without one is 1,000)

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 reductions individually:

import gzip

for label, frame in (("as-is", LAYER),
                     ("6 decimal places", prepare_response(LAYER, precision=6)),
                     ("simplified to 0.01°", prepare_response(LAYER, precision=None, simplify_deg=0.01))):
    text = frame.to_json().encode()
    print(f"{label:22} {len(text) / 1e6:6.2f} MB   ({len(gzip.compress(text, 6)) / 1e6:5.2f} MB gzipped)")
as-is                   54.29 MB   (18.10 MB gzipped)
6 decimal places        32.38 MB   (10.15 MB gzipped)
simplified to 0.01°     16.62 MB   ( 5.34 MB gzipped)

Precision alone removed 40% and changes nothing a consumer can detect at map scales. 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
con = duckdb.connect("provinces.duckdb", read_only=True)
con.execute("load spatial")
matched, rows = query_features(con, bbox=LONDON, admin="United Kingdom", limit=3)
print(matched, "matched;", [row[0] for row in rows])
39 matched; ['Essex', 'Thurrock', 'Kent']

Parameter placeholders rather than string formatting, for the same reason as in any other SQL: a bounding box is client input.

Example 4 — measuring the service

import socket
import subprocess
import sys
import time

import httpx

with socket.socket() as probe:
    probe.bind(("127.0.0.1", 0))
    port = probe.getsockname()[1]
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "bbox_app:app", "--port", str(port),
                           "--log-level", "warning"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
base = f"http://127.0.0.1:{port}"
for _ in range(200):
    try:
        httpx.get(base + "/docs", timeout=1); break
    except httpx.TransportError:
        time.sleep(0.1)


def throughput(path, seconds=5.0):
    """Sequential requests from one client for a few seconds."""
    n, start = 0, time.perf_counter()
    while time.perf_counter() - start < seconds:
        body = httpx.get(base + path, timeout=None).content
        n += 1
    return body, n / (time.perf_counter() - start)


for path in ("/features?limit=200", "/features?limit=50", "/features?bbox=-0.51,51.28,0.33,51.69"):
    body, rate = throughput(path)
    print(f"GET {path:42} {len(body) / 1e3:9.1f} kB  ({len(gzip.compress(body, 6)) / 1e3:7.1f} kB gzipped) "
          f"{rate:6.1f} req/s")
server.terminate()
server.wait()
GET /features?limit=200                           3380.2 kB  ( 1174.3 kB gzipped)   14.2 req/s
GET /features?limit=50                             859.3 kB  (  300.9 kB gzipped)   47.6 req/s
GET /features?bbox=-0.51,51.28,0.33,51.69          116.8 kB  (   36.0 kB gzipped)  118.7 req/s

Why the bounding box beats every other optimisation

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.29 MB for the whole layer to 116.8 kB for a London-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 two measured the same: 0.39 ms and 0.38 ms.

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 largest page the service allows — 3.4 MB — served 14.2 requests per second to one client; a service without the cap would serve the whole layer to anyone who asked.

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.

A cheap envelope filter followed by an exact predicate on the survivors. cheap filter first, exact test second.cx[] — enveloperiver’s envelope overlapsbounding boxes onlyexact intersectstested only on candidatesonly true overlapsFor a viewport the envelope filter is right and cheaper; for containment it is not spatialworkflow.io
That two-stage structure is what a spatial index does internally.

Axis order, the antimeridian and attribute allowlists

  • bbox is 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 sindex at start-up, not per request.
  • X-Total-Count or numberMatched lets clients avoid pointless paging.
  • Round coordinates. Six decimal places is 11 cm and cut the whole layer by 40%.

FAQ

How much does a bounding box filter save?

Measured on a 4,596-feature dataset: the whole layer is 54.29 MB; a London-sized bounding box returned 116.8 kB at 118.7 requests per second, against 14.2 for a 200-feature page.

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 much at small sizes: on 4,596 polygons, .cx took 0.39 ms and a brute-force intersects 0.38 ms.

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 the whole layer, which any client can trigger.