Nearest-Neighbour Matching with GeoPandas sjoin_nearest Jump to heading
Turn two datasets into a bounded set of plausible pairings, with distances measured in metres, a cap that survives dense city centres, and an output that is identical on every run.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
sjoin_nearest finds, for each row of the left frame, the nearest rows of the right frame, optionally within a maximum distance. Three properties decide whether it does what you want.
Distance units follow the CRS. In a geographic CRS the “distance” is in degrees, which is not a distance: a degree of longitude is 111 kilometres at the equator and 55 at sixty degrees north. A max_distance of 0.001 therefore means different things in different parts of one dataset. Reprojecting both frames to a metric CRS first is not an optimisation, it is a correctness requirement.
It returns the nearest, not all within the radius. By default it returns the single nearest match per left row. Candidate generation wants several, which means either raising the number returned or — more controllably — doing a radius join and ranking afterwards.
Ties are resolved arbitrarily. Two features at identical distance produce an order that depends on internal index layout, so two runs over the same data can differ. Sorting explicitly by distance and then by a stable identifier makes the output reproducible.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import geopandas as gpd
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.conflate.candidates")
def to_metric(frame: gpd.GeoDataFrame, epsg: int) -> gpd.GeoDataFrame:
"""Reproject to a metric CRS. Distances in degrees are not distances."""
if frame.crs is None:
raise ValueError("frame has no CRS; set it before reprojecting")
return frame.to_crs(epsg=epsg)
def candidates(external: gpd.GeoDataFrame, osm: gpd.GeoDataFrame,
radius_m: float, epsg: int, max_per_record: int = 10,
left_id: str = "record_id",
right_id: str = "osm_key") -> pd.DataFrame:
"""Every OSM feature within `radius_m` of each external record, ranked.
Returns one row per candidate PAIR, with the distance and the rank, so the
scoring stage can see the runner-up as well as the best candidate.
"""
left = to_metric(external, epsg)
right = to_metric(osm, epsg)
for frame, name, key in ((left, "external", left_id), (right, "osm", right_id)):
if key not in frame.columns:
raise ValueError(f"{name} frame is missing the identifier {key!r}")
# Buffer the left side and join: this returns EVERYTHING within the radius,
# unlike a nearest join which returns only the closest.
probe = left.copy()
probe["geometry"] = probe.geometry.buffer(radius_m)
pairs = gpd.sjoin(probe[[left_id, "geometry"]], right[[right_id, "geometry"]],
how="inner", predicate="intersects")
logger.info("%d record(s) produced %d raw candidate pair(s)",
len(left), len(pairs))
# Recover the true point-to-feature distance; the buffer was only a filter.
left_geom = left.set_index(left_id).geometry
right_geom = right.set_index(right_id).geometry
pairs = pairs.reset_index(drop=True)
pairs["distance_m"] = [
left_geom.loc[a].distance(right_geom.loc[b])
for a, b in zip(pairs[left_id], pairs[right_id])
]
# Deterministic order: distance, then the identifier, so ties never wobble.
pairs = pairs.sort_values([left_id, "distance_m", right_id],
kind="mergesort").reset_index(drop=True)
pairs["rank"] = pairs.groupby(left_id).cumcount()
capped = pairs[pairs["rank"] < max_per_record].copy()
dropped = len(pairs) - len(capped)
if dropped:
logger.warning("capped %d pair(s) beyond rank %d — those records are "
"dense enough to need review regardless of score",
dropped, max_per_record)
per_record = capped.groupby(left_id).size()
logger.info("candidates per record: median %.0f, max %d, %d record(s) with none",
per_record.median() if len(per_record) else 0,
per_record.max() if len(per_record) else 0,
len(left) - per_record.shape[0])
return capped[[left_id, right_id, "distance_m", "rank"]]
if __name__ == "__main__":
logger.info("call candidates(external, osm, radius_m=120, epsg=32633)")
Step-by-step walkthrough Jump to heading
- Refuse a missing CRS. A frame without a declared CRS cannot be reprojected correctly, and silently treating its coordinates as degrees or metres is the single most damaging assumption available here.
- Reproject both sides to the same metric CRS. Distances then mean metres everywhere in the dataset rather than varying with latitude.
- Require identifiers up front. Checking for them before the expensive join turns a confusing merge error into a clear message.
- Buffer and intersect rather than joining nearest. A nearest join returns one match; buffering the left side and intersecting returns everything within the radius, which is what a scoring stage needs.
- Recompute the true distance. The buffer was only a spatial filter; the distance that matters is from the original point or geometry, not from the buffer’s edge.
- Sort deterministically before ranking. Distance first, then the identifier as a tie-break, with a stable sort. Without this, two runs over identical input can rank tied candidates differently.
- Cap per record and report it. A record with more candidates than the cap is, by definition, in a dense area where the matcher is least reliable — the warning marks it for review rather than silently truncating.
- Report the distribution. Median and maximum candidates per record, plus the count of records with none, are the three numbers that tell you whether the radius is sensible before any scoring runs.
Verification Jump to heading
- Distances are plausible metres. Spot-check a pair whose separation you can estimate; a value in the thousands where you expected tens means the CRS is wrong.
- Two runs produce identical output. Run twice and compare; any difference means the sort is not fully deterministic.
- The median candidate count is small. Two to six is a healthy range; a median of thirty means the radius is far too large.
- Records with no candidates are plausible. Some is normal; most means the radius is too small or the projections disagree.
- The capped records are genuinely dense. Sample a few and confirm they are in city centres rather than scattered arbitrarily.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Distances in the thousandths | Join run in a geographic CRS | Reproject both frames to a metric CRS first |
| Radius behaves differently by latitude | Degrees used as a distance | Same fix: project before measuring |
| Only one candidate per record | Nearest join used instead of a radius join | Buffer the left side and intersect |
| Output differs between runs | Ties broken by index order | Sort by distance then identifier with a stable sort |
| Memory exhausted on a large join | Radius far too large for the density | Calibrate the radius; cap candidates per record |
| Merge fails after the join | Identifier columns missing or renamed | Assert both identifiers exist before joining |
| Every record has zero candidates | The two frames are in different places | Check both CRS declarations and the bounding boxes |
Specification reference Jump to heading
GeoDataFrame.sjoin_nearestjoins each geometry in the left frame to the nearest geometries in the right frame, optionally limited bymax_distance, and reports the separation in a distance column. Distances are computed in the units of the frames’ coordinate reference system, so a geographic CRS yields degrees rather than a metric distance. See the GeoPandas spatial joins documentation for the join predicates, the distance column and the CRS requirements.
Frequently Asked Questions Jump to heading
Why reproject before a spatial join?
Because distance in a geographic CRS is measured in degrees, and a degree is not a fixed distance. A degree of longitude spans about 111 kilometres at the equator and about 55 at sixty degrees north, so a single maximum-distance value silently means two different radii in two parts of the same dataset. Reprojecting both frames to a metric CRS makes the radius mean the same thing everywhere.
Should I use a nearest join or a radius join?
A radius join, for conflation. A nearest join returns the single closest feature, which discards the runner-up — and the gap between the best and second-best candidate is one of the most informative signals available for deciding whether a match is confident or ambiguous. Buffer the external side, intersect, then rank and cap, which gives the scoring stage everything it needs.
How many candidates per record should I keep?
Enough to include the true match and its nearest rivals, which in practice is a handful. Ten is a generous cap that costs little. The more useful observation is that a record legitimately exceeding the cap is in a dense area where distance-based matching is least reliable, so those records should be flagged for review regardless of what the scoring eventually says about them.
Why do two runs produce different candidate sets?
Because ties are being broken by whatever order the spatial index happened to return. Two features at exactly the same distance have no inherent ordering, so the result depends on internal layout that can change between runs or library versions. Sorting explicitly by distance and then by a stable identifier, with a stable sort algorithm, removes the nondeterminism entirely.
Related Jump to heading
- Matching OSM Features to External Datasets — the parent topic and the calibration behind the radius.
- Scoring Conflation Candidates with Multiple Signals — what consumes these candidate pairs.
- Accelerating Point-in-Polygon Joins on OSM Data — the same indexing techniques for containment rather than proximity.
- Picking a UTM Zone for an OSM Extract — choosing the metric CRS this join needs.
- Building an R-tree Index over OSM Geometries — the index a spatial join uses underneath.
Up one level: Matching OSM Features to External Datasets.