Writing Overpass QL Area and Bounding Box Queries Jump to heading
Select every feature inside a named administrative boundary — or inside a rectangle when no boundary is mapped — without hitting the silent failure where an unresolved area returns zero elements and no error at all.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Overpass has two ways to say “inside here”, and they are not variations of one idea. A bounding box is a rectangle in degrees, written (south,west,north,east), tested by coordinate comparison. It is cheap, exact, and completely indifferent to what is actually mapped — it will happily span three countries and half the sea.
An area filter tests membership of a polygon derived from a mapped closed way or relation. Areas are not first-class OSM objects; the Overpass server derives them from boundary relations and closed ways during its own area-generation pass, and it assigns them synthetic ids offset from the source object’s id. That derivation is why two things are true at once: an area filter is the only way to ask a question that respects a real administrative edge, and an area filter is the only spatial filter that can silently match nothing because the boundary you named is spelled differently, tagged at a different admin_level, or simply not present in the server’s area index.
The practical consequence is a rule: resolve the area by tags, bind it to a named set, and assert it is non-empty before anything else uses it. Never compute an area id by hand from a relation id, and never assume a name matched.
Runnable solution Jump to heading
The module below resolves an area, checks it resolved, and runs the real query against it — falling back to a bounding box when the boundary is not available.
from __future__ import annotations
import logging
from dataclasses import dataclass
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.overpass.area")
ENDPOINT = "https://overpass-api.de/api/interpreter"
HEADERS = {"User-Agent": "osm-pipeline-example/1.0 (contact@example.org)"}
@dataclass(frozen=True)
class Bbox:
south: float
west: float
north: float
east: float
def ql(self) -> str:
return f"({self.south},{self.west},{self.north},{self.east})"
def _post(query: str, timeout: int = 180) -> dict:
response = requests.post(ENDPOINT, data={"data": query},
headers=HEADERS, timeout=timeout)
response.raise_for_status()
return response.json()
def area_exists(name: str, admin_level: int) -> bool:
"""Confirm the boundary resolves to exactly one area before relying on it."""
query = (
"[out:json][timeout:25];\n"
f'area["name"="{name}"]["admin_level"="{admin_level}"]->.a;\n'
".a out count;"
)
payload = _post(query, timeout=60)
# `out count` returns a single element whose tags carry the totals.
tags = payload.get("elements", [{}])[0].get("tags", {})
total = int(tags.get("total", 0))
logger.info("area %r at admin_level %s resolved to %d area(s)",
name, admin_level, total)
return total == 1
def pois_in_area(name: str, admin_level: int, key: str, value: str) -> list[dict]:
"""Every node/way/relation with key=value inside a named administrative area."""
query = (
"[out:json][timeout:120];\n"
f'area["name"="{name}"]["admin_level"="{admin_level}"]->.a;\n'
"(\n"
f' node["{key}"="{value}"](area.a);\n'
f' way["{key}"="{value}"](area.a);\n'
f' relation["{key}"="{value}"](area.a);\n'
");\n"
"out center tags;"
)
return _post(query)["elements"]
def pois_in_bbox(bbox: Bbox, key: str, value: str) -> list[dict]:
"""The same question, bounded by a rectangle instead of a boundary relation."""
query = (
"[out:json][timeout:120];\n"
"(\n"
f' node["{key}"="{value}"]{bbox.ql()};\n'
f' way["{key}"="{value}"]{bbox.ql()};\n'
f' relation["{key}"="{value}"]{bbox.ql()};\n'
");\n"
"out center tags;"
)
return _post(query)["elements"]
def fetch(name: str, admin_level: int, fallback: Bbox,
key: str, value: str) -> list[dict]:
"""Prefer the administrative boundary; fall back to the rectangle, loudly."""
if area_exists(name, admin_level):
return pois_in_area(name, admin_level, key, value)
logger.warning("no single area for %r at admin_level %s — using the bbox fallback,"
" results will include neighbouring territory", name, admin_level)
return pois_in_bbox(fallback, key, value)
if __name__ == "__main__":
elements = fetch(
name="Kraków", admin_level=8,
fallback=Bbox(49.96, 19.79, 50.13, 20.22),
key="amenity", value="pharmacy",
)
logger.info("fetched %d element(s)", len(elements))
Step-by-step walkthrough Jump to heading
- Resolve before you query.
area_existsruns a tiny query whose only job is to count how many areas match the name and administrative level. It costs almost nothing and converts the silent failure into a decision. - Insist on exactly one. A count of zero means the boundary did not resolve; a count above one means the name is ambiguous, and picking arbitrarily between two areas called the same thing is worse than failing. Both cases fall through to the fallback.
- Bind the area once. The real query resolves the boundary a second time but binds it with
->.aand references.afrom all three element queries, so the boundary is resolved once per query rather than once per element type. - Name the element types explicitly. Pharmacies exist as nodes, as building ways, and occasionally as relations. Listing all three is deliberate; using
nwrwould also work but makes the candidate set larger than necessary when you only want one type. - Ask for centres, not geometry.
out center tagsgives one coordinate plus the tags per feature, which is exactly what a point-of-interest table needs and a fraction of the payload ofout geom. - Make the fallback loud. The warning names the consequence — results will include neighbouring territory — because a bounding box quietly substituted for a boundary is a correctness change, not a performance tweak.
- Keep the bbox honest. The fallback rectangle is passed in, not computed, so the caller owns the decision about how much surrounding territory is acceptable.
Verification Jump to heading
- The area count is exactly one. Run the
area_existsquery by hand for your boundary; a count of zero or two means the query you are about to run is not asking what you think. - The result count is plausible. A city of a million people has tens of pharmacies, not two and not eleven thousand. An order-of-magnitude surprise usually means the area resolved to the wrong administrative level.
- A sample point lies inside the fallback bbox. Take the
centerof any returned element and confirm it falls inside your rectangle; if it does not, the area you resolved is somewhere else entirely — a same-named place in another country is the usual culprit. - The area and bbox answers overlap sensibly. Run both forms once during development. The bbox result should be a superset of the area result; if the area result contains elements the bbox does not, one of the two is wrong.
- Repeat runs agree. Two runs minutes apart should return nearly identical counts. A large swing means you are hitting different servers in a load-balanced pool with different area-generation freshness.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Zero elements, HTTP 200 | The area did not resolve | Assert the area count before the real query |
| Elements from the wrong country | A same-named place matched first | Add a ["ISO3166-1"] or parent-area constraint |
| Far too many elements | admin_level too coarse — a region, not a city |
Raise the level; 8 is a typical municipality |
runtime error: Query run out of memory |
Area filter applied to an unbounded element query | Add a tag filter before the area filter |
| Hand-computed area id returns nothing | Area ids are derived, not equal to relation ids | Resolve by tags, never by arithmetic on an id |
| Ways returned without coordinates | out body instead of out center or out geom |
Choose an output mode that carries geometry |
| Different counts on repeated runs | Different servers in the public pool | Pin an endpoint, or accept the variance explicitly |
Specification reference Jump to heading
Overpass generates area objects from closed ways and boundary relations during a separate area-generation pass, and identifies them with ids derived from — but not equal to — the source object’s id. An
areaquery filters those generated objects by tag exactly as an element query filters elements, and the(area.setname)filter then selects elements located inside them. See the Overpass QL documentation on area filters and the area statement for the exact derivation rules and the set-binding syntax used throughout this guide.
Frequently Asked Questions Jump to heading
Why does my area query return nothing when the city obviously exists?
Because the area did not resolve, and an unresolved area produces an empty result rather than an error. The usual causes are a name that differs from the mapped value by an accent or a suffix, an administrative level that is wrong for that country’s hierarchy, or a boundary the server has not generated an area for. Run a small count query against the area statement alone before the real query, and treat a count other than one as a failure.
What administrative level should I use for a city?
There is no single answer, because the meaning of each level varies by country. Level eight is a municipality in much of Europe and is the most common choice for a city, but some countries use six or seven for the same concept and others attach the city name to a level that also covers surrounding rural territory. Resolve by name first, inspect what came back, and record the level you verified for each country rather than assuming one value travels.
Can I compute the area id from a relation id?
You can, and you should not. The derivation is an implementation detail of the area-generation pass, it differs between ways and relations, and a hand-computed id that happens to work today is exactly the kind of assumption that breaks silently later. Resolve areas by their tags, bind the result to a named set, and let the server tell you what matched.
Is a bounding box ever better than an area?
Yes, in two cases. When no boundary is mapped for the region you want, a rectangle is the only option. And when you are running a scheduled job where a predictable cost matters more than a precise edge, a rectangle’s cost is easy to estimate by eye while an area’s depends on the complexity of the boundary polygon. Accept that a rectangle over-selects, and filter the surplus out downstream where you can see what you removed.
Related Jump to heading
- Overpass API Query Language — the parent topic with the set model and filter cost ordering this query relies on.
- Handling Overpass Timeouts and Rate Limits — the client behaviour that keeps a scheduled version of this query welcome.
- Converting Overpass JSON to a GeoDataFrame — turning the elements returned here into typed rows.
- OSM Extract Clipping & Boundaries — the local-file equivalent of an area filter.
- Building a .poly File from an OSM Admin Relation — reusing the same boundary offline.
Up one level: Overpass API Query Language.