How to Snap Points to a Street Network in Python

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 random points in a city-centre network:

snap to NODE: median  36.1 m   p90  95.2 m   max 305.9 m
snap to EDGE: median  18.2 m   p90  72.6 m   max 274.0 m

A median snap of 36 m, and one point moved 306 m. On a question about 400 m walking 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"] > 200
print(f"median snap {np.median(distances):.1f} m, "
      f"{too_far.sum()} points over 200 m from the network")
median snap 36.1 m, 2 points over 200 m from the network

Two functions, answering different questions:

Function Snaps to Median error Use when
nearest_nodes a junction 36.1 m routing between graph nodes
nearest_edges a street segment 18.2 m you need the true nearest street

Node snapping is 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.
Node snapping jumps to a junction; edge snapping lands on the street outside. The gap is the mid-block distance.

Step-by-step solution

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:   36.1 m
  p75:   58.0 m
  p90:   95.2 m
  p95:  110.8 m
  p99:  164.5 m
  max:  305.9 m

That distribution is a report, not diagnostics. A catchment analysis at 400 m has a median 9% error from snapping alone before any routing happens.

3. Set a rejection threshold and act on it

MAX_SNAP_M = 200

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"]].to_string(index=False))
2 of 300 points over 200 m from the network
                    name  snap_m
   Reservoir pumping stn  305.9
        Depot (private)   274.0

Both are genuinely off the road network. Snapping them to a junction 300 m away produces a routing origin that is not where the thing is, and no downstream code can tell.

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

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 36.1 m
edge: median 18.2 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 275 distinct nodes
largest collision: 3 points on one node

Several points on one node is normal and usually harmless β€” 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.

Snap distance percentiles rising from 36 metres at the median to 165 metres at the 99th percentile and 306 at the maximum.
The median is the number people quote. The tail is the number that breaks the analysis.

Code examples

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
        pts["node"] = [u if Gp.nodes[u] else v for u, v, _ in ids]

    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=200)
  298 of 300 points snapped (2 rejected over 200 m or unroutable)
  snap distance: median 35.9 m, p90 90.4 m, max 196.2 m
  274 distinct nodes (1.09 points per node)

Returning the rejects rather than dropping them is what makes the run auditable. Two points is a number 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)


impact = snap_error_impact(snapped, G, threshold_m=400)
  84 of 200 points within 400 m using the snapped node
  27 are ambiguous once snap error is accounted for (13.5%)

Thirteen percent of points sit close enough to the threshold that the snap error alone decides which side they fall. On a headline figure of "42% within 400 m", that is a band of roughly Β±7 percentage points.

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 nearest_points, 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}); "
          f"{residual:.1f} m from the point (was {point.distance(
              Point(Gp.nodes[u]['x'], Gp.nodes[u]['y'])):.1f} m to node {u})")
    return node_id, residual


node, residual = insert_point_node(Gp.copy(), shop_point)
  inserted temp on edge (25497624, 25497655); 12.4 m from the point (was 71.8 m to node 25497624)

Seventy-two metres down to twelve. The remaining 12 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.

Explanation

Why node snapping is twice as far as 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 exactly the measured 36 m against 18 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 √(Ξ”lonΒ² + Ξ”latΒ²) 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.

Points near a 400 metre catchment boundary where the snap error decides which side they fall.
13.5% of points sit within their own snap error of the threshold. That band belongs in the reported figure.

Why the snap error compounds with routing error

Snapping introduces an error at both ends of a route. An origin 36 m from its node and a destination 36 m from its own contribute up to 72 m of uncertainty to a measured route length β€” before the circuity of the network is considered.

For a 5 km drive that is 1.4% and irrelevant. For a 400 m walking catchment it is 18% 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 cases or notes

  • 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.06 s against under 0.01 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, a median of 36 m to the nearest node and 18 m to the nearest edge, with a 90th percentile of 95 m and a maximum of 306 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 twice as far?

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 400 m walking catchment, where 13.5% of points fell within their own snap error of the threshold.

Can I snap exactly to a point on a street?

Yes β€” split the nearest edge and insert a node, which reduced a 72 m snap to 12 m in the example here. It mutates the graph, so reserve it for a handful of important origins.