How to Find the Nearest Point in GeoPandas

On this page

Problem statement

"Which supermarket is nearest to each village?" is one call in GeoPandas, sjoin_nearest, and it always returns an answer. Whether the answer is right depends on two things the call does not check: whether distance is measured in metres or in degrees, and whether every feature in the target layer is what the layer's name says.

This page matches Rutland's 64 towns, villages and hamlets to their nearest OpenStreetMap supermarket, and both things turn out to matter: measured in degrees, one place in six gets a different supermarket, and the nearest "supermarket" for a third of the county is a military base.

Quick answer

Nearest-neighbour matching links each point to its closest target, unless it falls beyond a maximum distance. no match Each point links to its nearest target; one lies beyond max_distance spatialworkflow.io
Each point links to its closest target; points beyond max_distance stay unmatched.

The most practical way to find the nearest point in GeoPandas is usually GeoDataFrame.sjoin_nearest().

Basic pattern, for the nearest supermarket to each town, village and hamlet in Rutland (OpenStreetMap data, set up under Code examples):

places = places.to_crs("EPSG:27700")        # British National Grid, in metres
supermarkets = supermarkets.to_crs(places.crs)

nearest = places.sjoin_nearest(
    supermarkets,
    how="left",
    distance_col="distance_m"
)
print(f"{len(places)} places -> {len(nearest)} rows; median distance {nearest['distance_m'].median():,.0f} m")
64 places -> 64 rows; median distance 4,824 m

Use a projected CRS before measuring distance. If your data is still in latitude/longitude such as EPSG:4326, the distance values are in degrees — Example 2 shows what that looks like.

Align the layers, sjoin_nearest, cap the distance

Each customer point matched to its nearest store. one nearest match per rowTwo point layersstorescustomers + storessjoin_nearest()plus a distance columnDistances are in CRS units — reproject to metres before setting max_distance spatialworkflow.io
sjoin_nearest returns one row per left feature, plus the distance to its match.

Load the point datasets

Start by reading the two layers. Here the places are points, and the supermarkets are a mix: OpenStreetMap maps most shops as building outlines, so the points layer has one supermarket and the polygons layer six.

import pandas as pd

print(places.geom_type.unique(), supermarkets_raw.geom_type.value_counts().to_dict())

# one point per supermarket, inside its outline
supermarkets = supermarkets_raw.to_crs(27700)
supermarkets["geometry"] = supermarkets.representative_point()
print(supermarkets[["name", "geometry"]].to_string())
<ArrowStringArray>
['Point']
Length: 1, dtype: str {'Polygon': 6, 'Point': 1}
                            name                       geometry
0  Kendrew Barracks Headquarters  POINT (491254.402 315129.476)
1                          Tesco  POINT (485892.848 308613.211)
2          The Co-operative Food  POINT (486934.372 309847.769)
3                     Co-op Food  POINT (486760.029 299753.082)
4          The Co-operative Food  POINT (485453.287 308617.001)
5                           Aldi  POINT (485425.322 310161.945)
6                           Lidl  POINT (485549.802 309351.697)

Check what the target layer contains before trusting its nearest matches: one of Rutland's seven "supermarkets" is named Kendrew Barracks Headquarters. sjoin_nearest finds the nearest feature; whether that feature is what the name of the layer says is a question about the data.

Check and align the coordinate reference system

Both layers must use the same CRS.

print(places.crs, supermarkets.crs)
EPSG:27700 EPSG:27700

If the CRS differs, reproject one layer to match the other. For nearest-distance analysis, use a projected CRS with metre-based units: in Great Britain, EPSG:27700.

places = places.to_crs("EPSG:27700")
supermarkets = supermarkets.to_crs(places.crs)

Find the nearest point with sjoin_nearest()

Use sjoin_nearest() to attach the nearest supermarket to each place.

nearest = places.sjoin_nearest(
    supermarkets[["name", "geometry"]],
    how="left",
    distance_col="distance_m"
)
print(len(places), "places,", len(nearest), "rows")
64 places, 64 rows

One row per place, because no place had two supermarkets at exactly the same distance; a tie would have given that place two rows. To cap the search, max_distance takes a distance in the CRS's units:

nearest_5km = places.sjoin_nearest(
    supermarkets[["name", "geometry"]],
    how="left",
    distance_col="distance_m",
    max_distance=5000
)
print(nearest_5km["index_right"].isna().sum(), "of", len(places), "places have no supermarket within 5 km")
31 of 64 places have no supermarket within 5 km

Review the output columns

Inspect the result:

print(list(nearest.columns))
print(nearest[["name_left", "fclass", "name_right", "distance_m"]].round(0).head())
['osm_id', 'code', 'fclass', 'population', 'name_left', 'geometry', 'index_right', 'name_right', 'distance_m']
         name_left   fclass                     name_right  distance_m
0           Ryhall  village  Kendrew Barracks Headquarters     12992.0
1        Pickworth   hamlet  Kendrew Barracks Headquarters      8167.0
2       Cottesmore  village  Kendrew Barracks Headquarters      1642.0
3           Seaton  village                     Co-op Food      3619.0
4  Thorpe by Water  village                     Co-op Food      4132.0

Both layers have a name, so the result has name_left and name_right; index_right is the matched supermarket's row. And the first three places already show the problem Example 1 measures.

Filter by maximum search distance if needed

If you do not want unrealistic matches, you can filter after the join.

nearby_only = nearest[nearest["distance_m"] <= 5000].copy()

If you used max_distance inside sjoin_nearest(), rows with no match inside that threshold have missing joined values:

unmatched = nearest_5km[nearest_5km["index_right"].isna()].copy()
print(len(nearby_only), "within 5 km after the join;", len(unmatched), "unmatched with max_distance")
33 within 5 km after the join; 31 unmatched with max_distance

The two agree — max_distance just does the filtering inside the join, and keeps the unmatched places as rows with blanks rather than dropping them.

Rutland places to their nearest supermarket

The data used on this page

Geofabrik's OpenStreetMap extract for Rutland, from its dated 1 January 2026 snapshot so the counts below stay reproducible: the town, village and hamlet points from the places layer, and every feature tagged as a supermarket from the points and polygons layers.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests

if not Path("osm").exists():
    reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                         "rutland-260101-free.shp.zip", timeout=300)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extractall("osm")

places = gpd.read_file("osm/gis_osm_places_free_1.shp")
places = places[places["fclass"].isin(["town", "village", "hamlet"])].reset_index(drop=True)
pois = [gpd.read_file(f"osm/gis_osm_{layer}_free_1.shp") for layer in ("pois", "pois_a")]
supermarkets_raw = pd.concat([p[p["fclass"] == "supermarket"] for p in pois], ignore_index=True)
supermarkets = supermarkets_raw.to_crs(27700)
supermarkets["geometry"] = supermarkets.representative_point()
print(len(places), "places:", places["fclass"].value_counts().to_dict(), "|", len(supermarkets), "supermarkets")
64 places: {'village': 52, 'hamlet': 10, 'town': 2} | 7 supermarkets

Example 1: Find the nearest supermarket for each place

places_bng = places.to_crs("EPSG:27700")
nearest = places_bng.sjoin_nearest(
    supermarkets[["name", "geometry"]],
    how="left",
    distance_col="distance_m"
)
print(nearest.sort_values("distance_m", ascending=False)
      [["name_left", "fclass", "name_right", "distance_m"]].round(0).head(5).to_string(index=False))
print("\nplaces served by each supermarket:", nearest["name_right"].value_counts().to_dict())

shops = supermarkets[supermarkets["name"] != "Kendrew Barracks Headquarters"]
without = places_bng.sjoin_nearest(shops[["name", "geometry"]], how="left", distance_col="distance_m")
moved = nearest["name_right"].eq("Kendrew Barracks Headquarters")
print(f"without it: {moved.sum()} places re-matched; their median distance "
      f"{nearest.loc[moved, 'distance_m'].median():,.0f} m -> {without.loc[moved, 'distance_m'].median():,.0f} m")
   name_left  fclass                    name_right  distance_m
Belmesthorpe village Kendrew Barracks Headquarters     13943.0
 Borderville  hamlet Kendrew Barracks Headquarters     13883.0
   Essendine village Kendrew Barracks Headquarters     13648.0
      Ryhall village Kendrew Barracks Headquarters     12992.0
     Tinwell village Kendrew Barracks Headquarters     12780.0

places served by each supermarket: {'Co-op Food': 25, 'Kendrew Barracks Headquarters': 21, 'The Co-operative Food': 9, 'Aldi': 5, 'Tesco': 4}
without it: 21 places re-matched; their median distance 8,167 m -> 12,143 m

Every one of the five places furthest from a supermarket is matched to Kendrew Barracks Headquarters, and so are 21 of the 64 places. Whether that feature is a shop the public can use is exactly the kind of question the join cannot answer; the last line shows how much the answer depends on it.

Number of Rutland's 64 towns, villages and hamlets for which each OpenStreetMap supermarket is the nearest. nearest supermarket to 64 placesCo-op Food (Uppingham)25Kendrew Barracks HQ21 — is it a shop?The Co-operative Food9 (two stores)Aldi5Tesco401530A third of the matches depend on one questionable feature spatialworkflow.io
Measured in Example 1. Without the barracks feature, its 21 places are matched to shops a median 12.1 km away instead of 8.2 km.

Example 2: The same join in degrees

import warnings

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    in_degrees = places.to_crs(4326).sjoin_nearest(supermarkets.to_crs(4326)[["name", "geometry"]],
                                                   distance_col="distance")
print(str(caught[0].message)[:75], "…")
print(f"median 'distance': {in_degrees['distance'].median():.4f}  (degrees)")
same = (in_degrees["name_right"].values == nearest["name_right"].values).mean()
print(f"same nearest supermarket as in metres: {same:.0%} of places")
Geometry is in a geographic CRS. Results from 'sjoin_nearest' are likely in …
median 'distance': 0.0550  (degrees)
same nearest supermarket as in metres: 84% of places

The distances are in degrees and meaningless as metres. At Rutland's latitude a degree of longitude is about 60% of a degree of latitude, so "nearest" in degrees can pick a different feature than nearest on the ground — and here it did, for about one place in six. The warning says the results are "likely incorrect"; the count says which ones.

Example 3: Find the nearest point to one single geometry

For a single point, direct distance calculation is simple.

site = places_bng.loc[places_bng["name"] == "Uppingham", "geometry"].iloc[0]

candidates = supermarkets.copy()
candidates["distance_m"] = candidates.geometry.distance(site)

nearest_one = candidates.loc[candidates["distance_m"].idxmin()]
print(nearest_one[["name", "distance_m"]].to_dict())
{'name': 'Co-op Food', 'distance_m': 179.0182651093306}

This works well for one geometry or a small number of checks.

A nearest join trusts its inputs completely

sjoin_nearest builds a spatial index over the target layer and, for each source feature, asks it for the closest geometry. That is far faster than a loop of distance calculations, and it is exact — for the coordinates and the features it is given. Both of this page's surprises are about what it is given.

Ways to find the nearest point and how they scale. three approaches, very different costshow it worksscales tosjoin_nearest()✓spatial index✓millions of pointsdistance matrixevery pair compared!a few thousandmanual looploop in Python✕hundredsThe index-backed method is both the shortest to write and the fastest to run spatialworkflow.io
A distance matrix over 50 000 points is 2.5 billion comparisons.

Distance is computed in the layer's units. In longitude and latitude a degree east is shorter than a degree north — at Rutland's latitude about 60% as long — so "nearest" in degrees favours targets to the east or west, and for 16% of Rutland's places it named a different supermarket from the one nearest on the ground. And the join has no idea what a feature is. Kendrew Barracks Headquarters is tagged as a supermarket in OpenStreetMap, so for 21 places it is the nearest supermarket; remove it and their median distance goes from 8.2 km to 12.1 km. The nearest feature is always found; whether it answers the question depends on the layer. How ties and search radius work in general is in nearest-neighbour joins explained.

Polygons as targets, ties and invalid input

  • Polygon targets measure to the edge. Six of the seven supermarkets are building outlines; this page reduced each to a point inside it with representative_point(), so distances run to a point inside each building rather than to its nearest wall.
  • Ties give extra rows. None here; with a regular grid of targets, check nearest.index.duplicated().sum().

Check for null or invalid geometry values before running the join.

places_bng = places_bng[places_bng.geometry.notna() & places_bng.is_valid].copy()
supermarkets = supermarkets[supermarkets.geometry.notna() & supermarkets.is_valid].copy()
print(len(places_bng), "places,", len(supermarkets), "supermarkets after the check")
64 places, 7 supermarkets after the check

If you are not sure which CRS to use, see the broader concept in How to Reproject Spatial Data in Python (GeoPandas).

Related tasks:

If your output distances look wrong, first check your reprojection workflow in How to Reproject Spatial Data in Python (GeoPandas).

FAQ

How do I find the nearest point in GeoPandas?

Use sjoin_nearest() to match each feature in one GeoDataFrame to the closest feature in another:

result = source.sjoin_nearest(target, how="left", distance_col="distance_m")

Should I use sjoin_nearest() or calculate distance manually?

Use sjoin_nearest() for layer-to-layer matching. Use manual distance calculation when you only need the nearest geometry for one feature or a very small number of features.

Why are my nearest distance values incorrect?

The most common reason is CRS. If your data is in latitude/longitude, distances are calculated in degrees. Reproject to a local projected CRS before running the analysis.

Can I set a maximum distance for nearest matches?

Yes. Use max_distance in sjoin_nearest():

result = source.sjoin_nearest(
    target,
    max_distance=5000,
    distance_col="distance_m"
)

How do I keep the geometry of the matched nearest point, not just its attributes?

sjoin_nearest keeps the left geometry, so rename and carry the target geometry before the join, for example stores["store_geom"] = stores.geometry. That column survives the join and lets you draw connecting lines or measure later.

Why does sjoin_nearest return more rows than my source layer?

This happens when two or more target features are tied at the same nearest distance, producing duplicate rows for one source point. Drop the extras with nearest = nearest[~nearest.index.duplicated(keep="first")] if you need exactly one match per source feature.

How do I exclude a point from matching itself when both layers are the same?

Run the join, then filter out self-matches where the indices line up, for example by comparing an ID column and keeping rows where left_id != right_id. A plain sjoin_nearest of a layer against itself returns each point matched to itself at distance zero.

How do I find the k nearest points instead of just the single closest one?

sjoin_nearest accepts exclusive and a max_distance, but for k-nearest use scipy.spatial.cKDTree on the coordinate arrays from points_from_xy, querying with k=3. Build the tree in a projected CRS so the returned distances are in metres.