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.

A point mid-block snapping to a distant junction under node snapping and to the adjacent street under edge snapping. a junction, or the nearest bit of roadnearest_nodes β€” 31 m medianthe shopjumps to a junctionnearest_edges β€” 13 m medianthe shoplands on the street outsideEdges are not routable nodes β€” pick an endpoint, or split the edge spatialworkflow.io
Node snapping jumps to a junction; edge snapping lands on the street outside. The gap is the mid-block distance.

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.

Node-snap distance percentiles for 300 OpenStreetMap shops on the central Manchester drive network. the median is quoted; the tail breaks thresholdsp5031.5 mp7549.7 mp9077.9 mp9599.1 mp99114.9 mmax139.4 m0140 mEdge snapping cuts the median to 13.7 m spatialworkflow.io
The median is the number people quote. The tail is the number that breaks the analysis.

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.

What snap_to_network did to 300 OpenStreetMap shops in central Manchester. 300 shops in, 284 routable origins out, 16 on a list1project the graph and the pointsmetres, not degrees2snap with return_dist=Truemedian 31.5 m, max 139.4 m3drop nodes outside the largest SCC2 shops on stranded nodes4reject snaps over 100 m14 shops off drivable streets5keep the rest, with snap_m284 shops on 199 nodesThe rejects are returned, not dropped, so the 16 can be checked by name spatialworkflow.io
Every shop that did not become a routing origin is accounted for, with the reason.

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 Ξ”lon2+Ξ”lat2\sqrt{\Delta\text{lon}^2 + \Delta\text{lat}^2} in degrees. At 53Β°N one degree of longitude is about 66 km against 111 km for latitude, so the metric is stretched 1.68Γ— north-south relative to east-west.

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 a catchment percentage computed from snapped points needs an error band. 5 of 200 shops sit within their own snap error of an 800 m boundarywhat you havewhat it meansan 800 m drive catchment28 of 200 shops inside (14%)shops within their snap error of it5 (2.5%)so the honest answer is11.5–16.5%, not 14%central Manchester, OpenStreetMap shopsReport the band, not the point estimate spatialworkflow.io
The snapping error does not average out; it sits on the threshold.

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_edges returns (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_edges is 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.

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.