Research and public data

openstreetmap.org

OpenStreetMap - Nominatim Geocoding + Overpass API

Notes an AI agent reads before it works on openstreetmap.org: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/openstreetmap.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/openstreetmap"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/openstreetmap/scraping.md
export BH_DOMAIN_SKILLS=1

Two fully public, no-auth APIs. Everything is a direct HTTP call - never need a browser.

  • Nominatim: geocoding (place name → lat/lon and reverse). Rate limit: 1 req/s.
  • Overpass API: spatial query engine over the full OSM dataset. Rate limit: 2 concurrent slots per IP on the public instance.

Do not use http_get without overriding User-Agent - its default Mozilla/5.0 is blocked by both APIs with HTTP 403. Pass headers={"User-Agent": "browser-harness/1.0"} on every call.


Fastest path: forward geocode a place

import json, urllib.parse
from helpers import http_get
 
UA = {"User-Agent": "browser-harness/1.0"}
 
def geocode(query: str, limit: int = 3) -> list[dict]:
    q = urllib.parse.quote(query)
    raw = http_get(
        f"https://nominatim.openstreetmap.org/search?q={q}&format=json&limit={limit}&addressdetails=1",
        headers=UA
    )
    return json.loads(raw)  # [] when nothing found
 
results = geocode("Eiffel Tower")
# results[0]['display_name']  == 'Tour Eiffel, 5, Avenue Anatole France, ..., 75007, France'
# results[0]['lat']            == '48.8582599'   ← STRING, not float
# results[0]['lon']            == '2.2945006'    ← STRING, not float
# results[0]['type']           == 'tower'
# results[0]['class']          == 'man_made'
# results[0]['importance']     == 0.6205937724353116
# results[0]['osm_type']       == 'way'
# results[0]['osm_id']         == 5013364
# results[0]['boundingbox']    == ['48.8574753', '48.8590453', '2.2933119', '2.2956897']  ← all strings
# results[0]['address']['city']     == 'Paris'
# results[0]['address']['postcode'] == '75007'
# results[0]['address']['country']  == 'France'
# results[0]['address']['country_code'] == 'fr'

Nominatim: all three query modes

1. Forward geocode (free-text)

import json, urllib.parse
from helpers import http_get
 
UA = {"User-Agent": "browser-harness/1.0"}
 
raw = http_get(
    "https://nominatim.openstreetmap.org/search?q=Eiffel+Tower&format=json&limit=3&addressdetails=1",
    headers=UA
)
results = json.loads(raw)
# Returns [] when nothing found — no exception
 
# Useful optional params:
# &addressdetails=1   → adds 'address' dict to each result (city, postcode, road, etc.)
# &extratags=1        → adds 'extratags' dict (website, wikidata, phone, etc.)
# &namedetails=1      → adds 'namedetails' dict (name:en, name:fr, etc.)
# &countrycodes=fr,de → restrict to countries (comma-separated ISO 3166-1 alpha-2)
# &viewbox=2.2,48.8,2.4,48.9 &bounded=1  → restrict to bounding box (lon_min,lat_min,lon_max,lat_max)

2. Reverse geocode (lat/lon → address)

raw = http_get(
    "https://nominatim.openstreetmap.org/reverse?lat=48.8584&lon=2.2945&format=json",
    headers=UA
)
result = json.loads(raw)
# result['display_name']  == 'Avenue Gustave Eiffel, Quartier du Gros-Caillou, ..., France'
# result['address']['road']         == 'Avenue Gustave Eiffel'
# result['address']['city']         == 'Paris'
# result['address']['postcode']     == '75007'
# result['address']['country']      == 'France'
# result['address']['country_code'] == 'fr'
# result['address']['state']        == 'Île-de-France'
# result['lat'], result['lon']  → strings (not floats)
 
# Optional: &zoom=N (0-18) controls granularity of the returned address
# zoom=3 → country, zoom=10 → city, zoom=18 → street/building (default)

3. Structured search (field-based)

raw = http_get(
    "https://nominatim.openstreetmap.org/search?city=Paris&country=France&format=json&limit=1",
    headers=UA
)
result = json.loads(raw)[0]
# result['name']          == 'Paris'
# result['lat']           == '48.8534951'
# result['lon']           == '2.3483915'
# result['type']          == 'administrative'
# result['place_rank']    == 12  (lower = broader: 4=country, 8=state, 12=city, 30=POI)
# result['addresstype']   == 'city'
# result['boundingbox']   == ['48.8155755', '48.9021560', '2.2241220', '2.4697602']
 
# Supported structured params: street, city, county, state, country, postalcode

4. Lookup by OSM ID

# Prefix: N=node, W=way, R=relation
raw = http_get(
    "https://nominatim.openstreetmap.org/lookup?osm_ids=W5013364&format=json",
    headers=UA
)
result = json.loads(raw)
# Returns list. Eiffel Tower way: result[0]['name'] == 'Tour Eiffel'
# Supports up to 50 IDs: osm_ids=W5013364,N123456,R789

Nominatim response field reference

FieldTypeNotes
place_idintInternal Nominatim ID - do not cache long-term, can change
osm_typestr"node", "way", or "relation"
osm_idintThe OSM element ID
latstrLatitude as string - convert with float(r['lat'])
lonstrLongitude as string - convert with float(r['lon'])
display_namestrFull human-readable address string
namestrShort name of the place
typestrOSM type tag value: "tower", "administrative", "restaurant", etc.
classstrOSM key: "man_made", "boundary", "amenity", "highway", etc.
addresstypestrSemantic category: "city", "road", "man_made", etc.
place_rankintHierarchy rank: 4=country, 8=state, 12=city, 16=suburb, 30=POI
importancefloat0–1 relevance score (higher = more notable)
boundingboxlist[str][south_lat, north_lat, west_lon, east_lon] - all strings, note unusual order
licencestrODbL attribution string - include in user-facing output
addressdictOnly present with &addressdetails=1 or in reverse results

address dict common keys: road, house_number, quarter, suburb, city_district, city, state, postcode, country, country_code, ISO3166-2-lvl4/6.


Overpass API: query OSM data by tags

Overpass is a read-only query engine over the full OSM planet. It supports finding POIs by tag, radius, bounding box, and combinations.

Endpoint: https://overpass-api.de/api/interpreter Backup instances (use when main is overloaded, which happens often):

  • https://overpass.openstreetmap.fr/api/interpreter - requires non-Mozilla User-Agent

http_get works for GET requests - pass headers={"User-Agent": "browser-harness/1.0"}. For POST, use urllib directly (see example below).

GET query (simplest for http_get)

import json, urllib.parse
from helpers import http_get
 
UA = {"User-Agent": "browser-harness/1.0"}
OVERPASS = "https://overpass.openstreetmap.fr/api/interpreter"
 
def overpass_get(query: str) -> dict:
    url = f"{OVERPASS}?data={urllib.parse.quote(query)}"
    raw = http_get(url, headers=UA)
    if not raw.startswith("{"):
        raise RuntimeError(f"Overpass error (HTML returned): {raw[:200]}")
    return json.loads(raw)
 
# Find cafes in central Paris (bbox: south_lat, west_lon, north_lat, east_lon)
r = overpass_get('[out:json][timeout:25];node["amenity"="cafe"](48.855,2.295,48.862,2.308);out 10;')
# r['version']    == 0.6
# r['generator']  == 'Overpass API 0.7.62.7 375dc00a'
# r['elements']   → list of matching OSM elements
 
for cafe in r['elements']:
    print(cafe['tags'].get('name'), cafe['lat'], cafe['lon'])
# 'Café de l\'Alma' 48.8609068 2.3015143
# 'Le Campanella'   48.8585847 2.3032822
# 'Kozy Bosquet'    48.855445  2.3054013
 
# Find restaurants within 500m radius of a point (around filter)
r = overpass_get(
    '[out:json][timeout:25];node["amenity"="restaurant"](around:500,37.7749,-122.4194);out 10;'
)
for rest in r['elements']:
    print(rest['tags'].get('name'), rest['tags'].get('cuisine',''))
# 'Nepalese Indian Cusine' 'indian;nepali'
# 'Local Diner' 'coffee_shop;italian;burger;seafood'
# 'Moya Cafe' ''

POST query (for complex QL, avoids URL length limits)

import json, urllib.parse, urllib.request, gzip
from helpers import http_get  # http_get is GET-only; use urllib for POST
 
OVERPASS = "https://overpass.openstreetmap.fr/api/interpreter"
 
def overpass_post(query: str) -> dict:
    """POST to Overpass — no URL length limits, preferred for multi-statement QL."""
    data = urllib.parse.urlencode({"data": query}).encode()
    req = urllib.request.Request(
        OVERPASS, data=data, method="POST",
        headers={
            "User-Agent": "browser-harness/1.0",
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept-Encoding": "gzip",
        }
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        body = r.read()
        if r.headers.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
    body = body.decode()
    if not body.startswith("{"):
        raise RuntimeError(f"Overpass error (HTML): {body[:300]}")
    return json.loads(body)
 
# Example: cafes in Paris bbox
r = overpass_post('[out:json][timeout:25];node["amenity"="cafe"](48.855,2.295,48.862,2.308);out 5;')
print(len(r['elements']))  # 5 (or up to 5)

Overpass element structure

Every element in r['elements'] is a dict with at minimum:

{
    "type": "node",          # "node", "way", or "relation"
    "id": 308684349,         # int — OSM element ID (stable, use for dedup)
    "lat": 48.8609068,       # float — ONLY present for node type
    "lon": 2.3015143,        # float — ONLY present for node type
    "tags": {                # dict — all OSM tags on this element
        "amenity": "cafe",
        "name": "Café de l'Alma",
        "name:fr": "Café de l'Alma",
        "outdoor_seating": "yes",
        "payment:credit_cards": "yes",
        "phone": "+33 1 45 51 56 74",
        "opening_hours": "Mo-Sa 08:00-23:00; Su 09:00-19:00",  # optional
        "website": "https://...",                               # optional
        "wheelchair": "yes"                                     # optional
    }
}

For way elements, use out center; to get a center dict with lat/lon instead of a node list:

# way element with out center:
{
    "type": "way",
    "id": 338411946,
    "center": {"lat": 48.8660087, "lon": 2.3153233},  # centroid of the polygon
    "nodes": [3454913623, 3454913707, ...],            # node IDs forming the boundary
    "tags": {"amenity": "cafe", "name": "Café 1902", ...}
}
 
# Query to get both nodes and ways with lat/lon:
query = '[out:json][timeout:25];(node["amenity"="cafe"](48.85,2.29,48.87,2.32);way["amenity"="cafe"](48.85,2.29,48.87,2.32););out center 20;'
r = overpass_get(query)
for el in r['elements']:
    if el['type'] == 'node':
        lat, lon = el['lat'], el['lon']
    else:  # way
        lat, lon = el['center']['lat'], el['center']['lon']
    print(el['tags'].get('name'), lat, lon)

Overpass QL quick reference

[out:json][timeout:25]      # Required header: JSON output, 25s timeout
[maxsize:52428800]          # Optional: 50MB max result size (default is server limit)

node["amenity"="cafe"](south,west,north,east);out N;
#  ↑ bbox order: south_lat, west_lon, north_lat, east_lon
#  Note: DIFFERENT from Nominatim's boundingbox field which is [south,north,west,east]

node["amenity"="cafe"](around:RADIUS_METERS,LAT,LON);out N;

node["amenity"~"cafe|restaurant"](bbox);out N;    # regex match on tag value
node[!"name"](bbox);out N;                        # elements WITHOUT the 'name' tag
node["name"~"Star",i](bbox);out N;               # case-insensitive regex

# Union of types:
(node["amenity"="cafe"](bbox); way["amenity"="cafe"](bbox););out center N;

# Multiple tags (AND logic):
node["amenity"="cafe"]["outdoor_seating"="yes"](bbox);out N;

OSM tile server (reference only, no scraping)

https://{a,b,c}.tile.openstreetmap.org/{z}/{x}/{y}.png
  • Subdomains a, b, c for load balancing
  • z = zoom level 0–19, x/y = tile coordinates
  • Returns 256×256 PNG tiles
  • Policy: max 2 req/s per IP, non-commercial use, must display OSM attribution
  • Tile coordinate calculator: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
  • Bulk tile downloading is prohibited - use Overpass or data extracts instead
# Convert lat/lon to tile coordinates
import math
 
def lat_lon_to_tile(lat, lon, zoom):
    n = 2 ** zoom
    x = int((lon + 180) / 360 * n)
    y = int((1 - math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat))) / math.pi) / 2 * n)
    return x, y
 
x, y = lat_lon_to_tile(48.8582, 2.2945, 14)
url = f"https://a.tile.openstreetmap.org/14/{x}/{y}.png"
# url == 'https://a.tile.openstreetmap.org/14/8281/5646.png'

Rate limits

APILimitEnforcement429 behavior
Nominatim1 req/sSoft - rapid requests work but you get delayed/droppedReturns HTTP 403 if your IP is banned (not 429)
Overpass (main)2 concurrent slots per IPHard - 3rd concurrent req returns HTML error immediatelyHTML error page with rate_limited in body
Overpass (main)Also: query complexity quotaResets over time (~per hour)HTML error page with rate_limited
Tile server2 req/s per IPSoft/hardIP block

Check your Overpass quota:

raw = http_get("https://overpass-api.de/api/status", headers={"User-Agent": "browser-harness/1.0"})
print(raw)
# Connected as: 1728118854
# Rate limit: 2
# 2 slots available now.
# Slot available after: 2026-04-18T11:00:00Z, in 30 seconds.

Handle rate limiting in production:

import time
 
def overpass_get_with_retry(query: str, max_retries: int = 3) -> dict:
    for attempt in range(max_retries):
        url = f"https://overpass.openstreetmap.fr/api/interpreter?data={urllib.parse.quote(query)}"
        raw = http_get(url, headers={"User-Agent": "browser-harness/1.0"})
        if raw.startswith("{"):
            return json.loads(raw)
        if "rate_limited" in raw or "too busy" in raw:
            wait = 2 ** attempt * 10  # 10s, 20s, 40s
            time.sleep(wait)
            continue
        raise RuntimeError(f"Overpass error: {raw[:200]}")
    raise RuntimeError("Overpass: too many retries")

Complete working example

import json, time, urllib.parse, urllib.request, gzip
from helpers import http_get
 
UA = {"User-Agent": "browser-harness/1.0"}
NOMINATIM = "https://nominatim.openstreetmap.org"
OVERPASS   = "https://overpass.openstreetmap.fr/api/interpreter"
 
def geocode(query: str, limit: int = 1) -> list[dict]:
    """Forward geocode — returns [] if nothing found."""
    q = urllib.parse.quote(query)
    raw = http_get(f"{NOMINATIM}/search?q={q}&format=json&limit={limit}&addressdetails=1", headers=UA)
    return json.loads(raw)
 
def reverse_geocode(lat: float, lon: float) -> dict:
    """Reverse geocode — always returns a result (nearest road/place)."""
    raw = http_get(f"{NOMINATIM}/reverse?lat={lat}&lon={lon}&format=json", headers=UA)
    return json.loads(raw)
 
def overpass_get(query: str) -> list[dict]:
    """Run an Overpass QL query, return elements list."""
    url = f"{OVERPASS}?data={urllib.parse.quote(query)}"
    raw = http_get(url, headers=UA)
    if not raw.startswith("{"):
        raise RuntimeError(f"Overpass error: {raw[:200]}")
    return json.loads(raw)["elements"]
 
def overpass_post(query: str) -> list[dict]:
    """POST variant — avoids URL length limits for complex queries."""
    data = urllib.parse.urlencode({"data": query}).encode()
    req = urllib.request.Request(
        OVERPASS, data=data, method="POST",
        headers={"User-Agent": "browser-harness/1.0",
                 "Content-Type": "application/x-www-form-urlencoded",
                 "Accept-Encoding": "gzip"}
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        body = r.read()
        if r.headers.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
    body = body.decode()
    if not body.startswith("{"):
        raise RuntimeError(f"Overpass error: {body[:300]}")
    return json.loads(body)["elements"]
 
# --- Usage examples (validated 2026-04-18) ---
 
# 1. Geocode a landmark
places = geocode("Eiffel Tower", limit=3)
# places[0]['lat']  == '48.8582599'  (string)
# places[0]['lon']  == '2.2945006'   (string)
# places[0]['display_name'] == 'Tour Eiffel, 5, Avenue Anatole France, ..., 75007, France'
# places[0]['address']['city'] == 'Paris'
lat = float(places[0]['lat'])
lon = float(places[0]['lon'])
 
# 2. Reverse geocode the coordinates
addr = reverse_geocode(lat, lon)
# addr['address']['road']    == 'Avenue Gustave Eiffel'
# addr['address']['city']    == 'Paris'
# addr['address']['postcode']== '75007'
# addr['address']['country'] == 'France'
 
# 3. Find nearby cafes (wait 1s between nominatim and overpass if same script)
time.sleep(1)
cafes = overpass_get(
    f"[out:json][timeout:25];node[\"amenity\"=\"cafe\"](around:500,{lat},{lon});out 10;"
)
for cafe in cafes:
    print(f"{cafe['tags'].get('name','?'):30s}  {cafe['lat']:.4f}, {cafe['lon']:.4f}")
# Café de l'Alma                  48.8609, 2.3015
# Le Campanella                   48.8586, 2.3033
 
# 4. Structured city lookup + find restaurants in bounding box
time.sleep(1)
paris = geocode("Paris, France")[0]
bb = paris['boundingbox']  # [south_lat, north_lat, west_lon, east_lon] ← Nominatim order!
# For Overpass: need (south_lat, west_lon, north_lat, east_lon) ← DIFFERENT order
south, north, west, east = bb[0], bb[1], bb[2], bb[3]
# Restrict to center slice to avoid massive result set
center_bbox = f"48.855,2.295,48.865,2.315"
rests = overpass_post(
    f"[out:json][timeout:25];node[\"amenity\"=\"restaurant\"]({center_bbox});out 5;"
)
print(f"Found {len(rests)} restaurants near Paris center")

Gotchas

http_get default UA (Mozilla/5.0) is blocked by both APIs. Always pass headers={"User-Agent": "browser-harness/1.0"}. The headers kwarg in http_get does a .update() so it properly overrides the default. Confirmed: Mozilla/5.0 → 403 on Nominatim; browser-harness/1.0 → 200.

Blocked User-Agent patterns on Nominatim: Mozilla/5.0, python-requests/*, Wget/*. Accepted: any non-generic app-style UA like browser-harness/1.0, MyApp/2.0, curl/7.x. Nominatim policy requires a descriptive UA with contact info, but in practice any non-library string passes.

Nominatim lat/lon are strings, Overpass lat/lon are floats. Always convert Nominatim coordinates: float(result['lat']). Overpass element lat/lon are native Python floats - no conversion needed.

Nominatim boundingbox field order is [south_lat, north_lat, west_lon, east_lon] - NOT [south, west, north, east]. Overpass bbox uses (south_lat, west_lon, north_lat, east_lon). When feeding a Nominatim bounding box into Overpass, you must reorder: f"({bb[0]},{bb[2]},{bb[1]},{bb[3]})".

overpass-api.de main instance is frequently overloaded. Returns HTTP 504 (timeout) or an HTML error page with rate_limited when busy. The FR mirror (overpass.openstreetmap.fr) is usually more responsive but also blocks Mozilla/5.0. Always detect non-JSON responses: if not raw.startswith("{").

Overpass error responses are HTML, not JSON. The API returns HTTP 200 with an HTML error page when rate-limited or when the server is too busy. Always check raw.startswith("{") before parsing.

Overpass rate limit: 2 concurrent slots, NOT 2 requests/s. You can run 2 queries simultaneously. A 3rd concurrent query immediately returns an error. Sequential queries with no sleep between them work fine as long as each completes before the next starts.

out N; limits results to N elements - use it. Without a limit, large bounding boxes can return thousands of elements and hit the 512MB memory limit, returning a maxsize error. Default safe limit: out 50; for exploration, out 500; for bulk collection.

Overpass QL bbox order is (south, west, north, east) - latitude FIRST. This is the opposite of the standard GeoJSON convention [west, south, east, north]. The around: filter uses (around:METERS,LAT,LON) - note lat before lon.

name tag in Overpass is the local-language name. For Paris cafes this is French. English names may appear under name:en but are often absent. Never assume name is in English.

Nominatim /reverse always returns the nearest result - it never returns an empty response (unlike /search). If the coordinates are in the ocean, it still returns the nearest coastline or country.

place_id is internal and ephemeral - do not store it for long-term use. Use osm_type + osm_id for stable references (e.g., way/5013364 for the Eiffel Tower).

Overpass http_get POST workaround: http_get only supports GET. For POST requests (needed to avoid URL length limits for complex multi-statement QL), use urllib.request.Request directly as shown in the overpass_post() example above.

scraping.md · updated Apr 28, 2026 · 2,434 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.