How to Find All Points Within a Radius in Python

On this page

Problem statement

"Every store within 5 km of each customer", "all airports within 100 km of this point", "how many sensors within 50 km": radius queries are among the most common spatial questions, and with latitude-longitude data they need a great-circle distance, not a buffer in degrees. A brute-force loop over all pairs is fine for hundreds of points and hopeless for hundreds of thousands. A spatial index such as scikit-learn's BallTree with the haversine metric answers the same queries in milliseconds and returns exact great-circle results.

This page runs radius queries on the 80,000 places in OurAirports: all airfields within 100 km of a point by brute force and with a BallTree, counts of neighbours within a radius for every large airport at once, a check that both methods agree exactly, and timings that show where the index pays off.

Quick answer

Build a BallTree on coordinates in radians with the haversine metric, and give the radius in radians (kilometres divided by the Earth's radius):

from sklearn.neighbors import BallTree
R = 6371.0088
tree = BallTree(np.radians(A[["latitude_deg", "longitude_deg"]].values), metric="haversine")
lhr = A[A.iata_code == "LHR"].iloc[0]
idx, dist = tree.query_radius(np.radians([[lhr.latitude_deg, lhr.longitude_deg]]), r=100 / R, return_distance=True, sort_results=True)
near = A.iloc[idx[0]].assign(km=dist[0] * R)
print(f"places within 100 km of LHR: {len(near):,}; of which large or medium airports: {near.type.isin(['large_airport', 'medium_airport']).sum()}")
print(near[near.type.isin(["large_airport", "medium_airport"])][["ident", "name", "km"]].head(6).round(1).to_string(index=False))
places within 100 km of LHR: 315; of which large or medium airports: 21
ident                       name   km
 EGLL    London Heathrow Airport  0.0
 EGWU               RAF Northolt  9.6
 EGLF        Farnborough Airport 30.9
 EGLK         Blackbushe Airport 31.5
 EGLC        London City Airport 35.9
 EGKB London Biggin Hill Airport 37.5

315 places lie within 100 km of Heathrow, 21 of them large or medium airports. Results come sorted by distance: RAF Northolt at 9.6 km, Farnborough at 30.9 km, London City at 35.9 km.

A radius query with a haversine BallTree. radius query with a BallTreelat, lon72,603 placesradiansnp.radiansBallTreemetric="haversine"radius / R100 / 6371.0088query_radius315 within 100 kmof LHRIdentical to a brute-force haversine filter spatialworkflow.io
A radius query with a BallTree.

Brute force, many queries, when an index pays off

1. The brute-force answer

Compute the haversine distance from the point to every row and filter. It is the reference the index must match:

def haversine_km(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 2 * R * np.arcsin(np.sqrt(a))
d = haversine_km(lhr.latitude_deg, lhr.longitude_deg, A.latitude_deg.values, A.longitude_deg.values)
brute = set(np.where(d <= 100)[0])
print(f"brute force: {len(brute):,} places; same set as the BallTree: {brute == set(idx[0])}")
brute force: 315 places; same set as the BallTree: True

The brute-force filter finds the same 315 places: the BallTree is exact, not an approximation.

2. Many queries at once

Count the places within 50 km of every large airport in one call with count_only=True:

L = A[A.type == "large_airport"]
counts = tree.query_radius(np.radians(L[["latitude_deg", "longitude_deg"]].values), r=50 / R, count_only=True) - 1
L = L.assign(within_50km=counts)
print(f"large airports: {len(L):,}; median places within 50 km: {np.median(counts):.0f}")
print(L.nlargest(5, "within_50km")[["iata_code", "municipality", "within_50km"]].to_string(index=False))
large airports: 1,175; median places within 50 km: 5
iata_code municipality  within_50km
      HND        Tokyo          909
      GMP        Seoul          633
      ITM        Osaka          580
      UKB         Kobe          568
      KIX        Osaka          552

Counting is a single call for all 1,175 large airports. The median large airport has 5 other places within 50 km, but Tokyo Haneda has 909, of which 897 are heliports, and Seoul Gimpo 633: in Japan and Korea heliports on buildings and hospitals crowd the cities.

Places within 50 km of the large airports with the most neighbours. places within 50 km, top large airportsHND Tokyo909GMP Seoul633ITM Osaka580UKB Kobe568KIX Osaka55202505007501000Median large airport: 5; Haneda's 909 include 897 heliports spatialworkflow.io
Crowded neighbourhoods.

3. When the index pays off

Time both approaches for growing numbers of query points. Timings change from run to run, so the block prints whether the speed-up clears a threshold, and the chart below shows one measured run:

import time
rows = []
for nq in (1, 100, 1000):
    Q = A.sample(nq, random_state=0)[["latitude_deg", "longitude_deg"]].values
    t0 = time.perf_counter(); tree.query_radius(np.radians(Q), r=50 / R, count_only=True); t_tree = time.perf_counter() - t0
    t0 = time.perf_counter(); [(haversine_km(q[0], q[1], A.latitude_deg.values, A.longitude_deg.values) <= 50).sum() for q in Q]; t_brute = time.perf_counter() - t0
    rows.append((nq, t_tree, t_brute))
for (nq, a_, b_), need in zip(rows, (2, 20, 50)):            # timings vary between runs; the thresholds do not
    print(f"{nq:>5} queries: BallTree more than {need}x faster than brute force: {b_ / a_ > need}")
    1 queries: BallTree more than 2x faster than brute force: True
  100 queries: BallTree more than 20x faster than brute force: True
 1000 queries: BallTree more than 50x faster than brute force: True

For one query the tree is roughly ten times faster than a vectorised pass over 72,603 rows; for 1,000 queries it is about a hundred times faster, a few hundredths of a second against nearly two seconds on this machine.

A radius in degrees, done wrong

The data used on this page

Every place in OurAirports, a public-domain database of airports, airfields, heliports and seaplane bases, excluding closed ones. The BallTree is scikit-learn's sklearn.neighbors.BallTree.

from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
p = Path("data/airports.csv")
if not p.exists():
    r = requests.get("https://davidmegginson.github.io/ourairports-data/airports.csv", timeout=300)
    r.raise_for_status()
    p.write_bytes(r.content)
A = pd.read_csv(p, keep_default_na=False, na_values=[""])
A = A[A.type != "closed"].reset_index(drop=True)
print(f"{len(A):,} places; types {A.type.value_counts().to_dict()}")
72,603 places; types {'small_airport': 42766, 'heliport': 23220, 'medium_airport': 4106, 'seaplane_base': 1273, 'large_airport': 1175, 'balloonport': 63}

Without closed sites, OurAirports lists 72,603 places: 42,766 small airports, 23,220 heliports, 4,106 medium and 1,175 large airports, 1,273 seaplane bases and 63 balloonports.

Example 1: the radius in degrees, done wrong

A common shortcut filters by a box or circle in degrees. Compare a 1Β° "radius" with the true 100 km result at Heathrow:

in_deg = np.hypot(A.latitude_deg - lhr.latitude_deg, A.longitude_deg - lhr.longitude_deg) <= 1
true = d <= 100
print(f"1Β° circle: {in_deg.sum():,} places; true 100 km circle: {true.sum():,}")
print(f"in the degree circle but farther than 100 km: {(in_deg & ~true).sum()}; within 100 km but outside the degree circle: {(true & ~in_deg).sum()}")
print(f"farthest place caught by the degree circle: {d[in_deg].max():.0f} km")
1Β° circle: 244 places; true 100 km circle: 315
in the degree circle but farther than 100 km: 11; within 100 km but outside the degree circle: 82
farthest place caught by the degree circle: 107 km

A circle of 1Β° around Heathrow catches 244 places against 315 within the true 100 km: it reaches 111 km north and south but only 69 km east and west at this latitude. It misses 82 places that are within 100 km and includes 11 that are not, the farthest 107 km away.

Map of south-east England with OurAirports places: blue within 100 kilometres of Heathrow, grey beyond, red within 100 kilometres but missed by a dashed circle of 1 degree drawn in degrees, which reaches farther north and south than east and west; the true 100 kilometre ring is drawn in blue. Map of south-east England with OurAirports places: blue within 100 kilometres of Heathrow, grey beyond, red within 100 kilometres but missed by a dashed circle of 1 degree drawn in degrees, which reaches farther north and south than east and west; the true 100 kilometre ring is drawn in blue.
A true radius and a degree circle.

How a BallTree skips whole groups

Log-log chart of query time against the number of query points for a BallTree and a brute-force haversine pass over 72,603 places, with the brute-force line rising about a hundred times faster at 1,000 queries in one measured run. Log-log chart of query time against the number of query points for a BallTree and a brute-force haversine pass over 72,603 places, with the brute-force line rising about a hundred times faster at 1,000 queries in one measured run.
Where the index pays off.

A BallTree partitions points into nested hyperspheres, balls, so a query can skip whole groups that are too far away to contain any match. With the haversine metric, scikit-learn treats the coordinates as latitude and longitude in radians on a unit sphere, so radii and returned distances are angles: multiply by the Earth's radius for kilometres. The results are exact great-circle distances on that sphere, identical to a brute-force haversine filter, which step 1 confirms.

The cost model explains when to use it. Building the tree takes a fraction of a second for 80,000 points; each query then touches only a small part of the data. A single query is barely faster than a vectorised brute-force pass, because NumPy is fast; for thousands of queries the index wins by orders of magnitude. For radius joins between two GeoDataFrames, project to a suitable CRS and use sjoin_nearest or sjoin with buffers, or keep the BallTree for latitude-longitude data spread across the globe, where no single projection is accurate.

Column order, spheres and the KD-tree alternative

Checks before running radius queries on coordinates. before a radius querylatitude first, in radianshaversine metricradius as an anglekm / 6371.0088distances back to kmx 6371.0088not degrees1Β° circle missed 82 of 315many queriesbuild the tree onceOurAirports, 72,603 open places spatialworkflow.io
Before a radius query.
  • Order of columns: the haversine metric expects latitude first, then longitude, in radians.
  • Sphere, not ellipsoid: distances can be up to about 0.5% off; points right at the edge of a radius may flip.
  • Antimeridian and poles: the haversine BallTree has no seam; boxes in degrees do.
  • KD-tree alternative: convert to 3D unit vectors and use scipy.spatial.cKDTree with chord distances, as on the plate boundaries page.

FAQ

How do I find all points within a radius of a coordinate in Python?

Build BallTree(np.radians(latlon), metric="haversine") and call query_radius(np.radians([[lat, lon]]), r=km / 6371.0088).

Why do I divide the radius by 6,371?

The haversine BallTree works on the unit sphere in radians; dividing kilometres by the Earth's radius converts the radius to an angle.

Is a BallTree faster than computing all distances?

For many queries, yes, by orders of magnitude; for a single query a vectorised NumPy pass is nearly as fast.

Can I use a radius in degrees instead?

Only as a rough pre-filter: a degree of longitude is much shorter than a degree of latitude away from the equator.