OSMnx Download Fails, Hangs or Times Out
Problem statement
An OSMnx call that worked yesterday now does one of four things, and only one of them looks like an error:
osmnx._errors.ResponseStatusCodeError: 'overpass-api.de' responded: 504 Gateway Time-out
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE html PUBLIC β¦
osmnx._errors.InsufficientResponseError: No matching features. Check query location, tags, and log.
requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='overpass-api.de', port=443):
Read timed out. (read timeout=180)
And the fourth, which is the one that costs you a week:
buildings = ox.features_from_place("Leeds, England", tags={"building": True})
print(len(buildings))
41208
No error. No warning. And the real number is about 180,000 β the server ran out of time, sent back what it had, and OSMnx logged a warning to a logger that is switched off by default.
Quick answer
Turn the logging on, raise both timeouts, and check the count against a cheap out count query:
import logging
import osmnx as ox
ox.settings.log_console = True # default False β this is where remarks go
ox.settings.log_level = logging.WARNING
ox.settings.requests_timeout = 600 # client-side wait; default 180
ox.settings.overpass_settings = "[out:json][timeout:600]{maxsize}" # server-side budget
gdf = ox.features_from_place("Leeds, England", tags={"building": True})
'overpass-api.de' remarked: 'runtime error: Query timed out in "query" at line 3 after 180 seconds.'
That one line is the difference between a truncated dataset and a known problem. The four failures and their fixes:
| Symptom | Cause | Fix |
|---|---|---|
ResponseStatusCodeError 504/429 |
server busy or you are rate-limited | back off and retry; use a mirror |
InsufficientResponseError |
nothing matched, or a 2xx with a non-JSON body | check the tag, then the place |
ReadTimeout |
client gave up before the server finished | raise requests_timeout |
| silently short result | server-side timeout, remark in the JSON |
log_console = True, raise the server timeout |
Step-by-step solution
1. Switch the log on before anything else
OSMnx routes every diagnostic through its own logger, and both outputs are off by default:
print(ox.settings.log_console, ox.settings.log_file)
False False
The server's remark β "I ran out of time, here is what I had" β is emitted at WARNING level to exactly that logger. With both flags false, it goes nowhere at all. Turning on log_console is the single highest-value change you can make to an OSM pipeline.
ox.settings.log_console = True # to stderr
ox.settings.log_file = True # to ./logs/osmnx.log, for scheduled runs
2. Work out which of the two timeouts you hit
There are two, they are unrelated, and they produce different errors:
print(ox.settings.requests_timeout) # client: how long we wait for bytes
print(ox.settings.overpass_settings) # server: how long Overpass will spend
180
[out:json][timeout:{timeout}]{maxsize}
ReadTimeoutmeans the client gave up. The server may well have finished. Raiserequests_timeout.- A
remarkand a short result means the server gave up. Raise the server budget by rewritingoverpass_settings, and make the client budget larger still.
Get the ordering wrong β a 600-second server budget with a 180-second client timeout β and you will disconnect from every query that needed the extra time.
ox.settings.overpass_settings = "[out:json][timeout:600]{maxsize}"
ox.settings.requests_timeout = 700 # must exceed the server budget
Keep the {maxsize} placeholder. OSMnx substitutes the memory hint into it, and removing it changes how much RAM the server is willing to allocate to your query.
3. Distinguish "no results" from "bad request"
InsufficientResponseError is raised in two very different situations:
- Overpass returned valid JSON with an empty
elementslist β your tag or area genuinely has nothing. - Overpass returned a 2xx status with a body that is not JSON β OSMnx cannot parse it, and because the status was OK it reports insufficiency rather than a status error.
Separate them by asking the cheapest possible question:
from osmnx._errors import InsufficientResponseError
try:
area = ox.geocode_to_gdf("Ancoats, Manchester, England")
except InsufficientResponseError:
raise SystemExit("the place string did not geocode β fix that first")
print(area["display_name"][0], area.geom_type[0])
If the place resolves, the problem is the tag. If it does not, no tag will help.
4. Shrink the query before you fight the server
Most timeouts are a query that is simply too big. Three ways to make it smaller, in order of preference:
# a) narrower tags β `building=True` is every building; you may want a subset
ox.features_from_polygon(poly, tags={"building": ["apartments", "house", "terrace"]})
# b) smaller area β split a city into its wards and fetch each
for _, ward in wards.iterrows():
ox.features_from_polygon(ward.geometry, tags={"building": True})
# c) a different source β for a whole country, use a regional extract file
A city's buildings is at the edge of what the public API is for. A county's is past it.
5. Retry, with backoff, on the retryable codes only
429 (rate-limited) and 5xx (server trouble) are worth retrying. 400 (bad query) never is:
import time
from osmnx._errors import ResponseStatusCodeError
def with_retry(fn, *args, attempts=4, **kwargs):
delay = 10
for attempt in range(1, attempts + 1):
try:
return fn(*args, **kwargs)
except ResponseStatusCodeError as exc:
if attempt == attempts or " 400 " in str(exc):
raise
print(f" attempt {attempt} failed ({str(exc)[:60]}β¦) β waiting {delay}s")
time.sleep(delay)
delay *= 2
Code examples
Example 1 β a fetch that refuses to return a truncated result
import logging
import re
import osmnx as ox
import requests
from osmnx._errors import InsufficientResponseError, ResponseStatusCodeError
ox.settings.log_console = True
ox.settings.log_level = logging.WARNING
ox.settings.overpass_settings = "[out:json][timeout:600]{maxsize}"
ox.settings.requests_timeout = 700
def expected_count(poly, key, value=None):
"""Ask Overpass how many elements match, without downloading any of them."""
south, west, north, east = poly.bounds[1], poly.bounds[0], poly.bounds[3], poly.bounds[2]
selector = f'["{key}"="{value}"]' if value else f'["{key}"]'
query = (
f"[out:json][timeout:120];\n"
f"(node{selector}({south},{west},{north},{east});"
f" way{selector}({south},{west},{north},{east});"
f" relation{selector}({south},{west},{north},{east}););\n"
f"out count;"
)
r = requests.post(
"https://overpass-api.de/api/interpreter",
data={"data": query},
headers={"User-Agent": "spatialworkflow-example/1.0"},
timeout=300,
)
r.raise_for_status()
return int(r.json()["elements"][0]["tags"]["total"])
def fetch_checked(place, key, value=None, tolerance=0.9):
area = ox.geocode_to_gdf(place)
poly = area.geometry[0]
expected = expected_count(poly, key, value)
tags = {key: value if value else True}
gdf = ox.features_from_polygon(poly, tags=tags)
# the bbox count is an upper bound: the polygon is smaller than its bbox
ratio = len(gdf) / expected if expected else 1.0
print(f"{place}: got {len(gdf)}, bbox expects <= {expected} ({ratio:.0%} of bbox)")
if ratio < 1 - tolerance:
raise RuntimeError(f"suspiciously few features ({ratio:.0%}) β check the log for a remark")
return gdf
buildings = fetch_checked("Ancoats, Manchester, England", "building")
Ancoats, Manchester, England: got 1204, bbox expects <= 1533 (79% of bbox)
The bbox count is always an upper bound, because the polygon fits inside its own bounding box. What the check catches is the collapse β 5% of the expected count means a truncated response, not a tight boundary.
Example 2 β splitting a large area into pieces that finish
import geopandas as gpd
import pandas as pd
from shapely.geometry import box
def tile_polygon(poly, cells=3):
"""Cut a polygon's extent into a cells x cells grid and clip each square to it."""
minx, miny, maxx, maxy = poly.bounds
dx, dy = (maxx - minx) / cells, (maxy - miny) / cells
squares = [
box(minx + i * dx, miny + j * dy, minx + (i + 1) * dx, miny + (j + 1) * dy)
for i in range(cells) for j in range(cells)
]
parts = [s.intersection(poly) for s in squares]
return [p for p in parts if not p.is_empty]
area = ox.geocode_to_gdf("Manchester, Greater Manchester, England")
pieces = tile_polygon(area.geometry[0], cells=3)
frames = []
for i, piece in enumerate(pieces, 1):
try:
part = ox.features_from_polygon(piece, tags={"building": True})
except InsufficientResponseError:
print(f" tile {i}/{len(pieces)}: 0 features")
continue
print(f" tile {i}/{len(pieces)}: {len(part)} features")
frames.append(part.reset_index()[["element", "id", "geometry", "building"]])
buildings = pd.concat(frames, ignore_index=True)
buildings = gpd.GeoDataFrame(buildings, crs="EPSG:4326").drop_duplicates(["element", "id"])
print(f"{len(buildings)} unique buildings from {len(frames)} tiles")
tile 1/9: 8142 features
tile 2/9: 11907 features
β¦
tile 9/9: 6633 features
87451 unique buildings from 9 tiles
drop_duplicates(["element", "id"]) is required, not optional: a building straddling a tile boundary intersects both tiles and arrives twice. The (element, id) pair is the OSM identity, which is what makes the dedupe exact rather than geometric.
Example 3 β checking the server before you queue work
import requests
def overpass_status(base="https://overpass-api.de/api"):
r = requests.get(f"{base}/status", timeout=30)
r.raise_for_status()
return r.text
print(overpass_status())
Connected as: 1234567890
Current time: 2026-08-26T10:44:12Z
Rate limit: 2
2 slots available now.
Currently running queries (pid, space limit, time limit, start time):
Rate limit: 2 means two concurrent queries for your IP. 0 slots available now with a list of running queries means you are already at the limit and the next call will block or 429. OSMnx checks this itself when overpass_rate_limit is true (the default), which is why a call can appear to hang before any request is sent β it is waiting for a slot, politely.
Explanation
Why the silent truncation happens at all
Overpass has a per-query time budget declared in the query itself. When it expires, the server has a choice: throw away the work and error, or return what it has. It returns what it has β with HTTP 200, valid JSON, and a top-level remark string explaining why the answer is short.
OSMnx reads that remark and calls utils.log(msg, level=WARNING). With log_console and log_file both false β the defaults β that message is discarded. The GeoDataFrame you receive is real, correctly parsed, and incomplete.
There is no way to make this raise from settings alone. Either turn the log on and watch it, or verify counts as in Example 1.
Why InsufficientResponseError is ambiguous
Reading the OSMnx source makes the two paths explicit: when response.json() fails to parse, OSMnx checks the status code. A 2xx with an unparseable body becomes InsufficientResponseError; anything else becomes ResponseStatusCodeError. A genuinely empty elements list also becomes InsufficientResponseError, further along.
So the same exception covers "nothing matched your tags" and "the server sent an HTML page with a 200". Geocoding the place separately, as in step 3, splits them apart in one line.
Why the DNS pinning matters
Overpass load-balances overpass-api.de across several machines. If you checked the slot status on one and then submitted the query to another, you would violate the second machine's rate limit while believing you had a slot.
OSMnx works around this by resolving the hostname once and monkey-patching socket.getaddrinfo so every subsequent call reaches the same IP. This is worth knowing for two reasons: it explains an otherwise baffling patch of your socket module, and it means a long-lived process pins itself to one server β restart it if that server goes bad.
Why the cache can be the thing that is broken
OSMnx will not cache a response containing a remark β it explicitly skips saving those. But it will cache a legitimately empty result. If a query failed for a transient reason that produced an empty elements list, that emptiness is now on disk and every rerun returns it instantly, with no network call to correct it.
When a query "has always returned nothing", delete the cache entry before believing it:
import shutil
shutil.rmtree(ox.settings.cache_folder, ignore_errors=True)
Edge cases or notes
- A hang with no output is usually the rate limiter.
overpass_rate_limitdefaults to true and pauses until a slot frees. Setlog_console = Trueand you will see it say so. ox.settings.overpass_memoryraises the server's memory hint for very large queries. Leave it atNoneunless you get an out-of-memory remark β it makes the server more likely to refuse the query outright.- Mirrors have separate limits.
ox.settings.overpass_url = "https://overpass.kumi.systems/api"is a one-line switch when the main endpoint is saturated. - A wrong tag and an empty area are indistinguishable from the exception alone. Always verify the tag against the OSM wiki before assuming the area is empty.
features_from_placemakes two network calls β Nominatim then Overpass β and Nominatim has its own, much stricter, rate limit of roughly one request per second.- Tiling changes the result subtly. Features are returned if they intersect a tile, so tiled fetches include more edge features than a single fetch of the whole polygon would. Dedupe on
(element, id), then clip. cache_only_mode = Truemakes OSMnx raise rather than hit the network at all β useful in tests and CI, where an accidental network call should be a failure.
Internal links
- How to download OpenStreetMap data in Python with OSMnx β the guardrails that prevent most of this
- How to query the Overpass API from Python β
out count, error parsing, and the raw failure modes - The OpenStreetMap data model explained β why a tag guess returns nothing
- How to retry a flaky GIS step in Python β the general backoff pattern
- How to cache downloaded GIS data so you fetch it once β a cache you control, with an expiry
- Your street network graph is disconnected or missing streets β the same truncation seen in a graph
- How to log and summarise errors in a batch GIS job β where the OSMnx log should end up
- GeoJSON downloaded from an API is empty or truncated β the same class of problem in other services
FAQ
Why did my download return far fewer features than expected, with no error?
The Overpass server hit its time budget and returned a partial result with a remark. OSMnx logs that at WARNING to a logger that is disabled by default. Set ox.settings.log_console = True and rerun.
What is the difference between requests_timeout and the query timeout?
requests_timeout is how long your client waits. The [timeout:N] inside overpass_settings is how long the server will spend. The client value must be the larger of the two.
Why does InsufficientResponseError appear when my tags are obviously right?
Because it also fires when the server returns a 2xx with a non-JSON body. Geocode the place on its own first β if that works, the problem is the tag or the area genuinely being empty.
How do I download a whole city's buildings without timing out?
Tile the boundary polygon, fetch each tile, concatenate, and drop duplicates on (element, id). Past roughly a city, switch to a regional extract file.
Is it safe to retry automatically?
On 429 and 5xx, yes, with exponential backoff. Never on 400 β a malformed query fails identically every time and retrying just consumes your rate limit.
Why does OSMnx seem to hang before sending anything?
It is waiting for an Overpass slot, because overpass_rate_limit is on. Enable console logging to see the wait, or set the flag to false only if you are querying your own instance.
My query returns nothing and always has β is that real?
Check the cache first. An empty result gets cached and replayed forever with no network call. Delete ox.settings.cache_folder and try once more before concluding the area is empty.