How to Query the Overpass API from Python

On this page

Problem statement

OSMnx is a good wrapper, and wrappers have edges. Sooner or later you want something it does not express:

  • every feature with amenity=cafe and outdoor_seating=yes — an intersection, not a union
  • a count, without downloading 30,000 features to call len() on them
  • features inside an administrative area named in the data, not a bounding box
  • everything that changed in the last thirty days

All of these are one line of Overpass QL. The cost of dropping to the raw API is that you now own the failure handling, and Overpass fails in ways that do not look like failures:

import requests

query = '[out:json][timeout:60];node["amenity"="cafe"](53.4770,-2.2500,53.4850,-2.2350);out body;'
r = requests.post("https://overpass-api.de/api/interpreter", data={"data": query}, timeout=120)
try:
    data = r.json()
except requests.exceptions.JSONDecodeError as err:
    print(f"JSONDecodeError: {err}")
    print(f"HTTP {r.status_code}, {r.headers.get('Content-Type')}: {r.text.split('<title>')[1].split('<')[0]!r}")
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
HTTP 406, text/html; charset=iso-8859-1: '406 Not Acceptable'

That is not a malformed response. That is Overpass returning an HTML error page — here 406 Not Acceptable, because the main public instance refuses the default python-requests User-Agent; on a busy day it is a 504 — and r.json() never gets far enough to tell you so.

Quick answer

POST the query with an honest User-Agent, check the content before parsing it, wait and retry when the server says it is busy, and read any other error out of the HTML:

import re
import time

import requests

OVERPASS = "https://overpass-api.de/api/interpreter"
# name the project and a real contact: generic and placeholder agents get HTTP 406
HEADERS = {"User-Agent": "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"}



def overpass(query, timeout=180, attempts=4):
    for attempt in range(attempts):
        r = requests.post(OVERPASS, data={"data": query}, headers=HEADERS, timeout=timeout)
        if r.status_code == 200 and r.text.lstrip().startswith("{"):
            return r.json()
        if r.status_code in (429, 504) and attempt < attempts - 1:
            time.sleep(10 * 2 ** attempt)                  # busy or rate-limited: back off
            continue
        errors = [" ".join(re.sub("<[^>]+>", "", e).split())
                  for e in re.findall(r"<p><strong.*?</p>", r.text, re.S)]
        raise RuntimeError(f"Overpass HTTP {r.status_code}: {errors or r.text[:200]}")


data = overpass("""
[out:json][timeout:60];
node["amenity"="cafe"](53.4770,-2.2500,53.4850,-2.2350);
out body;
""")
print(len(data["elements"]), "elements")
86 elements

Note the bounding box order: (south, west, north, east) — latitude first. That is the opposite of OSMnx and of GeoPandas total_bounds.

The four parts of an Overpass QL query, each terminated by a semicolon. every query is these four parts[out:json][timeout:60];settingsformat + server budgetnode["amenity"="cafe"]selectorelement type + tag filters(53.477,-2.250,53.485,-2.235);constraintS, W, N, E — lat firstout body;outputbody | center | geom | countA missing semicolon returns HTTP 400 with an HTML body spatialworkflow.io
Every Overpass query is these four parts. Most errors are a missing semicolon in one of them.

The four parts of a query, unions, tag filters

1. Learn the four parts of a query

[out:json][timeout:60];          ← settings: response format and server-side time budget
node["amenity"="cafe"]           ← element type + tag filters
  (53.477,-2.250,53.485,-2.235); ← spatial constraint
out body;                        ← what to return

Every statement ends in a semicolon, including the settings block and the final out. A missing one gets an HTTP 400 with an HTML body — the explanation below shows the message.

2. Combine element types with a union

A tag can be on a node, a way or a relation. Cafés are usually nodes; parks are usually ways. If you do not know, ask for all three:

[out:json][timeout:60];
(
  node["leisure"="park"](53.44,-2.30,53.52,-2.18);
  way["leisure"="park"](53.44,-2.30,53.52,-2.18);
  relation["leisure"="park"](53.44,-2.30,53.52,-2.18);
);
out center;

The parentheses make a union. out center; returns each way and relation with a computed centroid instead of its full geometry:

from collections import Counter

data = overpass("""
[out:json][timeout:60];
(
  node["leisure"="park"](53.44,-2.30,53.52,-2.18);
  way["leisure"="park"](53.44,-2.30,53.52,-2.18);
  relation["leisure"="park"](53.44,-2.30,53.52,-2.18);
);
out center;
""")
print(len(data["elements"]), "parks:", dict(Counter(e["type"] for e in data["elements"])))
way = next(e for e in data["elements"] if e["type"] == "way")
print(sorted(way.keys()), way["center"])
208 parks: {'node': 4, 'way': 197, 'relation': 7}
['center', 'id', 'nodes', 'tags', 'type'] {'lat': 53.4553571, 'lon': -2.2132332}

That is a fraction of the bytes of full geometry, and it is all you need for a point analysis. When you do need the shapes, use out geom; instead.

3. Use tag filters to express AND

This is the thing OSMnx will not do for you. Stack filters on one element and they combine with AND:

node["amenity"="cafe"]["outdoor_seating"="yes"](53.44,-2.30,53.52,-2.18);

The full filter vocabulary:

Filter Meaning
["amenity"] key exists, any value
["amenity"="cafe"] exact value
["amenity"!="cafe"] not this value (includes elements with no such key)
["name"~"Coffee"] value matches a regex
["name"~"coffee",i] case-insensitive regex
[!"name"] key absent

4. Ask for a count before you ask for the data

[out:json][timeout:60];
node["amenity"="cafe"](53.44,-2.30,53.52,-2.18);
out count;
data = overpass("""
[out:json][timeout:60];
node["amenity"="cafe"](53.44,-2.30,53.52,-2.18);
out count;
""")
print(data["elements"])
[{'type': 'count', 'id': 0, 'tags': {'nodes': '298', 'ways': '0', 'relations': '0', 'total': '298'}}]

One tiny response instead of a megabyte. Two things to notice: the counts are strings, so int() them; and the split by element type tells you in advance whether you need out center for ways.

5. Query by area, not just by box

An area is derived from a closed way or relation that Overpass has indexed. Bind it to a variable and use it as a constraint:

[out:json][timeout:60];
area["name"="Manchester"]["admin_level"="8"]->.searchArea;
node["amenity"="cafe"](area.searchArea);
out body;

->.searchArea names the result; (area.searchArea) constrains to it. This is exact — the actual administrative boundary rather than a box around it:

for label, where in [("bbox", "(53.44,-2.30,53.52,-2.18)"), ("Manchester area", "(area.searchArea)")]:
    payload = overpass(f"""
[out:json][timeout:60];
area["name"="Manchester"]["admin_level"="8"]->.searchArea;
node["amenity"="cafe"]{where};
out count;
""")
    print(f"{label:16} {payload['elements'][0]['tags']['total']:>5} cafés")
bbox               298 cafés
Manchester area    337 cafés

Qualify the area filter. area["name"="Ancoats"] alone matches any object named Ancoats anywhere on earth and forces a global scan, which is a reliable way to earn a 504. Always add admin_level, boundary, or an ISO3166 code.

A bounding box including features outside the boundary, next to an area constraint that follows it exactly. cheap and approximate, or exact and indexedbbox constraintincludes 3 features outsidearea constraintexactly the 4 insideAn unqualified area filter scans globally — always add admin_level spatialworkflow.io
A bbox is cheap and approximate — the box on this page found 298 cafés, Manchester's real boundary 337. An area is exact, and needs an indexed tag to stay fast.

A client for the real failure modes

Example 1 — a client that handles the real failure modes

import re
import sys
import time

import requests

OVERPASS = "https://overpass-api.de/api/interpreter"
HEADERS = {"User-Agent": "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"}
RETRYABLE = {429, 502, 503, 504}


def _errors(html):
    """Overpass reports errors as <p><strong>Error</strong>: …</p> inside an HTML page."""
    found = re.findall(r"<p><strong.*?</p>", html, re.S)
    return [" ".join(re.sub("<[^>]+>", "", block).split()) for block in found]



def overpass(query, *, attempts=6, timeout=300):
    delay = 10
    for attempt in range(1, attempts + 1):
        r = requests.post(OVERPASS, data={"data": query}, headers=HEADERS, timeout=timeout)

        if r.status_code == 200 and r.text.lstrip().startswith("{"):
            payload = r.json()
            # A remark means the server gave up mid-query and sent partial data.
            if "remark" in payload:
                raise RuntimeError(f"Overpass partial result: {payload['remark']}")
            return payload

        messages = _errors(r.text) or [r.text[:160]]
        if r.status_code in RETRYABLE and attempt < attempts:
            print(f"  attempt {attempt}: HTTP {r.status_code} — retrying in {delay}s", file=sys.stderr)
            time.sleep(delay)
            delay *= 2
            continue
        raise RuntimeError(f"Overpass HTTP {r.status_code}: {messages[0]}")

    raise RuntimeError("unreachable")


data = overpass("""
[out:json][timeout:120];
node["amenity"="cafe"](53.4770,-2.2500,53.4850,-2.2350);
out body;
""")
print(len(data["elements"]), "cafes")
86 cafes

Three things this handles that the naive version does not: a busy server (504, retryable), a rate limit (429, retryable), and the remark key — which arrives with HTTP 200 and valid JSON when the server times out mid-query and returns whatever it had. That last one is the dangerous case, because it looks exactly like success with fewer results.

Example 2 — turning the response into a GeoDataFrame

import geopandas as gpd
import pandas as pd
from shapely.geometry import Point, LineString, Polygon


def to_gdf(payload, crs="EPSG:4326"):
    rows, geoms = [], []
    for element in payload["elements"]:
        if element["type"] == "node":
            geom = Point(element["lon"], element["lat"])
        elif "center" in element:
            geom = Point(element["center"]["lon"], element["center"]["lat"])
        elif "geometry" in element:                       # from `out geom;`
            coords = [(p["lon"], p["lat"]) for p in element["geometry"]]
            closed = len(coords) > 3 and coords[0] == coords[-1]
            geom = Polygon(coords) if closed else LineString(coords)
        else:
            continue                                       # no usable geometry
        rows.append({"element": element["type"], "id": element["id"], **element.get("tags", {})})
        geoms.append(geom)

    return gpd.GeoDataFrame(pd.DataFrame(rows), geometry=geoms, crs=crs)


cafes = to_gdf(data)
print(cafes.shape)
print(cafes.sort_values("id")[["element", "id", "name", "cuisine"]].head(3).to_string(index=False))
(86, 69)
element        id           name                             cuisine
   node 249517666 Katsouris Deli sandwich;cake;breakfast;coffee_shop
   node 324661012     Caffè Nero                         coffee_shop
   node 324661296      Starbucks                         coffee_shop

Whether a closed way is a polygon or a line is a judgement — see the closed-way ambiguity. The rule above (closed and more than three points) is right for buildings and landuse, and wrong for roundabouts. If you are mixing feature types, split the query instead of the parsing.

Example 3 — the intersection query OSMnx cannot express

BBOX = "53.44,-2.30,53.52,-2.18"

QUERIES = {
    "all cafes":            f'node["amenity"="cafe"]({BBOX});',
    "with outdoor seating": f'node["amenity"="cafe"]["outdoor_seating"="yes"]({BBOX});',
    "wheelchair accessible": f'node["amenity"="cafe"]["wheelchair"="yes"]({BBOX});',
    "both":                 f'node["amenity"="cafe"]["outdoor_seating"="yes"]["wheelchair"="yes"]({BBOX});',
    "unnamed":              f'node["amenity"="cafe"][!"name"]({BBOX});',
}


for label, selector in QUERIES.items():
    time.sleep(3)                                   # a shared server: pace the requests
    payload = overpass(f"[out:json][timeout:60];\n{selector}\nout count;")
    total = int(payload["elements"][0]["tags"]["total"])
    print(f"{label:24} {total:5}")
all cafes                  298
with outdoor seating        40
wheelchair accessible       59
both                        14
unnamed                      2

Five real questions, five tiny responses, no feature data downloaded at all. The client writes its retries to stderr, so they never mix with the results: on the runs behind this page the shared server answered with a 504 between four and seven times across the five queries before each got through, which is normal for the main public instance and the reason the client exists. Stacked filters give AND; [!"name"] gives absence. Neither is expressible through the tags dict of a wrapper.

Why r.json() is the wrong first move

Why r.json() is the wrong first move

Overpass signals almost everything through the body rather than the status line. A syntax error is HTTP 400 with an HTML page. A busy dispatcher is HTTP 504 with an HTML page. A rate limit is HTTP 429 with an HTML page. Only a successful query is JSON.

So r.json() raises JSONDecodeError for four unrelated problems, and the traceback names none of them. Checking r.text.lstrip().startswith("{") before parsing, and scraping the <p><strong>Error</strong> block when it does not, turns them into messages you can act on — here a settings block missing its semicolon:

try:
    overpass('[out:json][timeout:60]\nnode["amenity"="cafe"](53.477,-2.25,53.485,-2.235);out count;')
except RuntimeError as err:
    print(err)
Overpass HTTP 400: Error: line 2: parse error: ';' expected - 'node' found.

The 504 that the client's retries absorb reads the same way: Dispatcher_Client::request_read_and_idx::timeout. The server is probably too busy to handle your request.

Why remark is the one to fear

The [timeout:60] in the settings block is a server-side budget. When a query exceeds it, Overpass does not fail — it returns HTTP 200, valid JSON, the elements it managed to collect, and a top-level remark explaining that the query ran out of time.

Give a heavy query a two-second budget and watch:

query = '[out:json][timeout:2];nwr["name"](50.0,-5.0,55.0,1.0);out count;'
for attempt in range(6):                     # retry only the server being busy, not the answer
    r = requests.post(OVERPASS, data={"data": query}, headers=HEADERS, timeout=120)
    if r.status_code not in (429, 504):
        break
    time.sleep(10 * 2 ** attempt)
payload = r.json()
print(f"HTTP {r.status_code}, {len(payload['elements'])} elements, remark: {payload.get('remark')!r}")
HTTP 200, 0 elements, remark: 'runtime error: Query timed out in "query" at line 1 after 3 seconds.'

Code that checks only the status code accepts this as a complete answer — here an empty one, but a query that times out halfway returns the half it has. A weekly job silently returning 60% of the cafés is far worse than one that crashes. Always check for remark, and treat it as an error.

Why the two timeouts are different

There are two, and they are unrelated:

  • [timeout:180] inside the query — how long the Overpass server will spend before giving up and sending a remark.
  • requests.post(..., timeout=300) — how long your client waits for bytes.

The client timeout must be the larger of the two, or you will disconnect from queries the server would have completed. Setting the server timeout high does not make a query faster; it makes a slow query finish rather than truncate.

Five Overpass responses and the handling each one needs. only one of these five is JSONWhat comes backWhat to doHTTP 406, HTML body — User-Agentrefusedname the project and a realcontactHTTP 400, HTML body — query syntaxread the <p><strong> block; neverretryHTTP 504, HTML body — server busyexponential backoff, or a mirrorHTTP 429, HTML body — rate limitedwait for a slot; slow downHTTP 200, JSON with a "remark"treat as failure — result ispartialr.json() raises the same error for the first four spatialworkflow.io
Four of the five are not JSON, and the fifth looks like success. Every one of them turned up while this page was being run.

Why to be polite to the public endpoint

overpass-api.de is donated infrastructure with a shared quota. The etiquette that keeps you unblocked:

  • Send a real User-Agent that names the project and a real contact. On this page both python-requests' default and a you@example.com placeholder got HTTP 406.
  • Ask for out count before out body on anything you have not sized.
  • Cache results locally, so iterating on your analysis does not re-query.
  • Use exponential backoff on 429 and 504 — never a tight retry loop.
  • For anything scheduled or bulk, move to a regional extract or your own instance.

Bounding-box order and node references

  • Bounding box order is (south, west, north, east) — latitude first, the opposite of OSMnx. total_bounds from GeoPandas gives (minx, miny, maxx, maxy), so reorder it explicitly.
  • out body on ways returns node references, not coordinates. Use out geom; for full geometry or out center; for a centroid.
  • ["amenity"!="cafe"] also matches elements with no amenity key at all. Negation in Overpass is not the complement of a set you can enumerate.
  • Areas exist only for objects Overpass has indexed as areas — closed ways and multipolygon relations with certain tags. A neighbourhood mapped only as a place=neighbourhood node has no area, so an area query on it finds nothing. Check with out count first: Ancoats, once a node, is now a closed way and does have one.
  • Regex filters are slower than exact matches. ["name"~"Coffee"] on a large extent can time out where an exact match would not.
  • [date:"2026-01-01T00:00:00Z"] in the settings block queries a historic snapshot, and [diff:...] returns what changed — both are much heavier than a current query.
  • Different instances have different limits, and different reachability. Other public instances exist; when this page was checked, maps.mail.ru answered from this machine and overpass.kumi.systems did not, and overpass-api.de answered only over IPv6. Keep the base URL configurable.

FAQ

Why does r.json() raise JSONDecodeError?

Because the response is an HTML error page, not JSON. Overpass reports syntax errors, rate limits and server load with HTML bodies. Check that the body starts with { before parsing, and scrape the error text when it does not.

What is the remark key?

A server-side timeout. You get HTTP 200, valid JSON and a partial element list. Treat any response containing remark as a failure, or you will silently analyse a fraction of the data.

How do I query "A and B" rather than "A or B"?

Stack tag filters on one element selector: node["amenity"="cafe"]["wheelchair"="yes"]. Separate statements inside ( … ); are a union, which is OR.

Why is my area query so slow?

The area filter is probably unqualified. area["name"="X"] scans globally. Add ["admin_level"="8"] or ["boundary"="administrative"] to hit the index.

What is the difference between out body, out center and out geom?

body returns tags plus node references (no coordinates for ways). center adds a computed centroid. geom inlines the full coordinate list. Use center for point analysis and geom when you need the shape.

How many requests can I make?

Ask the server: https://overpass-api.de/api/status reports the slots for your IP address — Rate limit: 2 at one point while this page was being checked and Rate limit: 4 an hour later — and it returns 429 when you exceed them. Back off exponentially, cache aggressively, and move to your own instance for scheduled work.

Can I query historic OSM data?

Yes, with [date:"…"] in the settings block for a snapshot or [diff:"…","…"] for changes. Both are far more expensive than a current query, so size them with out count first.