How to Download OpenStreetMap Data in Python with OSMnx
On this page
Problem statement
You need the cafΓ©s, or the buildings, or the parks for one city β and the OpenStreetMap download page offers you a 95 GB planet file (the PBF of 21 September 2026).
The gap between "I want one city's cafΓ©s" and "here is the entire planet" is what OSMnx closes. But the first attempt usually goes one of three ways:
import osmnx as ox
cafes = ox.features_from_place("Manchester", tags={"amenity": "cafe"})
- It returns cafΓ©s in whichever Manchester Nominatim ranks first. Today that is Manchester, England; for a name like "Springfield" the top hit is Illinois whether or not you meant it (step 1).
- It runs for four minutes and raises a timeout.
- It raises
InsufficientResponseError: No matching featuresand you cannot tell whether the tag was wrong or the place genuinely has none.
All three are fixable, and the fixes are the same three decisions every OSM download needs: what area, what tags, and what to do when the answer is nothing.
Quick answer
Name the place precisely, filter server-side, and catch the empty case:
import osmnx as ox
from osmnx._errors import InsufficientResponseError
ox.settings.use_cache = True # on by default; keeps reruns free
try:
cafes = ox.features_from_place(
"Manchester, Greater Manchester, England", # narrow enough to be unique
tags={"amenity": "cafe"},
)
print(len(cafes), "cafes")
except InsufficientResponseError:
print("no matching features β check the tag, then the place")
338 cafes
Three functions cover almost everything:
| Function | Use when |
|---|---|
features_from_place(query, tags) |
you can name the area ("Ancoats, Manchester, UK") |
features_from_bbox(bbox, tags) |
you have coordinates β the order is (west, south, east, north) |
features_from_polygon(poly, tags) |
you already have a study-area polygon |
Resolve the place, write tag filters for the OSM schema
1. Resolve the place before you query it
features_from_place geocodes your string with Nominatim and uses the resulting polygon. Check what it resolved to first, in its own call, so an ambiguous name fails visibly rather than returning the wrong city's data:
area = ox.geocode_to_gdf("Springfield")
print(area["display_name"][0], "|", area.geom_type[0])
Springfield, Sangamon County, Illinois, United States | MultiPolygon
Nominatim ranks by importance, and it picked Illinois. If you wanted Missouri, either qualify the string or pass which_result:
area = ox.geocode_to_gdf("Springfield", which_result=3)
print(area["display_name"][0])
Springfield, Greene County, Missouri, United States
which_result is fragile β the ranking shifts as OSM changes. Qualify the string instead: "Springfield, Missouri, USA". Reserve which_result for the case where no qualification is unique.
The geometry type matters. If the place resolves to a Point rather than a polygon β common for small hamlets and for POI names β there is no area to query, and the fetch either fails or silently uses a tiny extent:
if area.geom_type[0] not in ("Polygon", "MultiPolygon"):
raise ValueError(f"{area['display_name'][0]!r} has no boundary polygon; use a bbox")
2. Write the tag filter to match the OSM schema, not your vocabulary
Tags are the query. Take them from the OSM wiki rather than intuition β the tag for a pub is amenity=pub, and there is no amenity=coffee_shop.
tags = {"amenity": "cafe"} # exactly this value
tags = {"amenity": ["cafe", "restaurant", "bar"]} # any of these values
tags = {"building": True} # key present, any value
tags = {"amenity": "cafe", "shop": "bakery"} # cafes OR bakeries β see below
The last line surprises people: multiple keys in one dict are combined with OR, not AND. It returns everything tagged amenity=cafe plus everything tagged shop=bakery. For an intersection, fetch the broader set and filter in pandas.
3. Keep the area under the query limit
OSMnx splits large areas into multiple Overpass requests automatically, at:
print(ox.settings.max_query_area_size / 1e6, "kmΒ²")
2500.0 kmΒ²
A whole country is therefore hundreds of requests, and Overpass will throttle you long before they finish. For anything larger than a metropolitan area, use a bulk regional extract β that is what extracts are for.
4. Let the cache do the reruns
print(ox.settings.use_cache, ox.settings.cache_folder)
True ./cache
Caching is on by default and keyed on the exact request, so a second identical call is instant and touches no server. Two consequences: the cache never expires, so a month-old result is served silently; and ./cache is relative to the working directory, so running the same script from a different folder refetches everything.
from pathlib import Path
ox.settings.cache_folder = Path("~/.cache/osmnx").expanduser() # stable across cwd
5. Trim and reproject before doing anything else
keep = ["geometry", "name", "amenity", "addr:street", "addr:housenumber"]
cafes = cafes[[c for c in keep if c in cafes.columns]].reset_index()
cafes = cafes.to_crs("EPSG:27700")
print(cafes.shape, cafes.crs.to_epsg())
(338, 7) 27700
reset_index() matters: the (element, id) MultiIndex is the feature's identity and it is lost silently on to_file(). The reprojection matters for anything measured β OSM is always EPSG:4326, where .distance() returns degrees.
A reusable fetch that handles failures
The data used on this page
Everything on this page is live OpenStreetMap data through OSMnx. overpass-api.de refuses connections from here, so the setup points OSMnx at the maps.mail.ru mirror of the same API β the queries are unchanged β and turns off OSMnx's rate-limit polling, which that mirror does not answer. OpenStreetMap is edited continuously, so the counts move a little between downloads. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import osmnx as ox
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 10 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-download-openstreetmap-data-python-2026-10-10.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" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
print(ox.__version__, ox.settings.overpass_url)
2.1.1 https://maps.mail.ru/osm/tools/overpass/api
Example 1 β a reusable fetch with the failure modes handled
from pathlib import Path
import geopandas as gpd
import osmnx as ox
from osmnx._errors import InsufficientResponseError
ox.settings.use_cache = True
ox.settings.cache_folder = Path("~/.cache/osmnx").expanduser()
ox.settings.requests_timeout = 300 # default 180; big areas need longer
AREA_TYPES = ("Polygon", "MultiPolygon")
def osm_features(place, tags, *, crs="EPSG:27700", keep=()):
area = ox.geocode_to_gdf(place)
resolved = area["display_name"][0]
if area.geom_type[0] not in AREA_TYPES:
raise ValueError(f"{place!r} resolved to {resolved!r}, which has no boundary polygon")
try:
gdf = ox.features_from_polygon(area.geometry[0], tags=tags)
except InsufficientResponseError:
print(f" 0 features for {tags} in {resolved}")
return gpd.GeoDataFrame({"element": [], "id": []}, geometry=[], crs=crs)
print(f"{len(gdf):5} features for {tags} in {resolved}")
columns = ["geometry", *[c for c in keep if c in gdf.columns]]
return gdf[columns].reset_index().to_crs(crs)
cafes = osm_features(
"Ancoats, Manchester, England",
{"amenity": "cafe"},
keep=["name", "amenity", "addr:street"],
)
print(cafes.head(3).to_string())
11 features for {'amenity': 'cafe'} in Ancoats, Manchester, Greater Manchester, England, United Kingdom
element id geometry name amenity addr:street
0 node 5654604170 POINT (384967.085 398500.618) Waterside Coffee cafe NaN
1 node 5654604174 POINT (384835.446 398552.631) Cha Ology cafe Great Ancoats Street
2 node 8637214278 POINT (384924.218 398666.581) Cafe Cotton cafe NaN
Passing the polygon rather than the place string means the geocode happens once, visibly, and you can inspect what you got before spending a query on it.
Example 2 β several layers for one study area, in one pass
LAYERS = {
"cafes": {"amenity": "cafe"},
"parks": {"leisure": "park"},
"buildings": {"building": True},
"cycleways": {"highway": "cycleway"},
}
area = ox.geocode_to_gdf("Ancoats, Manchester, England")
poly = area.geometry[0]
out = Path("data/ancoats")
out.mkdir(parents=True, exist_ok=True)
for name, tags in LAYERS.items():
try:
gdf = ox.features_from_polygon(poly, tags=tags)
except InsufficientResponseError:
print(f"{name:10} 0 features β skipped")
continue
gdf = gdf.reset_index().to_crs("EPSG:27700")
# GPKG will not take a list-valued column or a 143-column zoo of mixed types
keep = ["name", *[k for k in tags if k != "name"]]
gdf = gdf[["element", "id", "geometry", *[k for k in keep if k in gdf.columns]]]
gdf.to_file(out / "ancoats.gpkg", layer=name, driver="GPKG")
types = ", ".join(f"{k}:{v}" for k, v in gdf.geom_type.value_counts().items())
print(f"{name:10} {len(gdf):5} features -> layer {name!r} in memory: {types}")
cafes 11 features -> layer 'cafes' in memory: Point:11
parks 1 features -> layer 'parks' in memory: Polygon:1
buildings 175 features -> layer 'buildings' in memory: Polygon:174, MultiPolygon:1
cycleways 22 features -> layer 'cycleways' in memory: LineString:22
One GeoPackage, one layer each, one shared extent. Because the polygon is fetched once and reused, all four layers are clipped to exactly the same boundary β which is not guaranteed if you pass the place string four times and Nominatim's answer shifts between calls.
Example 3 β knowing what you actually received
import fiona
import pandas as pd
def describe(gdf, label):
if len(gdf) == 0:
return {"layer": label, "n": 0, "geom": "-", "named": "-"}
return {
"layer": label,
"n": len(gdf),
"geom": ", ".join(f"{k}:{v}" for k, v in gdf.geom_type.value_counts().items()),
"named": f"{gdf['name'].notna().mean():.0%}" if "name" in gdf else "no tag",
}
rows = [
describe(gpd.read_file(out / "ancoats.gpkg", layer=layer), layer)
for layer in fiona.listlayers(out / "ancoats.gpkg")
]
print(pd.DataFrame(rows).to_string(index=False))
layer n geom named
cafes 11 Point:11 100%
parks 1 Polygon:1 100%
buildings 175 MultiPolygon:175 27%
cycleways 22 LineString:22 32%
This table is the sanity check, and it is read from the file, not from memory. The 174 building polygons and one multipolygon went in; 175 multipolygons came out, because the GeoPackage writer promoted the layer to a single multi type β code that expects Polygon after a round trip will now be wrong. 27% named tells you a join by building name will match about one row in four. Run it every time β it costs nothing and catches most of what goes wrong later.
Why a place query can return the wrong city
Why features_from_place can quietly return the wrong city
The place string goes to Nominatim, which returns candidates ranked by an importance score derived partly from Wikipedia links. The top hit is used. For any name shared by several settlements, the top hit is the most famous one β the query succeeds, it just answers a different question.
Splitting the geocode into its own call turns a silent wrong answer into a visible one. It also lets you cache and version the boundary, which you want anyway: the analysis extent is data, and it should not be re-derived from a string on every run.
Why a bounding box needs care
features_from_bbox takes (left, bottom, right, top) β that is (west, south, east, north), longitude first. The Overpass API's own bbox filter takes (south, west, north, east), latitude first. The conventions are transposed, and swapping them usually produces a plausible-looking box somewhere else on earth rather than an error.
If you hand-write bounding boxes, name the components and assemble the tuple at the call site:
west, south, east, north = -2.2500, 53.4770, -2.2350, 53.4850
gdf = ox.features_from_bbox((west, south, east, north), tags={"amenity": "cafe"})
print(len(gdf), "cafes in the box")
85 cafes in the box
Why the cache is both the best and the worst feature
The cache makes iteration bearable β a notebook rerun twenty times hits the network once. It also means the data underneath your analysis can be six months old with no indication whatsoever, because there is no TTL.
Treat it as a build artefact: delete it when you want fresh data, and record the fetch date alongside the output, as in recording provenance at fetch time. For pipelines, an explicit cache with an expiry is the right tool.
Why multiple keys mean OR
tags={"amenity": "cafe", "shop": "bakery"} becomes an Overpass union: one statement per key, results combined. This mirrors how the query is built and is the opposite of the pandas intuition, where extra conditions narrow a result.
For an intersection, filter after the fetch:
places = ox.features_from_polygon(poly, tags={"amenity": ["cafe", "restaurant"]})
vegan = places[places["diet:vegan"].notna()] if "diet:vegan" in places else places.iloc[:0]
print(f"{len(vegan)} of {len(places)} tagged diet:vegan")
0 of 26 tagged diet:vegan
Ambiguous errors, clipping and shared Overpass
InsufficientResponseErrorcovers two situations: no elements matched, and the place could not be geocoded. Wrap a single call so you know which one you hit.- Downloading is not clipping.
features_from_polygonreturns features that intersect the polygon, so boundary buildings come back whole and extend beyond it. Follow with an explicit clip if you need them cut. - Overpass is a shared free service. For repeated large queries, use a regional extract or run your own instance. Hammering the public endpoint gets your IP throttled and slows everyone down.
- All columns arrive as
object. Numeric-looking tags are strings; coerce withpd.to_numeric(..., errors="coerce")and count the failures. to_file()on GeoPackage fails on list-valued columns. Some tags come back as lists when an element carries conflicting values; select the columns you need rather than writing the whole frame.which_resultis not stable over time. Prefer a fully qualified place string, or save the resolved boundary polygon as a file and reuse it.- Raise the timeout for large areas.
ox.settings.requests_timeoutdefaults to 180 seconds, which a busy Overpass server will exceed for a whole city's buildings.
Internal links
- The OpenStreetMap data model explained β why the result is wide, sparse and mixed-geometry
- How to query the Overpass API from Python β when the wrapper is not expressive enough
- OSMnx download fails, hangs or times out β the errors these guardrails prevent
- How to build a street network graph in Python with OSMnx β the routable-graph side of the same library
- GIS data sources explained β licence and provenance for what you just downloaded
- How to cache downloaded GIS data so you fetch it once β an expiry the OSMnx cache does not have
- How to clip spatial data in Python with GeoPandas β cutting features at the study boundary
- How to read and write GeoPackage files in Python β the multi-layer output format used above
- How to download building footprints from OSM and Overture β buildings from three sources, compared
FAQ
Why did OSMnx return data for the wrong city?
Nominatim picked the most "important" match for an ambiguous name. Geocode separately with ox.geocode_to_gdf(), print display_name, and qualify the string until it is unique.
How do I know which tags to use?
Look the feature up on the OpenStreetMap wiki. Tags are conventions, not a schema, and guessing produces empty results that look identical to "nothing is there".
Why does an empty result raise instead of returning an empty frame?
OSMnx 2.x raises InsufficientResponseError by design. Catch it explicitly, otherwise one empty area kills a loop over fifty areas.
Can I download a whole country this way?
Not sensibly. The area is split into 2,500 kmΒ² chunks and Overpass will throttle you. Use a regional extract file for anything bigger than a metro area.
Where does OSMnx put its cache, and does it expire?
./cache relative to the working directory, and it never expires. Set ox.settings.cache_folder to an absolute path, and delete it when you want fresh data.
Do I get features clipped to my polygon?
No β you get features that intersect it. Clip afterwards if the analysis needs exact boundaries.
Why are all my numeric columns strings?
OSM tag values have no types, so everything arrives as object. Convert what you need with pd.to_numeric(errors="coerce") and check how many failed.