How to Build a Street Network Graph in Python with OSMnx

On this page

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 β€” 5,018 nodes for driving, 26,150 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")
5,018 nodes, 11,316 edges, epsg:4326
strongly connected components: 28
routable subgraph: 4,985 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 and what each controls. only one of these four has a safe default1how the area is defined β€” point, place orpolygonwhich streets exist2network_type β€” drive, walk, bike, alla different network3simplify=Truesafe default4truncate_by_edge + a bufferboundary artefactsThen restrict to the largest strongly connected component spatialworkflow.io
Only the third has a safe default. The other three change the answer.

Network type, a bigger download, the largest component, speeds

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 :  5,018 nodes, 11,316 edges,  28 strong components
walk  : 26,150 nodes, 68,244 edges,   1 strong components
bike  : 14,532 nodes, 32,833 edges,  75 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: nearly three times the nodes of the drive network and 75 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 39 stranded nodes (0.4%)

Those 39 nodes are places you can drive into and not out of, or the reverse. Left in the graph, a route to or from one of them can 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 area. same 2.5 km of ground, three different networksnodesedgesstrong componentsdriveβœ“5,01811,316!28bike14,53232,833βœ•75walk26,15068,244βœ“1Cycle networks fragment worst β€” contraflow lanes and one-way links spatialworkflow.io
Same ground, three networks. The walk graph has five times the nodes and no one-way traps.

A build function and a Piccadilly ward polygon

The data used on this page

OpenStreetMap through OSMnx, centred on Manchester Town Hall, and the ONS December 2024 boundary of Manchester's Piccadilly ward for Example 2. The runs used the maps.mail.ru Overpass mirror, saved on 9 October 2026; its status page is not one OSMnx can parse, so OSMnx's slot check is off.

import io
import os
import zipfile

import geopandas as gpd
import networkx as nx
import numpy as np
import osmnx as ox
import requests

# OpenStreetMap as Overpass returned it on 9 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-build-street-network-graph-python-2026-10-09.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)                               # Manchester Town Hall

ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"
reply = requests.get(f"{ONS}/Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query", timeout=300,
                     params={"where": "WD24NM='Piccadilly' AND LAD24NM='Manchester'",
                             "outFields": "WD24CD,WD24NM", "outSR": 4326, "f": "geojson"})
gpd.read_file(io.BytesIO(reply.content)).to_file("wards.gpkg")
print(ox.__version__, gpd.read_file("wards.gpkg")[["WD24CD", "WD24NM"]].to_dict("records"))
2.1.1 [{'WD24CD': 'E05011376', 'WD24NM': 'Piccadilly'}]

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: 9,023 nodes, 20,789 edges (dropped 52 stranded of 9,075, 43 components)
  edge length median 53 m, total 1,406 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 5,018 to 9,023 β€” 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 43, 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")                       # Manchester's Piccadilly ward
G, study_nodes = graph_for_area(ward.geometry, buffer_m=1500)
  3,783 nodes total, 461 inside the study area (12%)

Twelve percent inside. The other 88% 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: 9,023 nodes, 20,789 edges (dropped 52 stranded of 9,075, 43 components)
  edge length median 53 m, total 1,406 km
  speeds 8-64 kph, median 34
  saved 9,023 nodes to manchester_drive.graphml (12.9 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.

for u, v, k, data in G.edges(keys=True, data=True):
    data["my_cost"] = data["length"] * 1.5                  # a custom weight
ox.save_graphml(G, "with_cost.graphml")

plain = ox.load_graphml("with_cost.graphml")
typed = ox.load_graphml("with_cost.graphml", edge_dtypes={"my_cost": float})
edge = next(iter(G.edges(keys=True)))
print("reloaded as", type(plain.edges[edge]["my_cost"]).__name__,
      "| with edge_dtypes:", type(typed.edges[edge]["my_cost"]).__name__)
reloaded as str | with edge_dtypes: 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.

Boundary strandings, truncate_by_edge and imputed speeds

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 43 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 the nodes on the inbound leg. the return route exists in the world, not in the extractthe real street networkdownload boundarya one-way loop, fully drivablethe extracted graphno way back β€” 3 stranded nodesreturn leg severedDownload a buffer so these artefacts fall outside the study area spatialworkflow.io
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.

Read-only subgraphs, GraphML types and the cache

  • 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.