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 features and 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
Choosing between the three OSMnx feature functions based on how the study area is defined. how you define the area picks the functionHow is your study area defined?by namefeatures_from_placegeocoded firstby coordinatesfeatures_from_bboxW, S, E, Nby a layerfeatures_from_polygonexact boundaryEverything after the area choice is identical spatialworkflow.io
How you define the area decides the function. Everything after that is identical.

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.

Five stages of an OpenStreetMap download, each one removing something before the next. each stage discards what the next does not need1resolve the place, and check what itresolved toonce, visibly2filter tags server-sidesaves bandwidth3fetch through the cachereruns are free4trim 142 columns to the ones you asked forand reset_index()5reproject out of EPSG:4326before measuringSkipping the trim carries the whole tag zoo into the analysis spatialworkflow.io
Each stage removes something. Skipping the trim is what carries 143 columns into the analysis.

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.

One ambiguous place string resolving to several candidate cities ranked by importance. "Springfield" β€” five real candidates, all rank 16resolves toimportancewhat you get1stβœ•Illinois0.613βœ•used by default2ndMassachusetts0.611ignored3rdMissouri0.596ignored4thOhio0.556ignored5thOregon0.494ignoredIllinois wins by 0.002 β€” and no error is raised spatialworkflow.io
Ambiguity does not raise. It picks the most famous candidate and carries on.

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

  • InsufficientResponseError covers 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_polygon returns 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 with pd.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_result is 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_timeout defaults to 180 seconds, which a busy Overpass server will exceed for a whole city's buildings.

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.