Street Network Analysis with OSMnx: Routable Graphs from OpenStreetMap

A street network is not a layer of lines — it is a graph, and treating it as one is what separates "how far is the hospital" from "how long does it take to drive there". OSMnx downloads OpenStreetMap road data and hands back a networkx.MultiDiGraph whose nodes are intersections and whose edges are street segments carrying length, speed, and geometry, ready for shortest paths, service areas, and centrality. This stage of Spatial Analysis & Advanced Query Techniques covers the OSMnx 2.x API surface end to end: graph construction, the simplification and consolidation passes that decide what an "intersection" even means, projection to a metric CRS, and the round trip between the graph and a pair of GeoDataFrames. It borrows the point-matching machinery described in Nearest Neighbor & KD-Tree Search to snap origins onto the network, and it produces catchment areas that behave very differently from the circles in Proximity & Buffer Analysis — a 10-minute drive is a ragged star, not a disc.

Architecture & Data Structures

Everything OSMnx returns is a networkx.MultiDiGraph: directed, because one-way streets are not symmetric, and multi, because two intersections can be joined by more than one distinct street. A two-way street therefore appears as two reciprocal directed edges — (u, v, 0) and (v, u, 0) — while a one-way street contributes a single edge. Where two separate ways connect the same node pair (a divided road, a service loop, a bridge and the street beneath it), the third element of the edge key disambiguates them: (u, v, 0) and (u, v, 1).

Nodes are keyed by their OSM node id and carry x, y (in whatever CRS the graph is currently in), street_count — the number of physical street segments incident to the node, which is the honest degree measure once one-way pairs are collapsed — and highway when the node is tagged, for example traffic_signals. Edges carry osmid (an int, or a list of ints after simplification merges several ways), highway, name, oneway, reversed, lanes, maxspeed, and length. Two of those deserve attention. length is always in metres, computed as the great-circle distance between incident nodes before simplification and summed across the merged segments — so it is a real metric distance even while the graph is still in EPSG:4326. geometry exists only on simplified edges whose true shape is curved; for straight edges OSMnx reconstructs it on demand.

The practical consequence of the graph model is that adjacency, not geometry, is the primary index. A GeoDataFrame of street centrelines can tell you which segments are within 500 m of a point; only the graph can tell you which ones you can actually reach, and in what order. Every question that involves traversal — reachability, service areas, detour ratios, which streets carry through-traffic — is a graph query first and a geometry query second, and the geometry exists mainly so the answer can be drawn on a map or clipped against another layer.

OSMnx 2.x organises this into task-shaped submodules — ox.graph, ox.projection, ox.simplification, ox.truncate, ox.convert, ox.routing, ox.distance, ox.stats, ox.io, ox.plot, ox.settings — while keeping the workhorse functions aliased at the top level, so ox.graph_from_place and ox.graph.graph_from_place are the same object. The graph itself carries metadata in G.graph: the CRS, whether it has been simplified, and whether intersections have been consolidated.

import networkx as nx
import osmnx as ox

ox.settings.use_cache = True          # never re-download the same query
ox.settings.cache_folder = "./osm_cache"

street_graph = ox.graph_from_point(
    (45.0703, 7.6869), dist=700, dist_type="bbox", network_type="drive"
)

print(type(street_graph))   # <class 'networkx.classes.multidigraph.MultiDiGraph'>
print(street_graph.graph)   # {'crs': 'epsg:4326', 'simplified': True, ...}
print(street_graph.number_of_nodes(), street_graph.number_of_edges())  # 293 518

u, v, key, attrs = next(iter(street_graph.edges(keys=True, data=True)))
print(attrs["length"], attrs["oneway"], attrs["highway"])
# 24.716911102513790 False tertiary
Anatomy of an OSMnx MultiDiGraph fragment Three intersection nodes keyed by OSM node id. Nodes A and B are joined by a two-way street, drawn as two reciprocal directed edges pointing in opposite directions. Nodes B and C are joined by a one-way street, drawn as a single directed edge with oneway set to True and no reciprocal partner. A side panel lists a real edge attribute dictionary containing osmid, highway, name, oneway, reversed, length in metres, speed_kph and travel_time in seconds. A lower panel shows the graph-level attributes crs and simplified, and notes that the edge key disambiguates parallel edges. A two-way street is two reciprocal edges; a one-way street is one A B C oneway=False oneway=True no reciprocal edge osmid 249665743 osmid 13924328 osmid 452404562 node attrs: x, y, street_count G.graph {'crs': 'epsg:4326', 'simplified': True} key disambiguates parallel edges: (u, v, 0) and (u, v, 1) length is metres even while the graph is in EPSG:4326 edge attribute dict osmid: 132419478 highway: 'tertiary' name: 'Lungo Dora' oneway: False reversed: False length: 24.72 speed_kph: 30.2 travel_time: 2.94 length in metres, travel_time in seconds
Directionality lives in the edge set, not in an attribute — oneway=True means there is simply no edge in the opposite direction to traverse.

Environment Configuration & Dependency Resolution

python -m pip install "osmnx>=2.1,<3" "networkx>=3.3" "geopandas>=1.0" "scipy>=1.11"
# optional: scikit-learn (nearest-node search on unprojected graphs), matplotlib (plotting)

OSMnx 2.x has a deliberately thin required dependency set — GeoPandas, NetworkX, NumPy, pandas, requests, Shapely — and pushes the rest into optional extras. That matters in practice because the missing extra surfaces as a runtime ImportError deep inside a call you thought was pure graph work: ox.distance.nearest_nodes builds a scipy.spatial.cKDTree when the graph is projected and a sklearn.neighbors.BallTree with a haversine metric when it is not, so a projected pipeline needs SciPy and an unprojected one needs scikit-learn. Install SciPy and project the graph; it is both faster and the correct habit.

If you are porting code written against OSMnx 1.x, four breaking changes account for nearly every failure:

Configuration is global state on ox.settings, applied to every subsequent call. Set it once at import time, before any download:

import osmnx as ox

ox.settings.use_cache = True                 # cache Overpass responses on disk
ox.settings.cache_folder = "./osm_cache"     # keep it inside the project, not /tmp
ox.settings.requests_timeout = 300           # large city extracts need headroom
ox.settings.overpass_rate_limit = True       # pause instead of hammering the server
ox.settings.http_user_agent = "network-pipeline/1.0 (data@example.org)"

# Reproducible builds: pin the OSM snapshot date into the Overpass query itself
ox.settings.overpass_settings = '[out:json][timeout:{timeout}]{maxsize}[date:"2026-01-01T00:00:00Z"]'

print(ox.__version__)   # 2.1.1

Caching is not a nicety. Overpass is a shared volunteer service that will rate-limit or drop you, and a cached run is the difference between a two-minute test loop and a two-hour one. Commit the cache folder to your artifact store, not to git.

Two settings matter specifically for large extracts. ox.settings.max_query_area_size controls the threshold above which OSMnx automatically subdivides a request into a grid of smaller Overpass queries and stitches the results back together — the default is generous, but a country-scale bounding box will still time out, and you are better off looping over administrative polygons yourself. ox.settings.overpass_memory sets the maxsize hint in the query header; raise it only when the server reports it needs more, because an over-large request is more likely to be rejected outright. And because overpass_settings accepts a [date:...] clause, pinning that date is the only way to make a street-network analysis reproducible: OSM changes daily, and an unpinned rebuild six months later will legitimately return different node counts, different names, and a different answer.

Vectorized Operations & Core Workflow

Four constructors cover every acquisition case, and they differ only in how the query area is described: graph_from_place geocodes a name or a structured dict through Nominatim and clips to the resulting boundary polygon; graph_from_point takes a (lat, lon) centre plus a dist in metres; graph_from_bbox takes the (left, bottom, right, top) tuple; and graph_from_polygon takes any Shapely Polygon or MultiPolygon, which is how you drive the download from a boundary you already hold in a GeoDataFrame. The mechanics of each — including which_result for ambiguous place names and custom_filter for raw Overpass QL — are worked through in downloading OSM street networks with OSMnx.

The network_type argument is the single most consequential choice, because it decides which OSM ways enter the graph and whether edges come back bidirectional. Only "walk" is in ox.settings.bidirectional_network_types, so a walking graph traverses every segment in both directions regardless of the oneway tag; a bike graph does not, and will happily refuse to route the wrong way up a one-way street that a cyclist may legally use via oneway:bicycle=no.

What each OSMnx network_type includes A four-column comparison of the network_type values drive, walk, bike and all. Drive includes public drivable streets, excludes service roads, footways and paths, produces directed edges honouring one-ways, and suits car routing. Walk includes footways, steps and alleys, excludes motorways and cycleways, produces bidirectional edges, and suits isochrones and walkability. Bike includes cycleways and shared roads, excludes footways and motorways, produces directed edges where car one-ways still apply, and suits cycling accessibility. All includes every highway-tagged way including private access, excludes only areas and rest areas, produces the largest directed graph, and suits audits and custom filters. network_type decides both the ways and the directionality Axis drive walk bike all Includes public drivable streets footways, steps, alleys, aisles cycleways and shared roads every highway=* incl. private access Excludes service roads, footways, paths motorways, cycleways, foot=no footways, motorways areas, rest areas, services Edge direction directed; one-ways honoured bidirectional one-ways ignored directed; car one-ways apply directed; largest graph Use it for drive-time routing walk isochrones cycle accessibility audits, custom filters "all_public" is "all" minus private-access ways · "drive_service" is "drive" plus service roads
Pick the network_type that matches the mode you are modelling — a walk graph is bidirectional by design, a bike graph is not.

The canonical pipeline is five steps: download, keep a routable component, project, impute speeds and travel times, then route. Every step below runs as written:

import networkx as nx
import osmnx as ox

ox.settings.use_cache = True

# 1. Download a drivable network for a named place (geocoded, clipped to the polygon)
streets = ox.graph_from_place("Piedmont, California, USA", network_type="drive")

# 2. Keep the largest STRONGLY connected component — every node reachable from
#    every other, which is what routing actually requires on a directed graph
streets = ox.truncate.largest_component(streets, strongly=True)

# 3. Project to the UTM zone of the graph's centroid (metres, not degrees)
streets = ox.project_graph(streets)

# 4. Impute free-flow speeds from maxspeed tags, then derive travel times
streets = ox.routing.add_edge_speeds(
    streets,
    hwy_speeds={"residential": 30, "tertiary": 40, "secondary": 50},
    fallback=25,
)
streets = ox.routing.add_edge_travel_times(streets)

# 5. Snap two coordinates (easting, northing in the projected CRS) to nodes and route
origin = ox.distance.nearest_nodes(streets, X=566_500, Y=4_185_800)
destination = ox.distance.nearest_nodes(streets, X=568_800, Y=4_187_100)
route = ox.routing.shortest_path(streets, origin, destination, weight="travel_time")

trip = ox.routing.route_to_gdf(streets, route, weight="travel_time")
print(f"{trip['length'].sum():.0f} m in {trip['travel_time'].sum():.0f} s")
# 2523 m in 262 s

add_edge_speeds computes, per highway class, the mean of the maxspeed values actually present on that class in this graph, and applies it to every edge of that class that lacks one; classes with no observed value anywhere fall back to the graph-wide mean, or to your fallback if you supply one. It also converts values tagged mph, but nothing else — a maxspeed in knots or with a zone: prefix needs cleaning first. add_edge_travel_times then divides length by speed_kph and writes travel_time in seconds; it raises if any edge is missing either input. route_to_gdf returns the ordered edge GeoDataFrame for the route, which is how you get a defensible total rather than re-deriving one from node pairs. Multi-origin routing, k_shortest_paths, and the cpus argument for parallel batches are covered in shortest path routing with NetworkX and OSMnx.

Graph Simplification & Topology Details

Raw OSM ways are drawn with a vertex wherever the surveyor needed one to trace a curve. Imported verbatim, a gentle bend becomes a chain of degree-2 nodes that are not intersections at all, inflating node counts and slowing every traversal. simplify_graph — which the graph_from_* constructors run by default, hence G.graph["simplified"] == True — removes every non-intersection node, merges the segments between two real intersections into one edge, sums their length, collects their osmid values into a list, and stores the true curved shape in the edge's geometry attribute. Nothing spatial is lost; only the node inventory shrinks.

Simplification is one-way. Calling ox.simplify_graph on an already-simplified graph raises GraphSimplificationError, so if you need the raw topology — for map matching, or to attach per-vertex elevation — pass simplify=False at construction time and simplify later. edge_attrs_differ=["highway"] prevents merging across a change in road class, and node_attrs_include=["traffic_signals"] protects tagged nodes from removal.

Consolidation is the second, less obvious pass. A divided boulevard crossing another divided boulevard produces four separate OSM nodes where a human sees one intersection; a roundabout produces a ring of them. consolidate_intersections buffers each node, merges the clusters, and — with rebuild_graph=True — rebuilds the graph with one node per cluster, reconnecting edge geometries. Two details bite people. First, the graph must already be projected, because tolerance is expressed in CRS units; run it on a lat-lon graph and Shapely warns about buffering a geographic CRS while you silently consolidate at a tolerance of ten degrees. Second, tolerance is a per-node buffer radius, so merging nodes within 10 m of each other means tolerance=5. The rebuilt graph relabels nodes with cluster integers and preserves the originals in osmid_original, plus u_original/v_original on edges.

Raw geometry, after simplification, after intersection consolidation Three stages side by side. Stage one, the raw graph, shows a curved street carrying many small interstitial vertex nodes. Stage two, after simplify_graph, shows the same curve with only its two endpoint intersection nodes; the shape now lives in the edge geometry attribute. Stage three, after consolidate_intersections, shows four separate nodes of a divided-road crossing enclosed in a dashed tolerance circle collapsing into a single consolidated node. A summary bar states that node counts fall at every stage while edge lengths and geometry are preserved. 1 · raw (simplify=False) every OSM way vertex is a node degree-2 nodes are not intersections 2 · simplify_graph() shape kept in edge geometry one edge, summed length, osmid becomes a list 3 · consolidate (tol=5) 4 divided-road nodes → 1 originals kept in osmid_original requires a projected graph Node counts fall at every stage; edge lengths and true geometry are preserved throughout
Simplification collapses vertices that were never intersections; consolidation collapses intersections that OSM records as several nodes.
import osmnx as ox

grid = ox.graph_from_point((45.0703, 7.6869), dist=700, network_type="drive")
grid = ox.project_graph(grid)                    # MUST be projected first

merged = ox.consolidate_intersections(
    grid, tolerance=5, rebuild_graph=True, dead_ends=False
)
print(grid.number_of_nodes(), "->", merged.number_of_nodes())   # 293 -> 224
print(merged.graph["consolidated"])                             # True

# Nodes are relabelled by cluster; the OSM ids they replaced are kept
clusters = {n: a["osmid_original"] for n, a in merged.nodes(data=True)
            if isinstance(a["osmid_original"], list)}
print(len(clusters))                       # 27 clusters actually merged
print(next(iter(clusters.items())))        # (37, [246508609, 6047914118])

Connectivity is the other topology trap. retain_all=False (the default) keeps the largest weakly connected component, which ignores edge direction — so a cul-de-sac reachable only by driving the wrong way up a one-way street stays in the graph and quietly makes shortest_path return None. ox.truncate.largest_component(G, strongly=True) is the routing-safe filter; on a small extract it can discard more than half the nodes, which is a feature, not a bug.

With a clean graph in hand, the NetworkX algorithm library applies directly. nx.ego_graph(G, node, radius=600, distance="travel_time") returns the subgraph reachable within 600 seconds — the basis for the service areas built in building isochrones from a street network. Centrality needs a simple DiGraph first, because most NetworkX centrality functions do not accept parallel edges; ox.convert.to_digraph collapses each parallel bundle by keeping the minimum-weight edge:

import networkx as nx
import osmnx as ox

city = ox.graph_from_place("Piedmont, California, USA", network_type="drive")
routable = ox.truncate.largest_component(ox.project_graph(city), strongly=True)
routable = ox.routing.add_edge_travel_times(ox.routing.add_edge_speeds(routable))

# 5-minute drive-time catchment around one node
centre = ox.distance.nearest_nodes(routable, X=567_800, Y=4_186_400)
catchment = nx.ego_graph(routable, centre, radius=300, distance="travel_time")
print(catchment.number_of_nodes(), "of", routable.number_of_nodes())  # 347 of 349

# Betweenness on a DiGraph; sample k <= n sources to bound runtime on big graphs
simple = ox.convert.to_digraph(routable, weight="travel_time")
betweenness = nx.betweenness_centrality(simple, weight="travel_time", k=200, seed=7)
busiest = max(betweenness, key=betweenness.get)

stats = ox.stats.basic_stats(routable)
print(stats["intersection_count"], round(stats["circuity_avg"], 3))  # 315 1.112

basic_stats returns the standard morphology measures — k_avg, streets_per_node_avg, street_length_total, intersection_count, circuity_avg (network distance over straight-line distance, so 1.02 means a near-grid) and self_loop_proportion. Pass area in square metres to get the density measures as well.

CRS Alignment & Projection Pipeline

Every graph arrives in epsg:4326, and G.graph["crs"] is the authority for what the x/y node attributes mean. Because OSMnx computes length geodesically at construction, distance-weighted routing is already correct in the unprojected graph — but nothing else is. Node coordinates are degrees, so consolidate_intersections tolerances, nearest_nodes KD-tree distances, buffer radii, and any area you derive from an isochrone hull are all wrong until you project.

ox.project_graph(G) with no arguments picks the UTM zone containing the graph's centroid and reprojects both node coordinates and edge geometries, updating G.graph["crs"]. That automatic choice is right for a city-scale extract and wrong for anything spanning a zone boundary, where you should pass to_crs= explicitly — the selection logic is the same one described in Coordinate Systems with PyProj. Never project a street network to EPSG:3857 for analysis: its scale factor grows with latitude, so travel times computed from Web Mercator lengths are inflated by roughly 40% at 45° north.

import osmnx as ox

city = ox.graph_from_place("Piedmont, California, USA", network_type="drive")
print(city.graph["crs"])                       # epsg:4326

city_utm = ox.project_graph(city)              # auto: UTM zone of the centroid
print(city_utm.graph["crs"])                   # EPSG:32610

# Or pin the CRS explicitly for a multi-zone or standards-mandated extent
city_fixed = ox.project_graph(city, to_crs="EPSG:32610")
assert city_fixed.graph["crs"].is_projected    # guard before any metric step

# Round trip: graph -> GeoDataFrames -> graph, CRS carried both ways
nodes, edges = ox.convert.graph_to_gdfs(city_utm)
print(nodes.index.name, list(edges.index.names))   # osmid ['u', 'v', 'key']
print(nodes.crs == edges.crs == city_utm.graph["crs"])   # True

edges["minutes"] = edges["length"] / 1000 / 30 * 60      # custom weight column
rebuilt = ox.convert.graph_from_gdfs(nodes, edges, graph_attrs=city_utm.graph)

That round trip is the escape hatch for everything OSMnx does not do natively. graph_to_gdfs gives you a point GeoDataFrame indexed by osmid and a line GeoDataFrame indexed by the (u, v, key) MultiIndex, both carrying the graph's CRS; you can then join land-use attributes, clip against a boundary, filter by an attribute, or compute a custom cost column with ordinary GeoPandas, and graph_from_gdfs reassembles a routable graph. Always pass graph_attrs= — without it the rebuilt graph loses its CRS, and the next project_graph call fails or, worse, reprojects from an assumed EPSG:4326 that is no longer true.

Production Export & Integration

Street graphs are expensive to acquire and cheap to store, so the production pattern is to build once, persist, and let the analysis jobs read from your own storage instead of Overpass.

From Overpass download to published analysis outputs A left-to-right flow. OpenStreetMap data arrives through the Overpass API into a disk cache, becomes a MultiDiGraph that is projected and given speed and travel-time attributes, then fans out into three analyses: shortest path routing, ego-graph isochrones, and centrality. All three converge on graph_to_gdfs, which produces node and edge GeoDataFrames, which are exported to GeoPackage or GeoParquet, to PostGIS, or to a Folium or vector-tile web map. Build once, persist, then analyse from your own storage OpenStreetMap Overpass API disk cache MultiDiGraph project_graph() speed_kph, travel_time shortest_path() route + total minutes ego_graph() isochrone polygons centrality betweenness, closeness graph_to_gdfs() node + edge frames CRS carried through GraphML · GeoPackage · GeoParquet PostGIS street_edges table Folium map · vector tiles
One acquisition feeds every downstream analysis; the GeoDataFrame boundary is where the graph rejoins the rest of the geospatial stack.

Windows / Platform Edge Cases & Debugging

Frequently Asked Questions

Is OSMnx a routing engine? No, and treating it as one is the usual disappointment. It is a graph builder plus a thin convenience layer over NetworkX, so routing runs in Python at NetworkX speed and honours neither turn restrictions nor traffic signals. For a handful of routes over a city, that is fine. For millions of routes or turn-aware costs, build the graph in OSMnx for analysis and delegate the routing to a dedicated engine — the comparison against a database-side router is in OSMnx vs pgRouting.

Why is length in metres when the graph is in degrees? Because OSMnx computes it as a great-circle distance between incident node coordinates at construction time, before simplification, and sums those metres across merged segments. It is a stored attribute, not a derived geometry measurement, so it stays correct in EPSG:4326. Everything else metric — buffers, tolerances, hull areas, KD-tree distances — still requires a projected graph.

Should I consolidate intersections before or after projecting? After, always. tolerance is expressed in the graph's CRS units, so consolidation on a lat-lon graph interprets your metre value as degrees. Project first, then consolidate, and set the tolerance to roughly half the width of a divided carriageway in your study area.

How do I route on a custom cost instead of distance or time? Write your own numeric edge attribute and pass its name as weight. The clean way is the GeoDataFrame round trip: graph_to_gdfs, compute the column with vectorized pandas — a slope penalty, a bike-comfort score, a toll cost — then graph_from_gdfs(nodes, edges, graph_attrs=G.graph) and call ox.routing.shortest_path(G, orig, dest, weight="my_cost").

How large a network fits in memory? A simplified drive network for a mid-sized European city is typically tens of thousands of nodes and comfortably under a gigabyte; a metropolitan network_type="all" graph can be an order of magnitude larger, because pedestrian and service ways dominate the way count. If a city-scale walk graph is straining, request the narrowest network_type that answers your question, cut the extent with ox.truncate.truncate_graph_polygon, and persist to GraphML so you never rebuild it.