← Back to K-Nearest Neighbor Routing Algorithms
This page covers what happens when “the ten nearest” becomes “the ten nearest that are available”, and the three ways to keep that query fast.
Context & When to Use
The <-> operator gives PostGIS a genuinely index-assisted nearest-neighbour scan: the GiST index is walked in distance order and the scan stops as soon as LIMIT is satisfied. Ten rows out of two million in about three milliseconds. It is one of the most elegant things in the database, and it is fragile in one specific way.
Add WHERE status = 'available' and the elegance leaks. The index cannot evaluate status, so the executor fetches each candidate in distance order, checks the attribute, and discards the misses. If one row in five hundred is available, returning ten means walking roughly five thousand index entries and doing five thousand heap fetches. The query still returns the right answer, and it now takes 400 ms instead of 3.
Three patterns fix it, and which one applies depends on the filter’s shape: few known values, a bounded search area, or a nearest-per-group requirement. All three preserve the property that makes KNN worth using — early termination — rather than falling back to computing distance for everything. The unfiltered baseline is covered in Optimizing KNN Queries with the PostGIS Distance Operator.
Runnable Implementation
-- Pattern 1 — PARTIAL INDEX: few known values, exact match
CREATE INDEX vehicles_geog_available_gix ON vehicles
USING GIST ((geom::geography)) WHERE status = 'available';
CREATE INDEX vehicles_geog_busy_gix ON vehicles
USING GIST ((geom::geography)) WHERE status = 'busy';
-- The planner picks the matching partial index; the scan is pure KNN again
SELECT id, ROUND(ST_Distance(geom::geography, $1::geography)::numeric, 1) AS m
FROM vehicles
WHERE status = 'available'
ORDER BY geom::geography <-> $1::geography
LIMIT 10;
-- Pattern 2 — BOUNDED SEARCH: filter is arbitrary, area is not
SELECT id, ROUND(ST_Distance(geom::geography, $1::geography)::numeric, 1) AS m
FROM vehicles
WHERE ST_DWithin(geom::geography, $1::geography, 5000) -- index narrows first
AND status = ANY($2::text[]) -- then cheap filtering
ORDER BY geom::geography <-> $1::geography
LIMIT 10;
-- Pattern 3 — LATERAL PER-GROUP: nearest of each category, one KNN scan each
SELECT c.code, n.id, ROUND(n.m::numeric, 1) AS m
FROM categories c
CROSS JOIN LATERAL (
SELECT v.id, ST_Distance(v.geom::geography, $1::geography) AS m
FROM vehicles v
WHERE v.category = c.code
ORDER BY v.geom::geography <-> $1::geography
LIMIT 1
) n;Pattern 2 is the one to reach for first. It is a one-line change, needs no extra indexes, and converts an unbounded walk into a bounded one — the same ST_DWithin rewrite described in Avoiding Full Scans with ST_DWithin and Geography, used here to protect the ordering rather than the filter.
Key Parameters & Options
| Pattern | Best when | Cost |
|---|---|---|
| Partial index per value | ≤ ~20 known values, equality filter | One index per value; write overhead |
ST_DWithin bound | any filter, bounded area acceptable | Changes semantics: no result beyond the radius |
LATERAL per group | nearest-of-each-category | One KNN scan per group; needs a small groups table |
| Composite btree + GiST | filter is highly selective on its own | Loses distance ordering; sort afterwards |
| Expanding radius | a result is mandatory | Two or three queries in the worst case |
| No mitigation | filter matches > ~20 % of rows | Acceptable — the walk is short anyway |
That last row matters. If the filter keeps most rows, the plain KNN scan discards very little and none of this is needed. The patterns are for selective filters, and measuring selectivity is the first step rather than the last.
When a result is mandatory: expanding search
A bounded search returns nothing when nothing is near, which is usually correct and occasionally unacceptable — a dispatcher must be given some vehicle. The answer is to try a small radius first and widen only on a miss, so the common case stays fast.
RADII_M = (2_000, 10_000, 50_000) # try nearest first, widen on empty
async def nearest_available(conn, point_wkt: str, limit: int = 10):
for radius in RADII_M:
rows = await conn.fetch(NEAREST_BOUNDED_SQL, point_wkt, radius, limit)
if len(rows) >= limit:
return rows, radius
# Final fallback: unbounded, and log it — a frequent fallback means the
# radii are wrong for this data, not that the data is unusual
return await conn.fetch(NEAREST_UNBOUNDED_SQL, point_wkt, limit), NoneChoosing between the three patterns
Gotchas & Failure Modes
- A partial index the planner will not use. The index predicate must match the query predicate closely enough for PostgreSQL to prove implication.
WHERE status = 'available'matches;WHERE status IN ('available')usually does too;WHERE status <> 'busy'does not. - Too many partial indexes. Each one is maintained on every write. Twenty partial GiST indexes on a high-write table can cost more than the queries save; measure the write path before adding the fifth.
LIMITmissing from the KNN query. Without it there is no early termination and the operator sorts the whole candidate set. The<->operator is only fast in combination with a limit.- Ordering by
ST_Distanceinstead of<->. Semantically identical, but only the operator form is index-assisted for ordering. - A
LATERALjoin over a large outer table. Pattern 3 runs one KNN scan per outer row. That is excellent for twelve categories and disastrous for 200 000 customers — for the latter, invert the problem and batch. - Filter selectivity changing over time. A partial index sized for “10 % available” behaves very differently when a fleet goes to 90 % idle overnight. Re-measure after any operational change.
Verification Snippet
-- Confirm the partial index is chosen, and that the scan terminates early
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vehicles
WHERE status = 'available'
ORDER BY geom::geography <-> ST_SetSRID(ST_MakePoint(-0.1276, 51.5072), 4326)::geography
LIMIT 10;
-- Limit (actual time=0.09..3.06 rows=10 loops=1)
-- -> Index Scan using vehicles_geog_available_gix on vehicles
-- Order By: ((geom)::geography <-> '...'::geography)
-- (no "Rows Removed by Filter" line at all)
-- Selectivity check: is any of this necessary?
SELECT status, count(*), round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct
FROM vehicles GROUP BY status ORDER BY 2 DESC;curl -s "localhost:8000/v1/nearest?lon=-0.1276&lat=51.5072&status=available&limit=10" \
| jq '{count: (.results|length), radius_used: .radius_m, first: .results[0].distance_m}'
# {"count":10,"radius_used":2000,"first":184.6}Related
- K-Nearest Neighbor Routing Algorithms — the ordering this page filters
- Optimizing KNN Queries with the PostGIS Distance Operator — the unfiltered baseline
- Avoiding Full Scans with ST_DWithin and Geography — the bound used in pattern 2
← Back to K-Nearest Neighbor Routing Algorithms