Downloading OSM Street Networks with OSMnx
Every street-network analysis begins with one decision that is easy to make badly: how you describe the area you want. This guide is for anyone acquiring a routable OpenStreetMap graph in Python and needing it to be the same graph tomorrow — it sits under Street Network Analysis with OSMnx in Spatial Analysis & Advanced Query Techniques, and covers the acquisition half of the workflow that shortest path routing with NetworkX and OSMnx picks up from.
Why This Approach / What Goes Wrong
ox.graph_from_place("Springfield, USA", network_type="drive") is not a download — it is three chained operations pretending to be one. A name goes to Nominatim, whichever candidate ranks first becomes a boundary polygon, that polygon is serialised into an Overpass QL query, and the response is parsed into a MultiDiGraph. Each hop can silently return something defensible but wrong: the wrong Springfield, a point result with no polygon, an extent so large the Overpass instance refuses it.
Three failures dominate in practice. The first is boundary semantics. A bounding box cuts edges wherever the frame falls, leaving stub nodes with a single connection; a place polygon clips to an administrative boundary that may exclude the ring road your trips actually use. The truncate_by_edge argument decides whether a node just outside the extent is kept because its edge starts inside — and if you are going to stitch several downloads together, the answer must be yes on every one of them.
The second is that this is a live query against volunteer-run infrastructure. Overpass has no service-level agreement, and an unthrottled loop over a few hundred neighbourhoods will earn an HTTP 429 or a 504 partway through, leaving you with a half-built graph and no way to tell which tiles are missing. Caching is not an optimisation here; it is what makes the pipeline re-runnable at all.
The third is reproducibility. OSM changes hourly, so the identical script run six months apart legitimately returns different node counts. Anything you intend to defend later needs the extent pinned to a geometry you control rather than a name you hope resolves the same way.
Prerequisites
osmnx>=2.1,<3— the v2 API, whereox.graph,ox.ioandox.settingsare separate submodules and every optional argument must be passed by namegeopandas>=1.0— backsgeocode_to_gdfand the GeoPackage writernetworkx>=3.3— theMultiDiGraphcontainer andcompose_allfor stitching tilesshapely>=2.0— the polygon you hand tograph_from_polygon
conda install -c conda-forge "osmnx=2.1.*" "geopandas=1.0.*" "networkx=3.3.*" "shapely=2.0.*"
Step-by-Step Implementation
1. Identify your client and turn on the cache before the first call.
ox.settings is global state read at call time, so it must be set before any constructor runs. The cache key is a hash of the fully-built Overpass query string: change the extent, the network_type, or the Overpass server and you get a new key, but re-run the same request and the response is read straight off disk. Tag filtering happens while parsing the response rather than in the query itself, so widening useful_tags_way re-parses what is already cached instead of re-downloading it.
from pathlib import Path
import osmnx as ox
cache_dir = Path("./osm_cache") # inside the project, so CI can cache it
cache_dir.mkdir(exist_ok=True)
ox.settings.use_cache = True
ox.settings.cache_folder = cache_dir # a Path avoids Windows backslash escapes
ox.settings.log_console = True # print each Overpass call as it is made
ox.settings.useful_tags_way += ["surface", "cycleway"] # keep two extra edge tags
print(ox.__version__) # 2.1.1
2. Choose the constructor that matches how you can describe the area.
Four functions cover every case, and they differ only in how the extent is expressed. All of them accept the same downstream arguments — network_type, simplify, retain_all, truncate_by_edge, custom_filter — and all of them return a MultiDiGraph in epsg:4326.
import osmnx as ox
from shapely.geometry import box
# a) Named place: geocoded through Nominatim, then clipped to that boundary polygon
by_place = ox.graph_from_place("Piedmont, California, USA", network_type="drive")
# b) Centre point + radius; dist_type="network" trims to what is reachable,
# not to a square window around the centre
by_point = ox.graph_from_point(
(37.8244, -122.2317), dist=1500, dist_type="network", network_type="bike"
)
# c) Bounding box — the v2 tuple is (left, bottom, right, top) = (W, S, E, N)
by_bbox = ox.graph_from_bbox(
bbox=(-122.262, 37.816, -122.202, 37.844), network_type="drive"
)
# d) Any Shapely polygon you already hold, in lon/lat order
study_area = box(-122.262, 37.816, -122.202, 37.844)
by_polygon = ox.graph_from_polygon(study_area, network_type="drive", truncate_by_edge=True)
3. Resolve an ambiguous place name before you download anything.
graph_from_place hides the geocoding step, which is exactly why it surprises people. Do the geocode yourself, look at what came back, then hand the geometry to graph_from_polygon. A structured dict removes most ambiguity because each component is matched against a specific Nominatim field; which_result picks a different candidate by rank when it does not; and by_osmid=True addresses one OSM relation directly, which is the only variant that cannot drift.
import osmnx as ox
# Structured queries beat free text: each key maps to a Nominatim field
boundary = ox.geocode_to_gdf({"city": "Springfield", "state": "Illinois", "country": "USA"})
print(boundary["display_name"].iloc[0])
print(boundary.geometry.iloc[0].geom_type) # Polygon or MultiPolygon, never Point
# Free text with the wrong winner? Take the second candidate instead:
# ox.geocode_to_gdf("Springfield, USA", which_result=2)
# Pin it permanently by relation id (R = relation, W = way, N = node):
# ox.geocode_to_gdf("R122604", by_osmid=True)
city_walk = ox.graph_from_polygon(
boundary.geometry.iloc[0], network_type="walk", truncate_by_edge=True
)
If geocode_to_gdf raises because the top result is a Point, the place has a node in OSM but no boundary relation — there is nothing to clip to, so fall back to graph_from_point with an explicit radius rather than fighting the geocoder.
4. Pick network_type deliberately, and drop to custom_filter when none of them fit.
The six named types — "all", "all_public", "bike", "drive", "drive_service", "walk" — are shorthand for Overpass way filters, and the choice changes both the size of the download and the meaning of the result. "drive" excludes service roads and alleys; "drive_service" adds them back; "all_public" drops private access ways that "all" keeps. Anything outside that vocabulary goes through custom_filter, which takes raw Overpass QL and supersedes network_type entirely.
# Pedestrian network: every segment is traversable both ways regardless of oneway tags
walkable = ox.graph_from_point((45.4642, 9.1900), dist=800, network_type="walk")
# Not a road network at all — custom_filter takes over from network_type
tram_lines = ox.graph_from_point(
(45.4642, 9.1900),
dist=800,
custom_filter='["railway"~"tram"]',
retain_all=True, # a tram network is legitimately disconnected
)
print(walkable.number_of_edges(), tram_lines.number_of_edges())
retain_all=True matters here: the default keeps only the largest connected component, which on a sparse custom network can silently discard most of what you asked for.
5. Understand what simplify=True actually removes.
Simplification is on by default in every constructor. It deletes nodes that are not intersections, merges the run of segments between two real intersections into a single edge, sums the length, collects the osmid values into a list, and stores the traced shape as the edge's geometry. It is not a geometric generalisation — no coordinate is discarded — and it is not reversible, so anything needing per-vertex granularity (map matching, elevation sampling along a way) must ask for the raw topology at construction time.
import osmnx as ox
raw = ox.graph_from_point(
(45.4642, 9.1900), dist=600, network_type="drive", simplify=False
)
print(raw.number_of_nodes(), raw.number_of_edges(), raw.graph["simplified"])
# 2431 5106 False (indicative counts for this extent)
clean = ox.simplify_graph(raw, edge_attrs_differ=["highway"])
print(clean.number_of_nodes(), clean.number_of_edges(), clean.graph["simplified"])
# 612 1394 True
_, _, attrs = list(clean.edges(data=True))[0]
print(type(attrs["osmid"]).__name__, round(attrs["length"], 1), "geometry" in attrs)
# list 214.7 True
6. Persist the graph so the next run never queries Overpass.
GraphML is the only lossless option: OSMnx writes its own type declarations, so lists stay lists and booleans stay booleans on reload. GeoPackage is for handing the result to QGIS or PostGIS; pass directed=True or reciprocal one-way edges collapse into single lines.
import osmnx as ox
ox.io.save_graphml(clean, filepath="milan_drive.graphml")
reloaded = ox.io.load_graphml("milan_drive.graphml")
assert reloaded.graph["crs"] == clean.graph["crs"]
assert reloaded.number_of_edges() == clean.number_of_edges()
# Two layers, "nodes" and "edges", readable by any OGR client
ox.io.save_graph_geopackage(clean, filepath="milan_drive.gpkg", directed=True)
For analytical storage rather than reload, convert with ox.convert.graph_to_gdfs and write GeoParquet following Cloud-Native Geospatial Formats — the edge frame compresses well and its (u, v, key) index survives as ordinary columns.
7. Split a large extent instead of asking for it in one request.
Above ox.settings.max_query_area_size OSMnx subdivides a request automatically, but a region-scale query still tends to hit the server's own timeout. Tiling the polygon yourself gives you per-tile progress, a per-tile cache entry, and a failure you can resume. truncate_by_edge=True on every tile keeps the nodes just past each seam so the pieces reconnect when composed.
import networkx as nx
import osmnx as ox
from shapely.geometry import box
def tile_polygon(poly, step=0.05):
"""Cover a lon/lat polygon with ~step-degree tiles, clipped to its shape."""
west, south, east, north = poly.bounds
tiles, y = [], south
while y < north:
x = west
while x < east:
cell = box(x, y, min(x + step, east), min(y + step, north)).intersection(poly)
if not cell.is_empty:
tiles.append(cell)
x += step
y += step
return tiles
region = ox.geocode_to_gdf("Rhode Island, USA").geometry.iloc[0]
pieces = []
for i, tile in enumerate(tile_polygon(region, step=0.05), start=1):
part = ox.graph_from_polygon(tile, network_type="drive", truncate_by_edge=True)
pieces.append(part)
print(f"tile {i}: {part.number_of_nodes()} nodes") # resumable, one cache file each
region_graph = nx.compose_all(pieces) # OSM node ids are global, so shared nodes merge
print(region_graph.number_of_nodes(), region_graph.graph["crs"])
Verification
A downloaded graph should be checked for three things before anything is computed on it: the CRS and simplification flags are what you expect, the geometry actually falls inside the area you asked for, and a second identical call is served from disk.
import time
import osmnx as ox
assert by_polygon.graph["crs"] == "epsg:4326" # OSMnx always downloads in lon/lat
assert by_polygon.graph["simplified"] is True
assert by_polygon.number_of_nodes() > 0, "Empty graph — check bbox order or custom_filter"
nodes, edges = ox.convert.graph_to_gdfs(by_polygon)
inside = nodes.within(study_area).mean()
print(f"{inside:.1%} of nodes inside the requested polygon")
# 97.8% of nodes inside the requested polygon <- overhang from truncate_by_edge
assert edges["length"].min() > 0
assert edges["length"].sum() > 0
t0 = time.perf_counter()
ox.graph_from_polygon(study_area, network_type="drive", truncate_by_edge=True)
print(f"cached rebuild: {time.perf_counter() - t0:.2f}s") # cached rebuild: 0.41s
print(len(list(cache_dir.glob("*.json"))), "cached responses")
Reprojection is a separate step, not part of the download: everything above is in geographic degrees, and any length, buffer or tolerance you apply must come after ox.project_graph, using the datum-aware machinery described in Coordinate Systems with PyProj.
Edge Cases & Debugging
InsufficientResponseErrorfrom a valid-looking area. Overpass returned no ways matching the filter — usually anetwork_typetoo narrow for a rural extent, or acustom_filterregex that matches nothing. Retry withnetwork_type="all"to confirm the extent is right before blaming the query.- The graph is offset or empty from
graph_from_bbox. The v2 tuple is(left, bottom, right, top). Passing the v1north, south, east, westordering produces a degenerate or mirrored box rather than an error. ValueErrorfromgeocode_to_gdf: no polygon result. The top Nominatim candidate is a node, not a boundary relation. Raisewhich_result, use a structured dict, or switch tograph_from_point.- Repeated HTTP 429 or a stalled loop. Leave
ox.settings.overpass_rate_limit = Trueon, raiseox.settings.requests_timeout, and tile as in step 7. To warm a CI cache without building graphs, setox.settings.cache_only_mode = True— each call downloads, caches, then raisesCacheOnlyInterruptErrorinstead of parsing. GraphSimplificationErroronsimplify_graph. The graph is already simplified, because the constructors do it by default. Passsimplify=Falsewhen downloading if you intend to simplify with custom arguments later.- Attributes come back as strings after
load_graphml. You edited the file by hand or wrote it with plain NetworkX. Always round-trip throughox.io.save_graphml/ox.io.load_graphml, which carry the dtype declarations.
Frequently Asked Questions
Can I download a snapshot of the network as it existed on a past date?
Yes, by pinning the date into the Overpass query header via ox.settings.overpass_settings before the first call. That is the only way to make node and edge counts reproducible across months, because the live database changes hourly. Cache the responses alongside the pinned date and treat the cache folder as part of the analysis artefact.
Do I need OSMnx at all if I only want street geometry as a table? No. If you want lines and attributes rather than a routable graph, a bulk source is faster and does not touch Overpass — see streaming Overture Maps data with DuckDB, which reads the transportation theme straight from cloud storage. Reach for OSMnx when you need connectivity: node ids, edge direction, and a graph you can route or measure centrality on.
How do I keep the graph inside a boundary that has holes or multiple parts?
graph_from_polygon accepts a MultiPolygon directly, so dissolve your boundary features first and pass the result. Islands and enclaves are preserved, but each disjoint part is its own connected component — keep retain_all=True or the smaller parts vanish, and re-check connectivity before routing.
Is dist_type="network" worth the extra cost over "bbox"?
For anything catchment-shaped, yes. "bbox" returns a square window whose corners include streets far outside any realistic reach, while "network" truncates to nodes within dist metres along the edges themselves — a smaller, more honest extract, and the natural starting point for building isochrones from a street network.