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.

Bounding box and area filters compared on precision, cost and how each one fails Two panels. A bounding box is a rectangle in degrees tested by coordinate comparison, is the cheapest spatial filter, always matches something, and fails by returning too much — features from neighbouring regions that happen to fall inside the rectangle. An area filter tests membership of a polygon derived from a boundary relation, costs more, respects the real administrative edge, and fails silently by matching nothing at all when the name or admin level does not resolve. Two spatial filters, two completely different failure modes Bounding box A rectangle in degrees Tested by coordinate comparison Cheapest spatial filter there is Always matches something Fails by returning too much Neighbouring regions leak in Area filter A polygon from a boundary relation Derived, with a synthetic id Costs more than a rectangle Respects the real administrative edge Fails by matching nothing And reports no error when it does A rectangle over-selects loudly and an area under-selects silently, which is why the area form needs an explicit non-empty assertion.
The rectangle's failure shows up in the data; the area's failure shows up as an empty file nobody questions.

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.

python
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

  1. Resolve before you query. area_exists runs 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.
  2. 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.
  3. Bind the area once. The real query resolves the boundary a second time but binds it with ->.a and references .a from all three element queries, so the boundary is resolved once per query rather than once per element type.
  4. Name the element types explicitly. Pharmacies exist as nodes, as building ways, and occasionally as relations. Listing all three is deliberate; using nwr would also work but makes the candidate set larger than necessary when you only want one type.
  5. Ask for centres, not geometry. out center tags gives one coordinate plus the tags per feature, which is exactly what a point-of-interest table needs and a fraction of the payload of out geom.
  6. 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.
  7. 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.
The resolve, assert, query, verify sequence for an area-bounded query Four steps. First resolve the boundary by name and administrative level with a cheap count query. Second assert that exactly one area matched, treating zero as unresolved and more than one as ambiguous. Third run the real query with the area bound to a named set and referenced from each element query. Fourth verify the returned feature count and a sample coordinate against the expected bounding box, so a wrong boundary is caught before the data is used. Four steps, and the second one is the one people skip resolve name plus admin_level a cheap count query assert exactly one area zero or many both fail query bind once, reference name the element types verify count and a sample point against the expected bbox Skipping the assertion is what turns a misspelled place name into an empty dataset that flows downstream without complaint.
The assertion costs one small request and removes the only failure mode of this query that produces no error.

Verification Jump to heading

  • The area count is exactly one. Run the area_exists query 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 center of 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.
How administrative level maps to the unit you probably mean, by country group A grid showing which administrative level corresponds to a state or province, a county or district, and a municipality across three country groups. Much of Europe uses four for a state, six for a county and eight for a municipality. The United States uses four for a state, six for a county and eight for a city. Many other countries diverge, using four or five for a first-level division and seven or eight for a local one. A note says the level must be verified per country rather than assumed. admin_level is not portable — verify it per country Much of Europe United States Elsewhere State or province 4 4 4 or 5 County or district 6 6 5 to 7 Municipality 8 8 7 to 9 Neighbourhood 10 10 rarely used 9 to 11 Record the verified level alongside each region in your configuration; a level that worked in one country is not evidence for the next one.
The same number means different things in different places, which is why a level hard-coded across a multi-country job eventually returns a region instead of a city.

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 area query 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.

Up one level: Overpass API Query Language.