This guide is part of Fetching OSM Data via Overpass API, within the broader Mastering Geospatial Data Ingestion in Python reference.
Writing Overpass QL Queries for Large Areas
A query that works over a neighbourhood frequently fails over a region, and the failure is nearly always a resource limit rather than a syntax problem. The query language gives you three levers over that, and using them is mostly a matter of ordering the statements so the expensive part sees fewer elements.
Why Large-Area Queries Fail
- The server’s default timeout is short. A query that needs 300 seconds is killed at the default 180 with no partial result.
- Recursion happens before filtering when the query is written that way. Pulling every node in a bbox and then filtering by tag makes the server materialise millions of elements it will discard.
- Memory limits are hit by the intermediate set, not the output. A query returning 4 000 buildings can still exceed
maxsizeif it built a set of two million elements on the way. - Splitting by area is the wrong axis. A rural cell and an urban cell of the same size differ in element count by two orders of magnitude.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| Overpass API | 0.7.57+ | out geom, nwr shorthand, [maxsize] honoured |
| overpy | >=0.7 |
Optional client; the recipe uses requests directly |
| requests | >=2.32 |
Session reuse and explicit timeouts |
| shapely | >=2.0 |
Cell geometry for the density split |
pip install "requests>=2.32" "shapely>=2.0" "geopandas>=1.0"Where the Cost Goes
The Query Builder Recipe
from __future__ import annotations
import logging
import textwrap
import requests
logger = logging.getLogger(__name__)
ENDPOINT = "https://overpass-api.de/api/interpreter"
def build_query(
bbox: tuple[float, float, float, float],
filters: list[str],
element_types: str = "nwr",
timeout_s: int = 600,
maxsize_bytes: int = 1_073_741_824,
area_id: int | None = None,
) -> str:
"""Compose an Overpass QL query with explicit budgets and early filtering.
`filters` are tag selectors such as ['"building"', '"building"="residential"'];
they are applied in the selecting statement so the intermediate set stays small.
"""
south, west, north, east = bbox
scope = f"(area:{area_id})" if area_id else f"({south},{west},{north},{east})"
selectors = "\n ".join(f"{element_types}[{tag}]{scope};" for tag in filters)
return textwrap.dedent(f"""
[out:json][timeout:{timeout_s}][maxsize:{maxsize_bytes}];
(
{selectors}
);
out geom qt;
""").strip()
def run_query(query: str, session: requests.Session | None = None,
read_timeout: int = 900) -> dict:
"""POST the query, raising a clear error when the server reports a budget failure."""
session = session or requests.Session()
session.headers.setdefault("User-Agent", "spatial-etl/1.0 (data@example.org)")
response = session.post(ENDPOINT, data={"data": query}, timeout=(30, read_timeout))
if response.status_code == 400:
raise ValueError(f"Overpass rejected the query: {response.text[:400]}")
if response.status_code in (429, 504):
raise TimeoutError(f"Overpass refused or timed out ({response.status_code})")
response.raise_for_status()
payload = response.json()
remark = payload.get("remark")
if remark:
# A remark is how Overpass reports a partial result — never ignore it.
raise RuntimeError(f"incomplete result: {remark}")
logger.info("query returned %d elements", len(payload.get("elements", [])))
return payloadKey Implementation Notes
timeoutandmaxsizeare declared, never defaulted. The defaults are tuned for interactive use; a pipeline should state what it needs and fail clearly when the server will not grant it.- Filters go inside the selecting statement.
nwr["building"](bbox)is a different query fromnwr(bbox)followed by a tag filter, and the difference is the size of the intermediate set. out geomreturns way geometry inline. The alternative — recursing to nodes and resolving client-side — transfers far more and is only necessary when the node ids themselves are needed.qtsorts by quadtile. Output arrives in spatial order, which makes the downstream write spatially coherent for free.- A
remarkin the payload means a truncated answer. It arrives with HTTP 200 and a plausible-looking element list, which is the single most dangerous failure mode of the API; raising on it is essential. - The read timeout exceeds the query timeout. A client that gives up before the server does turns a slow success into a retry storm.
Estimating Density Before Splitting
Splitting by density requires knowing the density, and the cheapest estimate is a count-only query per candidate cell. Overpass supports out count;, which returns element counts without any geometry, at a fraction of the cost of the real query.
The practical procedure is a quadtree: run count queries over a coarse grid, subdivide any cell above the target count, and repeat until every leaf is under it. Two or three levels are usually enough, and the counting pass costs a small fraction of the extraction it plans.
Cache the resulting cell list next to the extraction configuration and refresh it occasionally rather than per run. Feature density changes slowly, and a stable cell list also makes the extraction’s per-cell outputs comparable between runs — which matters for the deduplication of edge-crossing features described in fetching OSM data via Overpass API.
Troubleshooting Large Queries
| Symptom | Likely cause | Fix |
|---|---|---|
| 504 after the full timeout | Query genuinely too large for one request | Split by density; raise timeout only if close |
| “runtime error: Query run out of memory” | Intermediate set too large | Move filters into the selecting statement; raise maxsize |
200 with a remark field |
Partial result returned | Raise on remark; re-run the cell smaller |
| Way geometry missing | out; used instead of out geom; |
Request geometry, or add node recursion |
| Duplicate features between cells | Features crossing cell boundaries | Deduplicate on osm_id after concatenation |
| Rate limited despite few requests | Query cost, not request count, is metered | Reduce per-query cost; back off as usual |
Integration Note
Cell enumeration, query construction and execution are three separable steps, and keeping them separate is what makes the extraction resumable: the cell list is state, each cell’s result is an artefact, and a failed cell retries alone. That maps directly onto the mapped-task pattern in parallel tile downloads with Prefect task mapping, with the concurrency cap set by the endpoint’s tolerance rather than by the worker count.
Public Instances, Mirrors and Self-Hosting
A large-area extraction eventually outgrows what a shared public endpoint should be asked to do, and there are three ways past that point.
Use a mirror. Several instances run the same API with independent quotas. Rotating between them spreads the load and gives the tiered fallback described in the rate-limit guide something to fall back to. Check each mirror’s stated policy — some ask that heavy users identify themselves.
Switch to an extract. For anything covering a country or larger, a regional .osm.pbf from a provider like Geofabrik plus a local reader is faster, kinder and entirely quota-free. The trade is freshness: extracts are cut daily rather than continuously.
Self-host the API. Running an Overpass instance against a local database removes every limit and adds an operational burden measured in hundreds of gigabytes and a multi-day initial import. It is worth it for teams whose work is predominantly OSM and rarely otherwise.
The decision usually resolves on freshness. If minute-level currency matters, stay on the API and split harder; if daily is enough, the extract path is better on every other axis.