How to Download OpenStreetMap Data in Python with OSMnx
Problem statement
You need the cafΓ©s, or the buildings, or the parks for one city β and the OpenStreetMap download page offers you a 90 GB planet file.
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 Manchester, New Hampshire.
- 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")
1043 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 |
Step-by-step solution
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())
(1043, 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.
Code examples
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())
28 features for {'amenity': 'cafe'} in Ancoats, Manchester, Greater Manchester, England, United Kingdom
element id geometry name amenity addr:street
0 node 303152717 POINT (384766.223 398772.117) Fig + Sparrow cafe Oldham Street
1 node 429437891 POINT (384912.881 398889.301) Takk Coffee cafe Tariff Street
2 node 1250132934 POINT (384880.556 398702.418) Idle Hands cafe Dale Street
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
gdf = gdf[["element", "id", "geometry", *[k for k in tags if k in gdf.columns]]]
gdf.to_file(out / "ancoats.gpkg", layer=name, driver="GPKG")
print(f"{name:10} {len(gdf):5} features -> layer {name!r}")
cafes 28 features -> layer 'cafes'
parks 6 features -> layer 'parks'
buildings 1204 features -> layer 'buildings'
cycleways 41 features -> layer 'cycleways'
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 28 Point:28 96%
parks 6 Polygon:5, MultiPolygon:1 83%
buildings 1204 Polygon:1198, Point:4, MultiPolygon:2 9%
cycleways 41 LineString:41 2%
This table is the sanity check. Point:4 among the buildings is the mixed-geometry problem waiting to happen; 9% named tells you a join by name will match one row in eleven. Run it every time β it costs nothing and catches most of what goes wrong later.
Explanation
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"})
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")
Edge cases or notes
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
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.