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')
Four-stage flow: identify, throttle, cache, back off.
The polite client is barely longer than the impolite one, and it survives contact with a batch.

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.

Triage table of four geocoding responses mapped to four different actions.
Only one of these is fixed by retrying, and one of them is made worse by it.

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.

Bar chart of Nominatim importance scores, with the country highest and the exact building low.
The right ranking for a search box is the wrong ranking for a file of addresses.

Edge cases or notes

  • 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.