Filtering a Layer by a User-Drawn Polygon
Run the index pass, then the exact predicate on its candidates, then the attribute masks — and put the cache boundary between the second and third. A traced corridor returns ten times the candidates a rectangle does for the same answer, which is worth knowing before somebody reports it as a bug.
Jump to heading Why this matters
Filtering by a drawn shape is mechanically the same as filtering by a viewport, and it behaves differently enough in practice that reusing the viewport code without thinking produces a feature that is fine in testing and slow in use.
The difference is selectivity. A viewport is an axis-aligned rectangle, so the bounding box the spatial index works with is the query shape, and the cheap first pass is very nearly the whole answer. A shape somebody traced around a catchment, a corridor, or a district with a notch in it has a bounding box that can be several times its own area — and the index can only ever prune by bounding box. Everything inside that box and outside the shape comes back as a candidate and has to be rejected by the exact predicate, one feature at a time.
Jump to heading Prerequisites
- The validated, simplified shape from storing drawn polygons in session state. A raw freehand trace with four thousand vertices makes every exact predicate call expensive in proportion.
- A layer with a built
.sindex, held by whatever caches the layer, per speeding up GeoPandas spatial joins. - Both the shape and the layer in the same CRS. This is not a detail — a mismatch here returns an empty frame with no error at all.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Align, then reduce, then test
import geopandas as gpd
from shapely.geometry.base import BaseGeometry
def filter_by_shape(gdf: gpd.GeoDataFrame, shape: BaseGeometry,
predicate: str = "intersects") -> gpd.GeoDataFrame:
"""Two passes: bounding-box candidates from the index, then the exact test."""
assert gdf.crs is not None, "layer has no CRS — the predicate would return nothing"
candidates = gdf.sindex.query(shape, predicate=None) # bbox pass only
subset = gdf.iloc[candidates]
exact = getattr(subset.geometry, predicate)(shape) # intersects / within
return subset[exact].reset_index(drop=True)
Passing predicate=None to sindex.query asks for the bounding-box pass alone, which is what makes the two stages visible and measurable. Passing the predicate straight to query lets GeoPandas do both in one call and is what you would ship — the split here is so you can count the candidates, which is the number that explains the performance.
Jump to heading Step 2 — Know what the shape costs before the user draws it
There is no fix for a corridor’s selectivity, because the index has only bounding boxes to work with. What there is instead is a mitigation: for shapes whose bounding box is much larger than their area, splitting the query into several smaller boxes along the shape and unioning the candidate sets prunes far more. It is worth doing only when the ratio is extreme, and the ratio is one line to compute:
overshoot = shape.envelope.area / shape.area # 1.0 for a rectangle
if overshoot > 6:
parts = split_along_shape(shape) # a handful of sub-boxes
candidates = np.unique(np.concatenate([gdf.sindex.query(p) for p in parts]))
Jump to heading Step 3 — Order the predicates, and put the cache boundary in the right place
import hashlib
import streamlit as st
@st.cache_data(ttl=900, max_entries=16, show_spinner=False)
def spatial_pass(shape_wkt: str) -> gpd.GeoDataFrame:
"""Cached on the shape alone — attribute filters stay outside."""
from shapely import wkt
return filter_by_shape(load_layer(), wkt.loads(shape_wkt))
subset = spatial_pass(st.session_state.aoi_wkt)
if category != "All":
subset = subset[subset["category"] == category] # microseconds, uncached
Keying the cache on the WKT string means an analyst who draws a shape, changes their mind about the category three times, and changes it back pays for the spatial work once. Keying it on the whole filter set would produce a separate entry per combination, each holding its own copy of the geometry, all of them discarded the moment the shape changes.
Jump to heading Step 4 — Say which predicate the map is using
intersects and within give visibly different answers for features that straddle the drawn boundary, and users have a firm intuition about which one they meant that does not survive contact with a shape they drew slightly too small. Put it in the interface:
mode = st.radio("Include features that…", ["touch the area", "are fully inside"],
horizontal=True)
predicate = "intersects" if mode.startswith("touch") else "within"
Two words of interface removes an entire category of “the filter is wrong” reports, because the disagreement was never about the code.
Jump to heading Verification
from shapely.geometry import box
# 1. A rectangle's index pass is exact, so both passes agree.
rect = box(-0.2, 51.45, -0.05, 51.55)
cands = layer.sindex.query(rect, predicate=None)
assert len(layer.iloc[cands][layer.iloc[cands].intersects(rect)]) == len(cands)
# 2. The two-pass result equals the naive full scan.
fast = filter_by_shape(layer, drawn)
slow = layer[layer.intersects(drawn)]
assert len(fast) == len(slow), "the index pass dropped a match"
# 3. within is a strict subset of intersects.
assert len(filter_by_shape(layer, drawn, "within")) <= len(fast)
The second assertion is the one that matters, and it is the one that catches a stale index built before a reprojection — the failure described in speeding up spatial joins, where the query is fast and wrong and nothing raises.
Jump to heading Edge cases and gotchas
- A shape drawn entirely outside the layer. Returns an empty frame, which is correct and looks identical to a CRS mismatch. Report “no features in the drawn area” explicitly so the two cases are distinguishable to the user.
- A multi-part shape. If the draw control allows several polygons, decide whether they union or intersect. Union is almost always what users mean by drawing two areas, and the index pass takes the union’s parts individually rather than the union’s bounding box, which is much more selective.
- Very large layers. Above a few million features the candidate pass itself becomes noticeable, and the answer is to push the predicate into PostGIS with a GiST index rather than filtering a resident frame at all.
- Holes. A polygon with an interior ring behaves correctly with
intersects, but its bounding box ignores the hole entirely, so the overshoot ratio is high and the exact pass rejects everything in the middle. Expected, and worth knowing when a doughnut-shaped catchment is slow.
Jump to heading FAQ
Should the exact predicate run in Python or in the database?
In the database when the layer is large enough that holding it resident is a problem, and in Python when it is already cached. The crossover is roughly where the layer stops fitting comfortably in the container’s memory budget — below it, a resident frame with an R-tree beats a round trip; above it, PostGIS with a GiST index wins on every axis.
Why does within return fewer features than users expect?
Because a feature has to be entirely inside the drawn shape, and a hand-drawn boundary almost always clips something the user thought they had enclosed. intersects matches the intuition of “I drew around these” far more often, which is why it is the better default — but say which one is active rather than relying on the default being intuitive.
Can I reuse the viewport filter code?
Yes — the two-pass structure is identical. What does not carry over is the assumption that the first pass is nearly exact, which holds for a rectangle and not for a traced shape. If the viewport code logs candidate counts, the drawn filter will show you the difference immediately.
Back to Dynamic Spatial Filtering.