How to Snap Points to a Street Network in Python
On this page
Problem statement
Routing works on graph nodes. Your data is addresses, shops, bus stops or incidents β none of which sit on a junction. Before anything can be routed, every point has to be attached to the network, and the attachment introduces error nobody records.
import osmnx as ox
nodes = ox.nearest_nodes(G, points.geometry.x, points.geometry.y, return_dist=True)
Measured over 300 shops from OpenStreetMap in central Manchester (step 2 and step 4 have the code):
Gp = ox.project_graph(G)
pts = points.to_crs(Gp.graph["crs"])
_, to_node = ox.nearest_nodes(Gp, pts.geometry.x, pts.geometry.y, return_dist=True)
_, to_edge = ox.nearest_edges(Gp, pts.geometry.x, pts.geometry.y, return_dist=True)
for label, d in [("NODE", to_node), ("EDGE", to_edge)]:
print(f"snap to {label}: median {np.median(d):5.1f} m p90 {np.percentile(d, 90):5.1f} m max {np.max(d):5.1f} m")
snap to NODE: median 31.5 m p90 77.9 m max 139.4 m
snap to EDGE: median 13.7 m p90 47.5 m max 128.3 m
A median snap of 31 m, and one shop moved 139 m. On a question about 400 m catchments, that is a large fraction of the answer β and it is invisible unless you ask for return_dist.
Quick answer
Snap, keep the distance, and reject the outliers:
import numpy as np
import osmnx as ox
Gp = ox.project_graph(G) # snap in metres, not degrees
pts = points.to_crs(Gp.graph["crs"])
node_ids, distances = ox.nearest_nodes(
Gp, pts.geometry.x, pts.geometry.y, return_dist=True
)
pts["node"] = node_ids
pts["snap_m"] = distances
too_far = pts["snap_m"] > 100 # a dense city centre; 500 m rurally
print(f"median snap {np.median(distances):.1f} m, "
f"{too_far.sum()} points over 100 m from the network")
median snap 31.5 m, 14 points over 100 m from the network
Two functions, answering different questions:
| Function | Snaps to | Median error | Use when |
|---|---|---|---|
nearest_nodes |
a junction | 31.5 m | routing between graph nodes |
nearest_edges |
a street segment | 13.7 m | you need the true nearest street |
Node snapping is more than twice as far on median, because it has to reach a junction rather than the nearest bit of road.
Project, keep the distance, reject outliers, check collisions
1. Project first, always
Gp = ox.project_graph(G)
pts = points.to_crs(Gp.graph["crs"])
nearest_nodes computes Euclidean distance in the graph's coordinate system. On the unprojected EPSG:4326 graph that is degrees, so the "nearest" node is the nearest in a latitude-distorted space and the returned distance is in degrees.
At 53Β°N a degree of longitude is 60% of a degree of latitude, so unprojected snapping is biased east-west. It usually still finds a plausible node, which is what makes it easy to miss.
2. Always ask for the distance
node_ids, distances = ox.nearest_nodes(Gp, xs, ys, return_dist=True)
Without return_dist=True the snap error is discarded, and there is no way to tell a point 5 m from a junction from one 300 m away in a field. Both come back as a node id.
for pct in (50, 75, 90, 95, 99):
print(f" p{pct}: {np.percentile(distances, pct):6.1f} m")
p50: 31.5 m
p75: 49.7 m
p90: 77.9 m
p95: 99.1 m
p99: 114.9 m
That distribution is a report, not diagnostics. A catchment analysis at 400 m has a median 8% error from snapping alone before any routing happens.
3. Set a rejection threshold and act on it
MAX_SNAP_M = 100
too_far = pts["snap_m"] > MAX_SNAP_M
print(f"{too_far.sum()} of {len(pts)} points over {MAX_SNAP_M} m from the network")
print(pts.loc[too_far, ["name", "snap_m"]].round(1).to_string(index=False))
14 of 300 points over 100 m from the network
name snap_m
Victory Trading UK 109.1
Specsavers 109.0
Snippers 2 105.6
Tessuti 114.8
Home Bargains 110.2
The Works 139.4
MS Auto Repair Centre 105.0
H&M 106.0
Cosy Home Furnishings 104.3
Home Components 100.1
Childrens Air Ambulance 128.6
PMT Manchester 101.2
House of CB London 118.4
Police 106.8
None of them is in a field. Four are inside the Arndale Centre, one is on St Ann's Square, and a Superdrug and a Clarks sit side by side in a retail park in Cheetham Hill β places where the nearest street a car can use is a block or a car park away, and every one of the sixteen is within about 50 m of a mapped footway or pedestrian street. On a drive network the distance is correct, and it is exactly why a routing origin 120 m from the shop needs to be flagged: it is not where the shop is, and no downstream code can tell. (On network_type="walk" the same shops would snap to the pedestrian street outside.)
The threshold is a judgement β 100 m in a dense city, 500 m in a rural analysis β but having one, and reporting what it excluded, is not.
4. Use edge snapping when the nearest street matters
xs, ys = pts.geometry.x, pts.geometry.y
edges, edge_dist = ox.nearest_edges(Gp, xs, ys, return_dist=True)
print(f"node: median {np.median(distances):.1f} m")
print(f"edge: median {np.median(edge_dist):.1f} m")
node: median 31.5 m
edge: median 13.7 m
nearest_edges returns a (u, v, key) tuple β the street segment, not a routable node. To route from it you must either pick the nearer endpoint, or split the edge and insert a temporary node.
Use edge snapping when the question is "which street is this on" β address matching, assigning incidents to road segments, joining traffic counts. Use node snapping when the question is "where does routing start".
5. Check for collisions
counts = pts["node"].value_counts()
print(f"{len(pts)} points snapped to {pts['node'].nunique()} distinct nodes")
print(f"largest collision: {counts.max()} points on one node")
300 points snapped to 208 distinct nodes
largest collision: 9 points on one node
Several points on one node is normal and usually harmless β here seven share one junction, and three shops on the same junction genuinely share a routing origin. It becomes a problem when it collapses a distinction the analysis depends on: two facilities on opposite sides of a junction now have identical catchments.
Snapping 300 Manchester shops, with the error kept
The data used on this page
OpenStreetMap through OSMnx: the drive network within 2.5 km of Manchester Town Hall, and 300 shops drawn at random (seeded) from every feature tagged shop within 2 km β points as mapped, polygons by their representative point. Overpass via the maps.mail.ru mirror while overpass-api.de was refusing connections. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import geopandas as gpd
import io
import networkx as nx
import numpy as np
import os
import osmnx as ox
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-snap-points-to-network-python-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
centre = (53.4808, -2.2426)
G = ox.graph_from_point(centre, dist=2500, network_type="drive")
features = ox.features_from_point(centre, tags={"shop": True}, dist=2000)
features = features[features["name"].notna()].reset_index()
features["geometry"] = features.geometry.representative_point()
points = features[["element", "id", "name", "shop", "geometry"]].sample(300, random_state=0).reset_index(drop=True)
points = gpd.GeoDataFrame(points, geometry="geometry", crs=4326)
shops = points
print(f"{len(features):,} named shops within 2 km; 300 sampled; graph {len(G.nodes):,} nodes")
1,562 named shops within 2 km; 300 sampled; graph 5,018 nodes
Example 1 β snapping with the error kept and the outliers rejected
import geopandas as gpd
import networkx as nx
import numpy as np
import osmnx as ox
def snap_to_network(points, G, *, max_snap_m=200, to="node", require_routable=True):
"""Attach points to a street graph, keeping the snap error and rejecting outliers."""
Gp = ox.project_graph(G)
pts = points.to_crs(Gp.graph["crs"]).copy()
valid = pts.geometry.notna() & ~pts.geometry.is_empty
if not valid.all():
print(f" dropped {(~valid).sum()} null geometries")
pts = pts[valid]
xs, ys = pts.geometry.x.to_numpy(), pts.geometry.y.to_numpy()
if to == "node":
ids, dist = ox.nearest_nodes(Gp, xs, ys, return_dist=True)
pts["node"] = ids
else:
ids, dist = ox.nearest_edges(Gp, xs, ys, return_dist=True)
pts["edge"] = list(ids)
# the routable endpoint is whichever of u, v is nearer to the point
pts["node"] = [u if np.hypot(Gp.nodes[u]["x"] - x, Gp.nodes[u]["y"] - y)
<= np.hypot(Gp.nodes[v]["x"] - x, Gp.nodes[v]["y"] - y) else v
for (u, v, _), x, y in zip(ids, xs, ys)]
pts["snap_m"] = np.asarray(dist)
if require_routable:
largest = max(nx.strongly_connected_components(Gp), key=len)
stranded = ~pts["node"].isin(largest)
if stranded.any():
print(f" {stranded.sum()} points snapped to a node outside the "
f"largest strongly connected component")
pts.loc[stranded, "snap_m"] = np.nan
rejected = pts[(pts["snap_m"] > max_snap_m) | pts["snap_m"].isna()]
kept = pts.drop(index=rejected.index)
print(f" {len(kept):,} of {len(points):,} points snapped "
f"({len(rejected)} rejected over {max_snap_m} m or unroutable)")
print(f" snap distance: median {kept['snap_m'].median():.1f} m, "
f"p90 {kept['snap_m'].quantile(0.9):.1f} m, max {kept['snap_m'].max():.1f} m")
print(f" {kept['node'].nunique():,} distinct nodes "
f"({len(kept) / max(kept['node'].nunique(), 1):.2f} points per node)")
return kept, rejected
snapped, rejected = snap_to_network(shops, G, max_snap_m=100)
2 points snapped to a node outside the largest strongly connected component
284 of 300 points snapped (16 rejected over 100 m or unroutable)
snap distance: median 31.0 m, p90 70.6 m, max 99.3 m
199 distinct nodes (1.43 points per node)
Returning the rejects rather than dropping them is what makes the run auditable. Eighteen rows β sixteen too far, two on stranded nodes β is a list you can check by eye; a silent drop is a number nobody ever sees.
The require_routable check catches a subtler failure: a point that snaps to a node outside the largest strongly connected component will route into a dead end regardless of how close the snap was.
Example 2 β how much the snap error matters
def snap_error_impact(snapped, G, *, threshold_m=400, samples=200, seed=0):
"""Compare catchment membership using snapped nodes against true point distance."""
Gp = ox.project_graph(G)
rng = np.random.default_rng(seed)
centre = ox.nearest_nodes(Gp, *snapped.geometry.union_all().centroid.coords[0])
lengths = nx.single_source_dijkstra_path_length(G, centre, weight="length")
sample = snapped.sample(min(samples, len(snapped)), random_state=seed)
node_dist = np.array([lengths.get(n, np.inf) for n in sample["node"]])
# the point is snap_m away from its node, so the true distance is bounded
lower = np.clip(node_dist - sample["snap_m"], 0, None)
upper = node_dist + sample["snap_m"]
inside = node_dist <= threshold_m
ambiguous = (lower <= threshold_m) & (upper > threshold_m)
print(f" {inside.sum()} of {len(sample)} points within {threshold_m} m "
f"using the snapped node")
print(f" {ambiguous.sum()} are ambiguous once snap error is accounted for "
f"({ambiguous.mean():.1%})")
return sample.assign(node_dist=node_dist, lower=lower, upper=upper,
ambiguous=ambiguous)
for threshold in (400, 800, 1200):
print(f"catchment {threshold} m:")
impact = snap_error_impact(snapped, G, threshold_m=threshold)
catchment 400 m:
10 of 200 points within 400 m using the snapped node
2 are ambiguous once snap error is accounted for (1.0%)
catchment 800 m:
28 of 200 points within 800 m using the snapped node
5 are ambiguous once snap error is accounted for (2.5%)
catchment 1200 m:
53 of 200 points within 1200 m using the snapped node
3 are ambiguous once snap error is accounted for (1.5%)
On a drive network at city scale the effect is small but not zero: at 800 m, 28 shops (14%) fall inside, and 5 sit so close to the line that the snap error alone decides which side they are on β a headline of 14% is honestly 11.5β16.5%. At 400 m only 10 of 200 are inside, because one-way streets make even nearby shops a long drive from the centre. The fraction that matters grows as the catchment shrinks towards the size of the snap error, which is why a walking catchment feels it most.
Reporting the band rather than the point estimate is more honest and costs two extra columns.
Example 3 β splitting an edge for an exact origin
When the snap error is unacceptable, insert a temporary node at the true nearest point on the edge:
from shapely.ops import substring
def insert_point_node(Gp, point, *, node_id="temp"):
"""Split the nearest edge at the point's projection and insert a routable node."""
u, v, key = ox.nearest_edges(Gp, point.x, point.y)
data = Gp[u][v][key]
geom = data.get("geometry")
if geom is None:
from shapely.geometry import LineString
geom = LineString([(Gp.nodes[u]["x"], Gp.nodes[u]["y"]),
(Gp.nodes[v]["x"], Gp.nodes[v]["y"])])
along = geom.project(point) # metres from u
snapped_point = geom.interpolate(along)
Gp.add_node(node_id, x=snapped_point.x, y=snapped_point.y)
Gp.add_edge(u, node_id, length=along, geometry=substring(geom, 0, along))
Gp.add_edge(node_id, v, length=geom.length - along,
geometry=substring(geom, along, geom.length))
if not data.get("oneway"):
Gp.add_edge(node_id, u, length=along)
Gp.add_edge(v, node_id, length=geom.length - along)
residual = point.distance(snapped_point)
print(f" inserted {node_id} on edge ({u}, {v}), {residual:.1f} m from the point")
return node_id, residual
# the kept shop that gains most from an exact origin: far from a junction, close to a street
_, edge_m = ox.nearest_edges(Gp, snapped.geometry.x, snapped.geometry.y, return_dist=True)
pick = snapped.iloc[np.argmax(snapped["snap_m"].to_numpy() - np.asarray(edge_m))]
print(f" {pick['name']} ({pick['shop']}): {pick['snap_m']:.1f} m from node {pick['node']}")
node, residual = insert_point_node(Gp.copy(), pick.geometry)
Rockefellers Jewellers (jewelry): 95.9 m from node 1921432971
inserted temp on edge (1680876610, 3450656), 24.3 m from the point
96 m down to 24.3 m. The remaining 24.3 m is the perpendicular offset from the street centreline, which no amount of graph surgery removes β it is the distance from the shop door to the middle of the road.
This is worth doing for a handful of important origins. Doing it for thousands mutates the graph heavily and slows every subsequent traversal; for bulk work, accept node snapping and report the error.
Node against edge snapping, and degrees
Why node snapping is further than edge snapping
A node is a junction. An edge is the whole street between two junctions. A point mid-block is, by definition, roughly half a block from the nearest junction and a few metres from the street.
With a median edge length of 51 m in this network, the expected node snap for a randomly placed point is around half that plus the perpendicular offset β which is what the measured 31 m against 13 m shows (the median edge here is 50.9 m).
The gap widens where edges are long. On a rural network with 1 km between junctions, node snapping can move a point 500 m while edge snapping moves it 20.
Why snapping in degrees is wrong but looks fine
nearest_nodes on an unprojected graph computes
The consequence is that a node slightly further away east-west can beat a genuinely nearer one north-south. It usually still picks a sensible node β which is why the bug survives β and the returned distance is in degrees, so any threshold you apply is meaningless.
Projecting first costs one line and removes both problems.
Why the snap error compounds with routing error
Snapping introduces an error at both ends of a route. An origin 31 m from its node and a destination 31 m from its own contribute up to 62 m of uncertainty to a measured route length β before the circuity of the network is considered.
For a 5 km drive that is 1.2% and irrelevant. For a 400 m walking catchment it is 16% and decisive.
The rule of thumb: snapping error matters when it is a meaningful fraction of the distances you are measuring. Short-distance walkability analyses need edge snapping or node insertion; city-scale driving analyses do not.
Why collisions are usually fine and occasionally not
Several points snapping to one node is expected β a junction serves everything around it. It is a problem only when the analysis depends on distinguishing them.
The case to watch is facilities. If two competing shops on opposite corners snap to the same node, their catchments become identical and any comparison between them is meaningless. Check the collision count on facility layers specifically, and use edge snapping or node insertion where it matters.
For demand points β homes, incidents β collisions are harmless and often helpful, because they let you aggregate before routing.
Edge keys, component checks and node insertion
- Project before snapping. Unprojected snapping is latitude-biased and returns degrees.
- Always pass
return_dist=True. Without it the error is discarded. nearest_edgesreturns(u, v, key), not a node. Pick an endpoint or insert a node to route from it.- Check the snapped node is in the largest strongly connected component, or routing from it will fail regardless of how close it was.
nearest_edgesis slower β 0.08 s against 0.005 s for 300 points β because it tests geometry rather than points.- Node insertion mutates the graph. Copy it first, and do not do it thousands of times.
- Report the snap distribution, not just the median. The p90 and the maximum are what break thresholds.
- The perpendicular offset never goes away. A shop is metres from the street centreline no matter how the graph is built.
Internal links
- Street networks as graphs explained β nodes, edges and what snapping attaches to
- How to build a street network graph in Python with OSMnx β producing the graph
- NetworkX Says No Path Exists Between Two Nodes
- Network distance vs straight-line distance explained β the error snapping adds to
- How to calculate the shortest path along a street network β what the snapped nodes feed
- Nearest-neighbour joins explained β the general nearest-feature problem
- Geocoding returns wrong or missing coordinates β error upstream of snapping
- How to build a distance matrix between two layers in Python β the KD-tree machinery underneath
- How to find the measure of a point on a route β snapping when the network is a linear referencing system
- How to clean species occurrence records β when a coordinate should not be moved at all
FAQ
How far do points typically move when snapped?
On a city-centre network, 300 OpenStreetMap shops in central Manchester moved a median of 31.5 m to the nearest drivable node and 14 m to the nearest edge, with a 90th percentile of 78 m and a maximum of 139 m.
Should I snap to nodes or edges?
Nodes for routing, since routing runs between nodes. Edges when the question is "which street is this on" β address matching, assigning incidents to segments.
Why is node snapping so much further?
A node is a junction and an edge is the whole street between junctions. A mid-block point is about half an edge length from the nearest junction.
Do I need to project the graph first?
Yes. Unprojected snapping measures in degrees, which is latitude-biased and returns distances in units no threshold can use.
What snap distance should I reject at?
Depends on the density β 100 m in a dense city, 500 m rurally. The important part is having a threshold and reporting what it excluded.
Does snapping error matter?
When it is a meaningful fraction of the distances you measure. Irrelevant for a 5 km drive; decisive for a short walking catchment. On an 800 m drive catchment in central Manchester, 4 of 200 shops sat within their own snap error of the boundary.
Can I snap exactly to a point on a street?
Yes β split the nearest edge and insert a node, which reduced a 96 m snap to 24 m in the example here. It mutates the graph, so reserve it for a handful of important origins.