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 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")
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
Choosing between features_from_place, features_from_bbox and features_from_polygon based on how the study area is defined.
How you define the area decides the function. Everything after that is identical.

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.

Five stages of an OSM download: resolve the place, filter tags server-side, fetch through the cache, trim columns, reproject.
Each stage removes something. Skipping the trim is what carries 143 columns into the analysis.

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.

One place string resolving to several candidate cities ranked by importance, with the top-ranked one silently used.
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")

Edge cases or notes

  • 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.