How to Geocode with Nominatim from Python Without Being Blocked
Problem statement
Nominatim is the OpenStreetMap geocoder. The public instance at nominatim.openstreetmap.org is free, needs no key, and is the fastest way to geocode a handful of addresses from Python.
It is also a demonstration service running on donated hardware, with a usage policy that most scripts violate on their first run: no more than one request per second, a genuine User-Agent identifying your application, no parallel requests, and no bulk geocoding. Ignore it and you get HTTP 403 โ for your whole IP address, sometimes for a long time.
The good news is that the polite client is barely longer than the impolite one, and everything it does (throttling, caching, retrying, identifying itself) is what you want in a production geocoder anyway.
Quick answer
One session, one identifying header, one enforced gap between requests, one cache:
import time
import requests
class NominatimClient:
"""A client that cannot exceed its rate limit by construction."""
def __init__(self, user_agent, base="https://nominatim.openstreetmap.org",
min_interval=1.1):
self.base = base.rstrip("/")
self.min_interval = min_interval
self._last = 0.0
self.session = requests.Session()
self.session.headers.update({"User-Agent": user_agent,
"Accept-Language": "en"})
def _wait(self):
gap = time.monotonic() - self._last
if gap < self.min_interval:
time.sleep(self.min_interval - gap)
self._last = time.monotonic()
def search(self, query=None, **structured):
self._wait()
params = {"format": "jsonv2", "limit": 1, "addressdetails": 1}
params.update({"q": query} if query else structured)
r = self.session.get(f"{self.base}/search", params=params, timeout=30)
r.raise_for_status()
return r.json()
>>> nom = NominatimClient("acme-analytics/1.0 ([email protected])")
>>> hit = nom.search("10 Downing Street, London, UK")[0]
>>> hit["lat"], hit["lon"], hit["addresstype"]
('51.5034878', '-0.1276965', 'office')
Step-by-step solution
1. Identify yourself properly
The User-Agent is not a formality. Requests with a default library agent โ python-requests/2.x โ are blocked, and the policy asks for an application name and a contact route. Put a real project name and a real address in it. If your traffic causes a problem, the alternative to a block is an email.
The same applies to a Referer for browser-side use. Server-side, the User-Agent is the one that matters.
2. Enforce the rate limit in the client, not in the caller
A time.sleep(1) scattered through calling code is a rate limit that survives until somebody writes a loop without it. Putting the wait inside the client, keyed off the last request time, makes the limit a property of the object.
Note the 1.1 second interval in the example rather than 1.0. Clock granularity and network jitter mean an exact one-second cadence produces occasional sub-second gaps, and the server measures arrival times, not your intentions.
3. Prefer structured queries to free text
Nominatim accepts either a single q string or separate street, city, county, state, postalcode and country parameters. The structured form skips the parsing stage entirely, which is where free-text geocoding loses most of its accuracy:
hit = nom.search(street="10 Downing Street", city="London",
postalcode="SW1A 2AA", country="United Kingdom")
You cannot mix the two forms in one request. If your data is already parsed into components โ and if you have followed the parsing guide, it is โ use the structured form.
4. Read the fields that describe the match
A Nominatim result is far more than a coordinate pair. The fields that decide whether to trust it:
| Field | What it tells you |
|---|---|
addresstype |
the precision level: office, road, postcode, city, country |
category / type |
what kind of feature matched โ highway/residential, place/postcode |
importance |
the ranking score, roughly 0โ1, used to order candidates |
boundingbox |
the extent of the matched feature โ a proxy for how vague the answer is |
display_name |
the full address as OSM understands it, for eyeballing mismatches |
The bounding box is the most underused. Measured on one real ladder of queries, the matched feature's diagonal span went from 55 m for the building, to 97 m for the street, 1,975 m for the postcode, 73.8 km for the city, and 1,615 km for the country. A result whose bounding box spans a country is not a location.
5. Handle the two failure shapes
Nominatim fails in two ways and only one of them looks like a failure:
- HTTP 403 / 429 โ you are being rate limited or blocked. Back off exponentially; do not retry immediately, and do not switch to threads.
- HTTP 200 with
[]โ no match. This is the normal outcome for a typo or an address OSM does not have. Measured directly:"10 Downng Stret, Londn"returns status 200 and an empty list.
Code that only checks raise_for_status() treats the second case as success and quietly writes None coordinates into the output.
6. Cache before you loop
Address files repeat. Cache on the normalised query string, persist the cache to disk, and a re-run of the same file costs nothing and hits the service zero times. This single step is what turns a policy-violating batch job into a compliant one.
Code examples
Example 1 โ the full polite client, with cache and backoff
import json
import sqlite3
import time
import requests
class CachedNominatim:
def __init__(self, user_agent, cache="geocode_cache.sqlite",
base="https://nominatim.openstreetmap.org", min_interval=1.1):
self.base, self.min_interval, self._last = base.rstrip("/"), min_interval, 0.0
self.session = requests.Session()
self.session.headers["User-Agent"] = user_agent
self.db = sqlite3.connect(cache)
self.db.execute("""create table if not exists cache (
key text primary key, response text, fetched_at text)""")
self.db.commit()
def _wait(self):
gap = time.monotonic() - self._last
if gap < self.min_interval:
time.sleep(self.min_interval - gap)
self._last = time.monotonic()
def _fetch(self, params, attempts=4):
for attempt in range(attempts):
self._wait()
r = self.session.get(f"{self.base}/search", params=params, timeout=30)
if r.status_code in (429, 502, 503, 504):
wait = 2 ** attempt * 5
print(f" {r.status_code}; backing off {wait}s")
time.sleep(wait)
continue
if r.status_code == 403:
raise PermissionError(
"403 from Nominatim โ the usage policy has been breached. "
"Stop, and move to your own instance before retrying.")
r.raise_for_status()
return r.json()
raise RuntimeError("gave up after repeated transient errors")
def geocode(self, query=None, **structured):
params = {"format": "jsonv2", "limit": 1, "addressdetails": 1}
params.update({"q": query} if query else structured)
key = json.dumps(params, sort_keys=True)
row = self.db.execute("select response from cache where key = ?", (key,)).fetchone()
if row:
return json.loads(row[0])
data = self._fetch(params)
self.db.execute("insert or replace into cache values (?, ?, datetime('now'))",
(key, json.dumps(data)))
self.db.commit()
return data
The cache stores misses as well as hits. A row that failed to geocode will fail again identically, and re-asking is the fastest way to get blocked.
Example 2 โ turning a response into a decision
PRECISION = {
"house": 0, "building": 0, "office": 0, "amenity": 0, "shop": 0,
"road": 1, "postcode": 2, "suburb": 3, "village": 3, "town": 3,
"city": 4, "county": 5, "state": 5, "country": 6,
}
def interpret(hits, worst_allowed="postcode"):
if not hits:
return {"status": "no_match"}
top = hits[0]
level = top.get("addresstype", "unknown")
rank = PRECISION.get(level, 9)
south, north, west, east = (float(v) for v in top["boundingbox"])
span_deg = max(north - south, east - west)
return {
"status": "ok" if rank <= PRECISION[worst_allowed] else "too_coarse",
"lat": float(top["lat"]), "lon": float(top["lon"]),
"precision": level, "rank": rank,
"importance": float(top.get("importance", 0)),
"bbox_span_km": round(span_deg * 111, 1),
"matched": top["display_name"],
}
>>> interpret(nom.search("London, UK"))
{'status': 'too_coarse', 'lat': 51.5074456, 'lon': -0.1277653,
'precision': 'city', 'rank': 4, 'importance': 0.8921,
'bbox_span_km': 73.8, 'matched': 'Greater London, England, United Kingdom'}
Example 3 โ running your own instance and pointing the same code at it
# docker run -it --rm -p 8080:8080 \
# -e PBF_URL=https://download.geofabrik.de/europe/great-britain-latest.osm.pbf \
# -e REPLICATION_URL=https://download.geofabrik.de/europe/great-britain-updates/ \
# -e IMPORT_WIKIPEDIA=false \
# --shm-size=1g mediagis/nominatim:4.4
local = CachedNominatim(
"acme-analytics/1.0 ([email protected])",
base="http://localhost:8080",
min_interval=0.0, # your hardware, your rules
)
The only two lines that change are the base URL and the interval. That is the argument for writing the client this way: the decision to self-host stops being a rewrite and becomes a configuration change, which means you can defer it until the volume justifies it.
A country-sized import needs tens of gigabytes of disk and runs for hours; a planet import is a different order of undertaking. Start with the smallest extract that covers your data.
Explanation
Why the policy exists and what happens when it is broken
The public instance is funded by donation and shared by everybody. The one-request-per-second rule is what keeps it usable, and the enforcement is automated: sustained excess traffic, missing or generic User-Agent strings, and parallel connections all trigger blocks at the IP level.
A block is not a rate limit you can retry through. It is a 403 that persists, and because it is per IP, it affects everyone behind your office NAT. The correct response to a 403 is to stop, not to slow down slightly.
Why threads make it worse rather than faster
The instinct when 40,000 addresses will take eleven hours at one per second is to parallelise. That converts a slow compliant job into a fast blocked one โ the policy limits requests per second in total, not per thread.
The three legitimate ways to make it faster, in order of effort: deduplicate the input (a customer file usually contains far fewer distinct addresses than rows), cache so repeats are free, and then self-host, where parallelism becomes your problem to tune rather than a policy breach.
Why importance is not a confidence score
importance in a Nominatim result is derived from the prominence of the feature โ roughly, how notable OpenStreetMap and Wikipedia consider it โ not from how well it matched your query. The country of the United Kingdom scores 0.9389; the actual building at 10 Downing Street scores 0.5506.
Ranking candidates by importance therefore biases towards big, famous things. When "Springfield, USA" returns Springfield, Illinois with an importance of 0.6126, the geocoder has told you which Springfield is most famous, not which one you meant.
Why structured queries beat a single string
Free-text search has to guess where the street ends and the town begins, in a language and a format it must infer. The structured endpoint skips that: you have already decided that "High Street" is the street and "Camden" is the city.
The gain is largest exactly where free text is worst โ addresses with unusual ordering, business names in the first line, and multi-country files where a comma means different things in different countries.
Edge cases or notes
countrycodes=gbremoves most cross-border mismatches and costs nothing.limit=1hides ambiguity. Ask for 3 during development and compare the top two scores; a small gap means the geocoder guessed.Accept-Languagechanges the returned names, not the coordinates. Set it explicitly so results are reproducible.- The
/lookupendpoint resolves known OSM ids and is far cheaper than searching when you already have one. /reversehas the same rate limit. Reverse geocoding a track of GPS points is bulk geocoding.- Nominatim will not find what OSM does not contain. Coverage of rural and non-European addresses varies widely; check against your own sample.
- Postcode-only queries return the postal unit, whose bounding box spanned 1,975 m in the measured case โ fine for a district map, not for a delivery route.
- Cache negative results too, with a timestamp, and expire them after a few months rather than never.
Internal links
- Geocoding explained: from an address string to a coordinate โ what the response fields mean
- How to geocode addresses in Python โ the general pattern across providers
- How to cache geocoding results so a rerun costs nothing โ the cache in detail
- Fixing 429 too many requests when geocoding โ when it has already gone wrong
- How to batch geocode thousands of addresses in Python โ the volume case
- How to build an offline geocoder from open address data โ the alternative to any API
- Choosing a geocoder: coverage, licence and cost compared โ whether this is the right one
- How to cache downloaded GIS data in Python โ the same discipline for files
FAQ
Do I need an API key for Nominatim?
No, and that is why the usage policy matters. Identification is by User-Agent, so put a real application name and contact address in it.
How many addresses can I geocode per second?
One, against the public instance, sequentially. Use 1.1 seconds between requests to absorb jitter. Against your own instance, as many as your hardware allows.
Why am I getting 403 Forbidden?
You have breached the usage policy โ too fast, in parallel, or with a generic User-Agent. Stop the job. Retrying through a block extends it; fix the client and consider self-hosting.
What does an empty response mean?
No match. Nominatim returns HTTP 200 with an empty array, so check the list length rather than only the status code.
Should I use q or the structured parameters?
Structured, if your addresses are already parsed into components. It skips Nominatim's own parsing, which is where free-text geocoding loses most of its accuracy.
Is importance a measure of match quality?
No. It measures how prominent the matched feature is, not how well it matched your text. The United Kingdom scores 0.94; the building at 10 Downing Street scores 0.55.