How to Geocode with Nominatim from Python Without Being Blocked

On this page

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(USER_AGENT)
hit = nom.search("10 Downing Street, London, UK")[0]
print(hit["lat"], hit["lon"], hit["addresstype"])
51.5034878 -0.1276965 office
The four properties of a compliant Nominatim client: identify, throttle, cache, back off. the compliant client, in four partsidentifyreal User-Agentthrottle1.1 s in the clientcachehits and missesback off429 yes, 403 stopEvery one of these is what you want in production anyway spatialworkflow.io
The polite client is barely longer than the impolite one, and it survives contact with a batch.

Identify yourself, rate-limit in the client, prefer structured queries

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")
print(hit[0]["display_name"] if hit else "no match")
10 Downing Street, 10, Downing Street, Westminster, Covent Garden, City of Westminster, Greater London, England, SW1A 2AA, 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.

Four responses from a geocoding service and the different action each one requires. four responses, four actionswhat came backwhat to doHTTP 200 with an empty listno match โ€” widen the query oraccept itHTTP 429 Too Many Requestshonour Retry-After, back off,continueHTTP 403 Forbiddenpolicy block โ€” stop the job, fixthe clienttimeout or 502transient โ€” retry with jitterA log that records all four as "failed" cannot tell you which one dominates spatialworkflow.io
Only one of these is fixed by retrying, and one of them is made worse by it.

A polite client with cache and backoff

The data used on this page

Every call on this page goes to the public Nominatim server run by the OpenStreetMap Foundation, so the results are whatever OpenStreetMap held when the page was last run. USER_AGENT identifies the application, as the usage policy requires. Replace it with your own project's name and contact before you run anything, and keep to one request a second.

USER_AGENT = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"

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
cached = CachedNominatim(USER_AGENT)
for attempt in ("first", "second"):
    start = time.perf_counter()
    cached.geocode("10 Downing Street, London, UK")
    print(f"{attempt} call: {time.perf_counter() - start:.3f} s")
first call: 0.196 s
second call: 0.000 s

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"],
    }
print(interpret(cached.geocode("London, UK")))
{'status': 'too_coarse', 'lat': 51.5074456, 'lon': -0.1277653, 'precision': 'city', 'rank': 4, 'importance': 0.8920997258748663, 'bbox_span_km': 93.7, '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-260101.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(
    USER_AGENT,
    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.

Example 4 โ€” the precision ladder, measured

from math import asin, cos, radians, sin, sqrt


def diagonal_m(bbox):
    """Great-circle length of a Nominatim bounding box's diagonal, in metres."""
    south, north, west, east = (radians(float(v)) for v in bbox)
    h = sin((north - south) / 2) ** 2 + cos(south) * cos(north) * sin((east - west) / 2) ** 2
    return 2 * 6_371_000 * asin(sqrt(h))


for q in ["10 Downing Street, London", "Downing Street, London", "SW1A 2AA",
          "London, UK", "United Kingdom", "Springfield, USA"]:
    top = cached.geocode(q)[0]
    print(f"{q:26} {top['addresstype']:9} importance {float(top['importance']):.4f}  "
          f"bbox diagonal {diagonal_m(top['boundingbox']):>12,.0f} m  {top['display_name'][:38]}")
print("misspelt:", cached.geocode("10 Downng Stret, Londn"))
10 Downing Street, London  office    importance 0.5506  bbox diagonal           55 m  10 Downing Street, 10, Downing Street,
Downing Street, London     road      importance 0.4736  bbox diagonal           97 m  Downing Street, Westminster, Covent Ga
SW1A 2AA                   postcode  importance 0.0933  bbox diagonal        1,975 m  SW1A 2AA, City of Westminster, Greater
London, UK                 city      importance 0.8921  bbox diagonal       73,804 m  Greater London, England, United Kingdo
United Kingdom             country   importance 0.9389  bbox diagonal    1,615,487 m  United Kingdom
Springfield, USA           city      importance 0.6126  bbox diagonal       30,121 m  Springfield, Sangamon County, Illinois
misspelt: []

Why the usage policy exists

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.

Nominatim importance scoring the United Kingdom at 0.94 and the building at 10 Downing Street at 0.55. importance measures fame, not fitUnited Kingdom0.9389Greater London0.8921Springfield, Illinois0.612610 Downing Street0.5506Downing Street0.473601Sorting a batch by this score puts the least useful answers at the top spatialworkflow.io
The right ranking for a search box is the wrong ranking for a file of addresses.

Country codes, single results and languages

  • countrycodes=gb removes most cross-border mismatches and costs nothing.
  • limit=1 hides ambiguity. Ask for 3 during development and compare the top two scores; a small gap means the geocoder guessed.
  • Accept-Language changes the returned names, not the coordinates. Set it explicitly so results are reproducible.
  • The /lookup endpoint resolves known OSM ids and is far cheaper than searching when you already have one.
  • /reverse has 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.

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.