Geocoding with Nominatim: Why You Must Set a User-Agent
Languages: curl, Python, JavaScript · Estimated time: ~6 minutes · Nominatim (OpenStreetMap) live status ↗
The missing User-Agent header breaks more Nominatim integrations than anything else. This tutorial covers the correct setup, rate limiting, batching strategies, and when to fall back to OpenCage.
Nominatim's User-Agent requirement is the number one reason integrations fail. Requests without a User-Agent return HTTP 403 or get silently dropped. Set it correctly and Nominatim is one of the most reliable free APIs we monitor.
Live status at /nominatim.
The Correct User-Agent Format
The OSM Usage Policy requires a User-Agent that identifies your application and a way to contact you. The format is informal — any non-empty string that identifies your app is technically acceptable. In practice:
# Wrong — will be blocked
curl "https://nominatim.openstreetmap.org/search?q=London&format=json"
# Correct — identify your app and contact
curl -H "User-Agent: MyWeatherApp/1.0 ([email protected])" \
"https://nominatim.openstreetmap.org/search?q=London&format=json&limit=1"
# Response:
# [{"lat":"51.5074456","lon":"-0.1277653","display_name":"London, Greater London, England, United Kingdom",...}] Python: Forward Geocoding with Rate Limit
import requests
import time
from functools import lru_cache
NOMINATIM_BASE = "https://nominatim.openstreetmap.org"
HEADERS = {"User-Agent": "MyApp/1.0 ([email protected])"}
@lru_cache(maxsize=1000)
def geocode(query: str) -> dict | None:
"""
Forward geocode a query string. Results cached in memory.
"""
resp = requests.get(
f"{NOMINATIM_BASE}/search",
params={"q": query, "format": "json", "limit": 1},
headers=HEADERS,
timeout=10
)
resp.raise_for_status()
results = resp.json()
if not results:
return None
r = results[0]
return {
"lat": float(r["lat"]),
"lon": float(r["lon"]),
"display_name": r["display_name"]
}
def reverse_geocode(lat: float, lon: float) -> dict | None:
"""
Reverse geocode coordinates to address.
"""
resp = requests.get(
f"{NOMINATIM_BASE}/reverse",
params={"lat": lat, "lon": lon, "format": "json"},
headers=HEADERS,
timeout=10
)
resp.raise_for_status()
return resp.json()
# Geocode multiple locations with rate limiting
locations = ["Paris, France", "Berlin, Germany", "Tokyo, Japan"]
results = []
for loc in locations:
result = geocode(loc)
if result:
results.append(result)
time.sleep(1) # Respect 1 req/sec limit JavaScript: Geocoding in the Browser
For browser-side geocoding, note that the same User-Agent requirement applies — but browsers don't let you set the User-Agent header (it's a forbidden header). Nominatim generally works from browsers anyway because the browser sets its own User-Agent. For server-side Node.js, you must set it explicitly.
// Node.js server-side (must set User-Agent)
async function geocode(query) {
const url = new URL("https://nominatim.openstreetmap.org/search");
url.searchParams.set("q", query);
url.searchParams.set("format", "json");
url.searchParams.set("limit", "1");
const res = await fetch(url, {
headers: { "User-Agent": "MyApp/1.0 ([email protected])" }
});
const results = await res.json();
return results[0] ?? null;
}
// Simple rate limiter
let lastCall = 0;
async function rateLimitedGeocode(query) {
const now = Date.now();
const elapsed = now - lastCall;
if (elapsed < 1000) {
await new Promise(r => setTimeout(r, 1000 - elapsed));
}
lastCall = Date.now();
return geocode(query);
} When Nominatim Isn't Enough
Nominatim struggles with: partial addresses, business name searches, non-Latin scripts (quality varies), and autocomplete (it's designed for complete queries). If you're hitting these walls:
OpenCage (2,500 calls/day free, key required) aggregates multiple geocoding backends including OSM, and handles these edge cases better. See /opencage for current status and our comparison of geocoding APIs at /category/geocoding.