Snapping Coordinates to the Nearest Address Point

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.

Covered and uncovered queries against the same dataset A cluster of address points represents a covered urban area. A query inside the cluster returns a point twelve metres away and is accepted. A second query far outside the cluster returns the nearest point in the dataset, over three kilometres away, which the distance ceiling rejects so the query can fall back to a provider. address points — covered area query A → 12 m → accept inside the ceiling; this is the delivery point query B → 3 100 m → reject outside coverage; without a ceiling this returns a wrong address confidently A nearest-neighbour query never fails — which is exactly the danger

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

How the query stays at a millisecond Three stages. The ST_DWithin predicate lets the GiST index discard everything outside the radius. The distance operator makes the index return the survivors already ordered by distance. The limit stops execution after the first row, so no sort of a result set ever happens. ST_DWithin index discards everything outside 75 m geom <-> point rows returned already in distance order LIMIT 1 execution stops at the first row — no sort Drop the DWithin predicate and the index cannot bound the search; drop the distance operator and Postgres sorts a result set instead of walking the index. Either change turns a millisecond into hundreds. Both clauses are required; neither is redundant with the other.

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

Snap coverage and the residual provider spend Three regions compared. City traffic snaps locally 97 percent of the time. Suburban traffic snaps 84 percent. Rural traffic snaps 41 percent, leaving the majority to a provider. The provider spend is proportional to the unshaded remainder in each row. Share of queries answered by the local snap city 97% local · 3% to a provider suburban 84% local · 16% to a provider rural 41% local — most of this region still costs money

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.