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

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
How an OSMnx download resolves against the on-disk Overpass cache A left-to-right flow. Your graph_from_polygon call is turned into an Overpass QL query built from the network type filter and the polygon outline, and that query string is hashed into a cache key such as 8f3c dot dot dot c1 dot json. If a file with that name exists in the cache folder, the JSON is read from disk with no network call, in tens of milliseconds. If it does not, the request goes to the Overpass API with a rate-limit pause, takes seconds to minutes, and the response is written into the cache on the way past. Both branches parse the same JSON into the same MultiDiGraph, so only the wall clock differs. One Overpass call, then never again graph_from_polygon(...) your call Overpass QL built from network_type filter + polygon outline cache key = hash(query) 8f3c…c1.json file present in cache_folder? yes no cache hit · read JSON from disk no network call · tens of milliseconds cache miss · Overpass, rate-limited seconds to minutes · response written to cache Both branches parse the same JSON into the same MultiDiGraph — only the wall clock differs
The cache key is derived from the query string, so an identical request never reaches the network twice.

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)
The four OSMnx extent constructors compared A four-row comparison table. graph_from_place takes a place name or structured dict, geocodes it through Nominatim, and returns a graph clipped to whichever boundary polygon ranked first. graph_from_point takes a latitude-longitude centre plus a distance in metres and returns either a square window or everything within that distance along the network, depending on dist_type. graph_from_bbox takes a west-south-east-north tuple and cuts edges hard at the rectangular frame. graph_from_polygon takes any Shapely polygon or multipolygon in lon-lat order and returns exactly the study area you supplied, which is the only fully reproducible option. Same graph, four ways of naming the area constructor extent you supply what comes back graph_from_place geocodes via Nominatim a name or structured dict which_result picks the candidate whatever polygon Nominatim ranked first — verify it graph_from_point centre plus radius (lat, lon) and dist in metres dist_type="bbox" or "network" a square window, or all that is reachable within dist metres graph_from_bbox rectangular frame (left, bottom, right, top) v2 order: W, S, E, N a hard cut — edges are chopped wherever the frame falls graph_from_polygon the reproducible one any Shapely (Multi)Polygon EPSG:4326, lon-lat order exactly the study area you supplied, byte for byte The other three are conveniences that end up calling the fourth — hold the polygon yourself and the extent stops drifting.
Every constructor resolves to a polygon eventually; supplying that polygon directly is what makes a rebuild comparable to the original.

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
A curved OSM way before and after graph simplification Two panels showing the same bend in a road. On the left, with simplify equals False, the way is drawn as nine graph nodes joined by eight edges: two of them are real intersections at the ends and seven are interstitial vertices the surveyor added to trace the curve. On the right, with the default simplify equals True, the same bend is two intersection nodes joined by a single edge. The panel below notes that the merged edge sums the segment lengths, keeps every original OSM way id in a list, and stores the full curve in its geometry attribute, so the shape survives and only the node inventory shrinks. Interstitial vertices are not intersections simplify=False every vertex the surveyor drew becomes a node 9 nodes · 8 edges simplify=True (default) only true intersections remain in the node table 2 nodes · 1 edge length = sum of segments · osmid = [ 12, 13, 14 ] · geometry = the full curve The shape survives inside the edge; the vertices simply stop being graph nodes.
Simplification is a topology edit, not a generalisation — the drawn curve is preserved as an edge attribute.

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"])
Timeline of one giant bounding box request versus tiled, cached requests Three timelines share a zero to two-hundred-and-forty-second axis. The first shows a single graph_from_bbox over the whole region running for roughly three minutes and then failing with an HTTP 504 gateway timeout, leaving nothing usable. The second shows nine polygon tiles, each taking about fifteen seconds with a short rate-limit pause between them, finishing well inside the same budget with full coverage and one cache file per tile. The third shows the identical script re-run afterwards completing in under two seconds because every response is already on disk. One giant query versus nine polite ones one bbox tiled cached re-run graph_from_bbox over the whole region HTTP 504 · nothing usable 9 tiles composed · full coverage gaps are the rate-limit pause · one cache file each same script re-run from ./osm_cache · under 2 s 0 s 60 s 120 s 180 s 240 s The tiled run pays for the same total area, keeps per-tile progress, and costs nothing the second time.
Tiling converts one unrecoverable failure into nine independently cached requests you can resume.

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

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.