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
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:
bboxis now a single tuple in(left, bottom, right, top)order — west, south, east, north. The 1.x signature took separatenorth,south,east,westarguments, so a mechanical port silently queries the wrong rectangle (or an empty one).network_typevalues were renamed. The old"all_private"is now"all", and the old"all"is now"all_public". Existing"all"code therefore starts pulling in private-access ways.- Modules moved.
ox.utils_graph.graph_to_gdfs→ox.convert.graph_to_gdfs;ox.speed.add_edge_speeds→ox.routing.add_edge_speeds;ox.distance.shortest_path→ox.routing.shortest_path. - Nearly every optional argument must now be passed by name.
ox.graph_from_place(place, "drive")raisesTypeError; writenetwork_type="drive".
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.
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.
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.
- Persist the graph itself with GraphML.
ox.io.save_graphml(G, "city_drive.graphml")andox.io.load_graphml(...)round-trip the full topology and the attribute types — floats come back as floats,onewayas a bool,osmidlists as lists — because OSMnx writes its own type declarations. This is the only format that preserves the graph losslessly. - Persist the tables with GeoPackage or GeoParquet.
ox.io.save_graph_geopackage(G, "city_streets.gpkg")writesnodesandedgeslayers readable by QGIS and any OGR client. For analytical storage, takegraph_to_gdfsand write GeoParquet instead, following Cloud-Native Geospatial Formats — the edge frame compresses well and its(u, v, key)index survives as columns. - Land the edges in PostGIS with
edges.reset_index().to_postgis("street_edges", engine)when other services need indexed access; see PostGIS Integration with Python. If the routing itself belongs in the database rather than in Python, the trade-offs are laid out in OSMnx vs pgRouting for network analysis. - Publish routes and catchments to a web map. Reproject the route GeoDataFrame back to EPSG:4326 at the very last step and hand it to Interactive Maps with Folium, or tile the edge layer for a vector basemap.
- Cache aggressively in CI. Keep
ox.settings.use_cache = Trueand mount the cache folder as a build cache; a test suite that re-queries Overpass on every run will eventually be throttled and will fail for reasons unrelated to your code.
Windows / Platform Edge Cases & Debugging
shortest_pathreturnsNone. The destination is unreachable from the origin given edge directions. Runox.truncate.largest_component(G, strongly=True)before routing, and confirm withnx.number_strongly_connected_components(G).ImportError: scipy must be installed as an optional dependency to search a projected graph.nearest_nodesneeds SciPy for the KD-tree on projected graphs; on unprojected graphs it needs scikit-learn for the haversine BallTree. Install SciPy and project.TypeError: graph_from_place() takes 1 positional argument. OSMnx 2.x requires optional arguments to be passed by name; writenetwork_type="drive", not a bare"drive".- An empty or wrongly-placed graph from
graph_from_bbox. The v2 tuple is(left, bottom, right, top)— west, south, east, north — not the v1north, south, east, westordering. GraphSimplificationError: This graph has already been simplified. The constructors simplify by default; build withsimplify=Falseif you want to control the pass yourself.UserWarning: Geometry is in a geographic CRSduring consolidation. You are consolidating an unprojected graph and the tolerance is being read as degrees. Callox.project_graphfirst, and remembertoleranceis a radius — use 5 to merge nodes within 10 m.- Overpass returns HTTP 429 or 504. Keep
ox.settings.overpass_rate_limit = True, raiserequests_timeout, and split a large area into polygon chunks rather than one giant bounding box. - Windows cache path errors. Set
ox.settings.cache_folderwith forward slashes or apathlib.Path; a backslash literal in a plain string is read as an escape sequence, and deeply nested project paths can also trip the 260-character path limit. travel_timemissing on some edges.add_edge_travel_timesrequires non-nulllengthandspeed_kpheverywhere; if a highway class had nomaxspeedanywhere in the extract, supplyfallback=toadd_edge_speeds.- Attributes look like strings after reloading. Only
load_graphmlrestores OSMnx's own type declarations — a graph written with plainnx.write_graphmlcomes back with every attribute stringified.
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.