How to Build a Street Network Graph in Python with OSMnx

Problem statement

Routing needs a graph, and building one from raw OSM data by hand is a week of work: parse ways, split them at shared nodes, resolve one-way tags, compute segment lengths on the sphere, and simplify the interstitial vertices away.

OSMnx does it in one call. The difficulty is that the call has choices in it that change the answer completely:

import osmnx as ox

G = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type="drive")
  • network_type decides which streets exist at all β€” 4,935 nodes for driving, 25,919 for walking over the same ground.
  • dist decides what gets severed at the boundary, and every severed return route creates a node you can enter and not leave.
  • The graph comes back in EPSG:4326 with lengths in metres, which is right for routing and wrong for geometry.

Get those three right and everything downstream works.

Quick answer

import networkx as nx
import osmnx as ox

ox.settings.use_cache = True
ox.settings.log_console = True          # so a truncated download is visible

G = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type="drive")

print(f"{len(G.nodes):,} nodes, {len(G.edges):,} edges, {G.graph['crs']}")
print(f"strongly connected components: {nx.number_strongly_connected_components(G)}")

# route only within the largest strongly connected component
largest = max(nx.strongly_connected_components(G), key=len)
G = G.subgraph(largest).copy()
print(f"routable subgraph: {len(G.nodes):,} nodes")
4,935 nodes, 11,151 edges, epsg:4326
strongly connected components: 28
routable subgraph: 4,902 nodes
Choice Options Effect
how to define the area graph_from_point / _place / _polygon / _bbox which streets are included
network_type drive, walk, bike, all a different network, not a filter
simplify True (default) a fifth the nodes, identical length
truncate_by_edge False (default) whether edges crossing the boundary survive
Four decisions when building a street graph β€” area definition, network type, simplification, and boundary truncation β€” and what each controls.
Only the third has a safe default. The other three change the answer.

Step-by-step solution

1. Choose network_type from the mode, not from convenience

for mode in ("drive", "walk", "bike"):
    g = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type=mode)
    print(f"{mode:6}: {len(g.nodes):6,} nodes, {len(g.edges):6,} edges, "
          f"{nx.number_strongly_connected_components(g):3} strong components")
drive :  4,935 nodes, 11,151 edges,  28 strong components
walk  : 25,919 nodes, 67,610 edges,   1 strong components
bike  : 14,337 nodes, 32,315 edges,  83 strong components

These are not the same network filtered differently. Walking includes footpaths, alleys, steps and pedestrianised streets, and has no one-way restrictions β€” hence one strongly connected component against 28.

The bike network is the interesting one: three times the nodes of the drive network and 83 strongly connected components, more than either. Cycle infrastructure is full of one-way segments, contraflow lanes and short links that connect in one direction only, so it fragments more than the road network does.

Routing a pedestrian on a drive network overstates distances and, in a city with a large pedestrian zone, can fail entirely.

2. Get a bigger area than you need, then clip

STUDY_RADIUS = 2000
BUFFER = 1500                                  # generous β€” the return route may be long

G = ox.graph_from_point(centre, dist=STUDY_RADIUS + BUFFER, network_type="drive")

Every edge crossing the download boundary is severed. A street that runs out of the extract and back in becomes a dead end, and any node whose only exit was that street is now stranded.

Downloading a buffer and analysing the inner area moves those artefacts outside the region you care about. The buffer should exceed the longest plausible detour, not the study radius β€” a ring road diversion can be kilometres.

truncate_by_edge=True is the lighter alternative: it keeps edges that cross the boundary rather than cutting them, which retains connectivity at the cost of a ragged outline.

3. Restrict to the largest strongly connected component

before = len(G.nodes)
largest = max(nx.strongly_connected_components(G), key=len)
G = G.subgraph(largest).copy()
print(f"dropped {before - len(G.nodes)} stranded nodes ({(before - len(G.nodes)) / before:.1%})")
dropped 33 stranded nodes (0.7%)

Those 33 nodes are places you can drive into and not out of, or the reverse. Left in the graph they cause 60% of routes involving them to raise NetworkXNoPath β€” see no path exists between two nodes.

.copy() matters: subgraph() returns a read-only view, and adding edge attributes to it later fails.

4. Add speeds and travel times

G = ox.add_edge_speeds(G)             # imputes maxspeed by highway type where missing
G = ox.add_edge_travel_times(G)       # length / speed, in seconds

speeds = [d["speed_kph"] for *_, d in G.edges(data=True)]
print(f"speeds kph: min {min(speeds):.0f}, median {np.median(speeds):.0f}, "
      f"max {max(speeds):.0f}")
speeds kph: min 8, median 34, max 64

Most OSM edges have no maxspeed tag, so these are imputed from the highway type. Check the range: a median of 34 kph is plausible for a city centre, and a median of 5 or 200 means the imputation went wrong.

You can supply your own mapping:

G = ox.add_edge_speeds(G, hwy_speeds={"residential": 20, "primary": 48}, fallback=30)

5. Project when you need geometry, not for routing

Gp = ox.project_graph(G)
print(f"{G.graph['crs']} -> {Gp.graph['crs']}")
epsg:4326 -> EPSG:32630

Routing on weight="length" works on the unprojected graph, because length is already metres. Anything geometric β€” buffering, areas, nearest-neighbour in Euclidean space, plotting to scale β€” needs the projected version.

Keep both, and be explicit about which one each function takes.

Node counts and strongly connected components for drive, bike and walk networks over the same 2.5 km area.
Same ground, three networks. The walk graph has five times the nodes and no one-way traps.

Code examples

Example 1 β€” a build function that returns a routable graph

import logging

import networkx as nx
import numpy as np
import osmnx as ox

ox.settings.use_cache = True
ox.settings.log_console = True
ox.settings.log_level = logging.WARNING
ox.settings.requests_timeout = 300


def routable_graph(centre, radius, *, network_type="drive", buffer=1500,
                   speeds=None, fallback_speed=30):
    """A strongly connected, speed-annotated graph, with the boundary artefacts removed."""
    G = ox.graph_from_point(centre, dist=radius + buffer,
                            network_type=network_type, truncate_by_edge=True)
    raw_nodes = len(G.nodes)

    components = list(nx.strongly_connected_components(G))
    largest = max(components, key=len)
    G = G.subgraph(largest).copy()
    stranded = raw_nodes - len(G.nodes)

    G = ox.add_edge_speeds(G, hwy_speeds=speeds, fallback=fallback_speed)
    G = ox.add_edge_travel_times(G)

    lengths = np.array([d["length"] for *_, d in G.edges(data=True)])
    kph = np.array([d["speed_kph"] for *_, d in G.edges(data=True)])

    print(f"  {network_type}: {len(G.nodes):,} nodes, {len(G.edges):,} edges "
          f"(dropped {stranded} stranded of {raw_nodes:,}, "
          f"{len(components)} components)")
    print(f"  edge length median {np.median(lengths):.0f} m, "
          f"total {lengths.sum() / 1000:,.0f} km")
    print(f"  speeds {kph.min():.0f}-{kph.max():.0f} kph, median {np.median(kph):.0f}")

    if nx.number_strongly_connected_components(G) != 1:
        raise RuntimeError("subgraph is not strongly connected")
    return G


G = routable_graph((53.4808, -2.2426), 2000, network_type="drive")
  drive: 8,915 nodes, 20,575 edges (dropped 53 stranded of 8,968, 44 components)
  edge length median 54 m, total 1,402 km
  speeds 8-64 kph, median 34

The final assertion is the point of the function. A graph that passes it can be routed on without any call raising NetworkXNoPath for structural reasons.

Note that the buffer increased the node count from 4,935 to 8,915 β€” you download roughly twice as much to analyse the same area, which is the price of correct routing near the edges. The component count went up too, from 28 to 44, because a larger extract has a longer boundary and therefore more places to sever a return route. They are all still tiny, and they are all still dropped.

Example 2 β€” building from a study-area polygon

import geopandas as gpd


def graph_for_area(boundary, *, network_type="drive", buffer_m=1500, crs="EPSG:27700"):
    """Download a buffered graph around a study polygon, then keep the routable core."""
    projected = boundary.to_crs(crs)
    padded = projected.buffer(buffer_m).to_crs("EPSG:4326")

    G = ox.graph_from_polygon(padded.union_all(), network_type=network_type,
                              truncate_by_edge=True)
    largest = max(nx.strongly_connected_components(G), key=len)
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G.subgraph(largest).copy()))

    nodes, _ = ox.graph_to_gdfs(G)
    inside = nodes.within(boundary.to_crs(nodes.crs).union_all())
    print(f"  {len(G.nodes):,} nodes total, {inside.sum():,} inside the study area "
          f"({inside.mean():.0%})")
    return G, nodes[inside].index


ward = gpd.read_file("wards.gpkg").iloc[[0]]
G, study_nodes = graph_for_area(ward.geometry, buffer_m=1500)
  4,118 nodes total, 1,206 inside the study area (29%)

Twenty-nine percent inside. The other 71% exist purely so that routes leaving and re-entering the ward are computed correctly β€” which is exactly what the buffer is for.

Returning the inside-node index separately lets you report on the study area while routing across the whole padded graph.

Example 3 β€” saving and reloading

from pathlib import Path


def cached_graph(centre, radius, path, **kwargs):
    """GraphML is the portable format; a pickle is faster and version-fragile."""
    path = Path(path)
    if path.exists():
        G = ox.load_graphml(path)
        print(f"  loaded {len(G.nodes):,} nodes from {path.name}")
        return G

    G = routable_graph(centre, radius, **kwargs)
    ox.save_graphml(G, path)
    print(f"  saved {len(G.nodes):,} nodes to {path.name} "
          f"({path.stat().st_size / 1e6:.1f} MB)")
    return G


G = cached_graph((53.4808, -2.2426), 2000, "manchester_drive.graphml")
  drive: 8,392 nodes, 19,127 edges (dropped 58 stranded of 8,450, 31 components)
  saved 8,392 nodes to manchester_drive.graphml (12.4 MB)

One caution about GraphML: all attributes become strings on reload. OSMnx converts the ones it knows about back to numbers, but a custom attribute you added will come back as text and silently break weight= in a shortest-path call.

G = ox.load_graphml(path, edge_dtypes={"my_cost": float})

For a working cache inside one project, pickling the graph is faster and preserves types exactly β€” at the cost of being unreadable by anything else and fragile across library versions.

Explanation

Why the boundary creates stranded nodes

Downloading a fixed radius cuts every street that crosses the edge. In a directed network that is worse than it sounds.

Consider a one-way loop where the outbound leg is inside the extract and the return leg runs just outside it. Inside the graph, the outbound leg exists and the return does not, so every node along it can be entered and never left. Those nodes are strongly connected to nothing.

The measured effect: 28 strongly connected components in a 2.5 km extract and 44 in a 3.5 km one. The count goes up with the extract, because a larger area has a longer boundary and therefore more places to sever a return route. What a buffer changes is not how many artefacts exist but where they are β€” outside the region you are analysing rather than inside it.

Why truncate_by_edge helps

By default OSMnx truncates the graph at the requested distance, cutting edges mid-way. truncate_by_edge=True keeps any edge with at least one endpoint inside, so a street crossing the boundary survives whole.

That preserves connectivity for routes that briefly leave the area, at the cost of a graph whose outline is ragged rather than circular. For routing, ragged and connected beats neat and severed every time.

A one-way loop whose return leg falls outside the download boundary, stranding every node on the inbound leg.
The return route exists in the real world and not in the extract. Every node on the inbound leg becomes a trap.

Why speeds are imputed and what that costs

Most OSM ways have no maxspeed tag β€” mapping it is tedious and rarely done outside major roads. ox.add_edge_speeds fills the gaps by taking, for each highway type, the mean of the tagged values in your graph, and falling back to a global default where a type has none.

That is a reasonable local estimate and it is still an estimate. The consequences:

  • Travel times are approximate. Fine for comparing routes, weak for absolute predictions.
  • They ignore congestion entirely. A 34 kph median is a free-flow figure.
  • They ignore turn delays, which in an urban network are a large fraction of journey time.

For anything where the absolute time matters, use a routing engine with a traffic model. For "which of these two routes is shorter" or "what does a 10-minute catchment look like", imputed speeds are adequate.

Why to keep the unprojected graph for routing

length is stored in metres regardless of the graph's CRS, so nx.shortest_path(G, a, b, weight="length") is correct on the EPSG:4326 graph. Projecting first changes nothing about the routing and costs a full coordinate transform.

Project when you need Euclidean geometry: nearest-neighbour snapping, buffering isochrone points, computing areas, or plotting at true scale. Many workflows keep both G and Gp = ox.project_graph(G) and pass whichever the function needs.

The one thing to avoid is computing distances yourself from node x/y on the unprojected graph β€” those are degrees, and the answer will be wrong by a latitude-dependent factor.

Edge cases or notes

  • subgraph() returns a read-only view. Call .copy() before adding attributes.
  • truncate_by_edge=True keeps boundary-crossing edges whole and is almost always what you want.
  • GraphML stringifies attributes. Pass edge_dtypes= on load, or use a pickle for an internal cache.
  • network_type="all" includes private roads and driveways, which is rarely what a routing analysis means.
  • The OSMnx cache never expires. A month-old graph is served silently β€” see OSMnx download fails, hangs or times out.
  • graph_from_place needs a polygon boundary. A place that geocodes to a point will fail or produce a tiny extract.
  • Imputed speeds are free-flow. They ignore congestion and turn delays entirely.
  • Save the graph. Rebuilding from Overpass on every run is slow and rude to a shared service.

FAQ

Which network_type should I use?

The one matching the mode of travel. They are different networks, not filters: walk has five times the nodes of drive over the same area, and no one-way restrictions.

Why should I download a bigger area than I need?

Every street crossing the boundary is severed, so routes that leave and re-enter the area fail. A buffer moves those artefacts outside the part you are analysing.

What does truncate_by_edge do?

Keeps edges with at least one endpoint inside the requested area rather than cutting them at the boundary. It preserves connectivity at the cost of a ragged outline.

Do I need to project the graph?

Not for routing β€” length is already in metres. Project for buffering, areas, nearest-neighbour snapping or plotting to scale.

Why does my graph have 28 strongly connected components?

One-way streets plus the download boundary. Restrict to the largest component before routing, and use a buffer to reduce the artefacts.

Are the travel times realistic?

They are free-flow estimates from imputed speeds, ignoring congestion and turn delays. Good for comparing routes, weak for predicting an absolute journey time.

How should I save a graph?

ox.save_graphml for portability, remembering that attributes come back as strings unless you pass edge_dtypes. A pickle is faster for an internal cache and fragile across versions.