TL;DR: With an address-point dataset loaded into PostGIS, a nearest-neighbour query answers reverse geocoding locally in about a millisecond and for no per-call cost — provided you impose a distance ceiling so a query in an uncovered area returns nothing rather than something distant. The wider workflow is reverse geocoding workflows in Python.
The Query
-- Address points, one row per delivery point, geography for metre distances.
CREATE TABLE address_point (
id bigserial PRIMARY KEY,
house_number text NOT NULL,
street text NOT NULL,
locality text,
postcode text,
geom geography(Point, 4326) NOT NULL
);
CREATE INDEX address_point_geom_gix ON address_point USING GIST (geom);
-- Nearest address point within 75 metres of the query coordinate.
SELECT id, house_number, street, locality, postcode,
ST_Distance(geom, :query) AS distance_m
FROM address_point
WHERE ST_DWithin(geom, :query, 75) -- bounds the search, uses the index
ORDER BY geom <-> :query, id -- KNN ordering, deterministic tie-break
LIMIT 1;
Both clauses earn their place. ST_DWithin bounds the search so the planner can use the
index to eliminate everything outside the radius, and the <-> ordering lets the index
return candidates in distance order instead of sorting a result set. Dropping either turns
a millisecond query into a scan.
Why the Distance Ceiling Is Mandatory
Without a ceiling, a nearest-neighbour query always succeeds. In a covered urban area that is what you want; in an area your dataset does not cover, it returns the nearest point in the dataset, which may be kilometres away and completely unrelated to the query.
Set the ceiling from the precision you are claiming, not from the dataset’s density. For a building-level answer, 75 metres is generous; beyond that the “nearest” point is likely to be on a different street, and returning it silently converts a coverage gap into a wrong answer.
The Index Path a KNN Query Takes
When Local Snapping Wins
| Factor | Local snap | Provider call |
|---|---|---|
| Latency | ~1 ms | 80–300 ms |
| Marginal cost | none | metered |
| Coverage | only where you have data | broad, but variable by market |
| Freshness | as fresh as your last import | provider’s own cadence |
| Attribution and licence | governed by the dataset’s terms | governed by the provider contract |
The economics are decisive where an authoritative address-point file exists for your market. A fleet workload doing hundreds of thousands of reverse lookups a day resolves almost all of them locally for the cost of a database, and calls a provider only for the tail outside coverage — which is a much smaller and much more affordable number.
The Python Side
from __future__ import annotations
from dataclasses import dataclass
import psycopg
from psycopg.rows import dict_row
SNAP_SQL = """
SELECT id, house_number, street, locality, postcode,
ST_Distance(geom, ST_MakePoint(%(lon)s, %(lat)s)::geography) AS distance_m
FROM address_point
WHERE ST_DWithin(geom, ST_MakePoint(%(lon)s, %(lat)s)::geography, %(ceiling)s)
ORDER BY geom <-> ST_MakePoint(%(lon)s, %(lat)s)::geography, id
LIMIT 1
"""
@dataclass(frozen=True)
class Snap:
house_number: str
street: str
locality: str | None
postcode: str | None
distance_m: float
def snap(conn: psycopg.Connection, lat: float, lon: float,
ceiling_m: float = 75.0) -> Snap | None:
"""Nearest address point within the ceiling, or None when uncovered."""
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(SNAP_SQL, {"lat": lat, "lon": lon, "ceiling": ceiling_m})
row = cur.fetchone()
if row is None:
return None
return Snap(
house_number=row["house_number"],
street=row["street"],
locality=row["locality"],
postcode=row["postcode"],
distance_m=float(row["distance_m"]),
)
Returning None rather than a distant match is the entire contract of this function. The
caller can then decide — fall back to a provider, return an administrative answer, or leave
the ping unresolved — and that decision is explicit rather than hidden inside a silently
poor result.
Batching the Snaps
Per-row queries are fine for interactive use and wasteful for a batch. Sending the whole set of coordinates in one statement and letting Postgres do the join is roughly an order of magnitude faster, because the per-statement overhead disappears and the index stays warm.
-- Snap a batch in one statement using a lateral join.
WITH q(idx, pt) AS (
SELECT ord, ST_MakePoint(lon, lat)::geography
FROM unnest(%(lons)s::float8[], %(lats)s::float8[]) WITH ORDINALITY AS t(lon, lat, ord)
)
SELECT q.idx, a.house_number, a.street, a.postcode,
ST_Distance(a.geom, q.pt) AS distance_m
FROM q
LEFT JOIN LATERAL (
SELECT *
FROM address_point ap
WHERE ST_DWithin(ap.geom, q.pt, 75)
ORDER BY ap.geom <-> q.pt, ap.id
LIMIT 1
) a ON true;
The LEFT JOIN LATERAL preserves one output row per input coordinate, including the
uncovered ones, which keeps the result aligned with the input array. An inner join silently
drops them and leaves the caller reconciling two differently sized lists.
Measuring Coverage Before You Rely on It
Local snapping is only economical if it answers most queries, so the first thing to measure is coverage against your own traffic — not against the dataset’s published extent. A dataset covering a whole country still leaves you calling a provider constantly if your deliveries concentrate in the areas it happens to be thin.
Run a sample of historical coordinates through the snap and record the distance distribution and the null rate. Three numbers come out of it: the share that snapped within the ceiling, the median snap distance, and the share that found nothing. The first predicts your provider spend, the second tells you whether the ceiling is well chosen, and the third localises the gaps.
Break all three down by region. Coverage is almost never uniform, and a national figure of eighty percent frequently resolves into near-total coverage in cities and near-zero in rural areas. That breakdown is what decides whether to buy a supplementary dataset for one region or to route those areas to a provider permanently.
Re-run the measurement after every import. An address-point file that gains a million rows in a release is good news only if the new rows are where your traffic is, and the same three numbers tell you that in minutes rather than after a month of watching the bill.
Coverage Decides the Economics
Edge Cases and Failure Modes
Ties at equal distance. Two address points at identical distance — common for a duplex
sharing a parcel centroid — must break deterministically or repeated queries will alternate
between them. Ordering by id after the distance operator costs nothing and makes the
answer stable.
Geometry versus geography. Using geometry with SRID 4326 makes ST_Distance return
degrees, so a 75-metre ceiling becomes a 75-degree one and the query matches most of the
planet. Use geography, or project explicitly and be consistent everywhere.
A stale import. Address points are only as current as the last load. New developments simply do not appear, and the symptom is a rising rate of uncovered queries in one area — which is worth alerting on, because it is indistinguishable from a coverage gap until you look at the import date.
Points that are parcel centroids, not doors. Some datasets place the point at the centre of the parcel, which for a large site is a long way from the entrance. Where that matters, keep both the parcel point and any access point, as discussed in choosing between HERE and Mapbox for logistics.
Integration Note
Local snapping is best modelled as the first provider in the reverse-geocoding chain, not as a separate code path. It has a coverage area, it can decline, and when it declines the next provider is tried — exactly the semantics the fallback chain already implements. Treating it that way means the routing, the metrics and the accuracy reporting all work without special cases, and the index design behind it is covered under designing spatial indexes for geocoded data.
Related
- Reverse Geocoding Workflows in Python — where snapping fits among the precision targets.
- Designing Spatial Indexes for Geocoded Data — the GiST index this query depends on.
- Choosing GiST vs BRIN Index in PostGIS — why KNN ordering requires GiST.