Nearest-Neighbor Joins

Building geometry from plain coordinate columns and matching each point to the closest feature in another layer with sjoin_nearest, instead of a containment predicate.

GeoPandas Fundamentals covers matching points to the polygon they fall inside. That question does not always have an answer — a point can easily sit outside every polygon in the other layer — and sometimes it is not even the right question. "Which store is closest to this customer" or "which sensor is nearest this road segment" asks for a nearest match, not a containment test.

Turning coordinates into geometry#

Data rarely starts as geometry. More often it is two plain float columns — lon and lat — that need to become points before any spatial operation can touch them:

Python
import geopandas as gpd
points = gpd.GeoDataFrame(    df,    geometry=gpd.points_from_xy(df.lon, df.lat),    crs="EPSG:4326")

points_from_xy builds one Point per row from the two coordinate columns in a single vectorized call — no loop building Point objects one row at a time. Wrapping the result in GeoDataFrame attaches that geometry plus a declared CRS, here EPSG:4326: plain latitude/longitude, the natural format for coordinates read out of a GPS device or a location field.

Distance only means something in a projected CRS#

EPSG:4326 is a geographic CRS — coordinates are angles in degrees, not lengths. A "distance" computed directly in it is a difference in degrees, which does not correspond to a fixed distance in meters anywhere on Earth. As covered in GeoPandas Fundamentals, any distance calculation needs a projected CRS first — reproject with to_crs before joining, not after.

The nearest-neighbor join#

Python
nearest = gpd.sjoin_nearest(    points,    lines[["segment_id", "geometry"]],    how="left",    distance_col="distance_m")

sjoin_nearest asks a different question than sjoin: not "does a predicate hold," but "which feature in the right-hand layer is closest?" — for every geometry in the left-hand layer. distance_col writes the computed distance into a new column, in whatever linear unit the active CRS uses.

distance_m is a confidence signal

A join always returns a nearest feature, even when the true nearest one is hundreds of meters away — a point genuinely outside the other layer's coverage, for instance. Store the distance and treat a large value as a hint to double-check the match, not as an equally trustworthy result.

Handling ties#

A nearest join can return more than one equally-near match — two lines meeting exactly at a point, for example. If the target schema wants exactly one row per point, break the tie explicitly:

Python
nearest = (    nearest.sort_values("distance_m")    .drop_duplicates(subset="point_id", keep="first"))

Sorting before dropping duplicates guarantees the surviving row is the closest match, not an arbitrary one from the join's own output order.

Common mistakes#

Computing distance in a geographic CRS#

A distance_m computed while still in EPSG:4326 looks like a small, plausible number and is easy to mistake for meters when it is really degrees. Reproject to a projected CRS before joining, every time.

Trusting every match equally#

sjoin_nearest never fails to find something. Read distance_col alongside the match, and decide a sane threshold beyond which a match is not worth using.

Skipping the tie-break after a nearest join#

Without sort_values + drop_duplicates, a point that ties between two features can appear twice in the result — silently doubling that point in anything joined against it downstream.

Further reading#

See also#