Batch Geocoding with Nominatim Without Getting Blocked Jump to heading

Geocode tens of thousands of addresses against a shared public service without exceeding its usage policy, and without losing a day’s work when the process dies at input 34,000.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

The public Nominatim instance’s usage policy allows roughly one request per second from a single source and requires an identifying user agent. That ceiling is not negotiable by writing a faster client, so the only levers available are asking fewer times and never asking twice.

Deduplication is the larger of the two. Address lists drawn from real systems repeat heavily — the same office, the same depot, the same postcode centroid — and collapsing them on a normalised key routinely removes a third or more of the work before a single request is made. Caching removes the rest: every result written to durable storage the moment it arrives means a rerun, a crash, or a second job over overlapping data costs nothing.

Where the requests go when a naive batch is progressively improved Five bars showing how many requests a hypothetical fifty thousand row address list actually issues under successive improvements. The naive loop issues all fifty thousand. Trimming whitespace and normalising case collapses exact duplicates and removes roughly a fifth. Normalising punctuation and abbreviations removes more. Persisting every result so a rerun is free removes the entire second run. The remaining distinct addresses are the irreducible work. Most of a geocoding batch is work you do not need to do Naive loop over rows 50,000 requests Trim and case-fold about 40,000 Normalise punctuation about 33,000 Distinct addresses only about 31,000 Second run, with cache near zero Proportions vary by dataset but the shape does not: normalisation and persistence remove far more traffic than client tuning can.
The last bar is the one that matters most in practice, because development reruns outnumber production runs by a wide margin.

Resumability is the third requirement and it falls out of the second for free. If every answer is persisted as it arrives, a run that dies is resumed by skipping the keys already present — no checkpoint file, no bookkeeping.

Runnable solution Jump to heading

python
from __future__ import annotations

import logging
import re
import sqlite3
import time
import unicodedata
from dataclasses import dataclass
from pathlib import Path

import requests

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.nominatim.batch")

ENDPOINT = "https://nominatim.openstreetmap.org/search"
HEADERS = {"User-Agent": "osm-pipeline-example/1.0 (contact@example.org)"}
MIN_INTERVAL = 1.1          # seconds between requests; policy is ~1/sec
_WS = re.compile(r"\s+")
_PUNCT = re.compile(r"[.,;]+")


@dataclass(frozen=True)
class Address:
    street: str
    city: str
    postalcode: str
    country: str

    def key(self) -> str:
        """A normalised, order-stable key used for dedupe and for the cache."""
        parts = (self.street, self.city, self.postalcode, self.country)
        cleaned = []
        for part in parts:
            # NFKC folds compatibility forms; casefold is stronger than lower().
            text = unicodedata.normalize("NFKC", part).casefold()
            text = _PUNCT.sub(" ", text)
            cleaned.append(_WS.sub(" ", text).strip())
        return "|".join(cleaned)


def open_store(path: Path) -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS geocode (
            key         TEXT PRIMARY KEY,
            lat         REAL,
            lon         REAL,
            place_rank  INTEGER,
            osm_type    TEXT,
            osm_id      INTEGER,
            resolved_at TEXT NOT NULL,
            status      TEXT NOT NULL      -- 'ok' or 'no_match'
        )
    """)
    conn.commit()
    return conn


def already_done(conn: sqlite3.Connection, key: str) -> bool:
    row = conn.execute("SELECT 1 FROM geocode WHERE key = ?", (key,)).fetchone()
    return row is not None


def geocode_one(session: requests.Session, addr: Address) -> dict | None:
    """One structured, country-constrained lookup. Returns the top candidate."""
    params = {
        "street": addr.street,
        "city": addr.city,
        "postalcode": addr.postalcode,
        "countrycodes": addr.country,      # removes the wrong-country class entirely
        "format": "jsonv2",
        "addressdetails": 1,
        "limit": 1,
    }
    response = session.get(ENDPOINT, params=params, timeout=30)
    if response.status_code in (429, 503):
        wait = float(response.headers.get("Retry-After", 30))
        logger.warning("throttled, sleeping %.0fs", wait)
        time.sleep(wait)
        return geocode_one(session, addr)
    response.raise_for_status()
    results = response.json()
    return results[0] if results else None


def run_batch(addresses: list[Address], store: Path) -> None:
    conn = open_store(store)
    session = requests.Session()
    session.headers.update(HEADERS)

    # Dedupe on the normalised key, preserving one representative per key.
    unique: dict[str, Address] = {}
    for addr in addresses:
        unique.setdefault(addr.key(), addr)
    logger.info("%d row(s) collapsed to %d distinct address(es)",
                len(addresses), len(unique))

    pending = [(k, a) for k, a in unique.items() if not already_done(conn, k)]
    logger.info("%d already cached, %d to fetch", len(unique) - len(pending), len(pending))

    last = 0.0
    for i, (key, addr) in enumerate(pending, start=1):
        # Throttle on the wall clock, not with a fixed sleep: local work is free.
        gap = MIN_INTERVAL - (time.monotonic() - last)
        if gap > 0:
            time.sleep(gap)
        last = time.monotonic()

        hit = geocode_one(session, addr)
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        if hit is None:
            conn.execute(
                "INSERT INTO geocode (key, resolved_at, status) VALUES (?, ?, 'no_match')",
                (key, now))
        else:
            conn.execute(
                "INSERT INTO geocode (key, lat, lon, place_rank, osm_type, osm_id,"
                " resolved_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'ok')",
                (key, float(hit["lat"]), float(hit["lon"]),
                 int(hit.get("place_rank", -1)), hit.get("osm_type"),
                 int(hit.get("osm_id", 0)), now))
        conn.commit()          # commit per row: a crash loses at most one lookup
        if i % 100 == 0:
            logger.info("%d/%d fetched", i, len(pending))

    conn.close()


if __name__ == "__main__":
    sample = [
        Address("Rynek Główny 1", "Kraków", "31-042", "pl"),
        Address("rynek glowny 1 ", "KRAKÓW", "31-042", "pl"),   # same key
    ]
    run_batch(sample, Path("geocode.sqlite"))

Step-by-step walkthrough Jump to heading

  1. Normalise into a key, not into the query. The key is casefolded, punctuation-stripped and whitespace-collapsed, but the query still sends the original strings. Normalising the query itself can degrade matching; normalising only the cache key cannot.
  2. Use NFKC plus casefold. Unicode normalisation folds compatibility forms so visually identical strings hash together, and casefold handles cases that lower() does not.
  3. Dedupe before counting the work. The log line reporting rows collapsed to distinct addresses is the first useful number in the run, and it is usually a pleasant surprise.
  4. Skip what is already stored. Resumability needs no checkpoint file: the store is the checkpoint, and already_done is the whole resume logic.
  5. Query structurally, with a country constraint. Separate street, city and postcode fields remove parser guesswork, and the country code removes the wrong-country failure class entirely.
  6. Throttle on the clock, not with a fixed sleep. Sleeping a full second after each request adds the request’s own duration on top. Measuring the gap since the last request keeps the actual rate at the intended one.
  7. Record no-match as a result. An address that does not resolve is an answer worth storing; without it, every rerun retries the same hopeless inputs at one per second.
  8. Commit per row. The write cost is negligible next to a one-second interval, and it means a crash loses at most a single lookup.
The batch loop, from raw rows to a durable result store Four stages. Normalising builds a stable key from each address without changing the query that will be sent. Deduplicating collapses rows sharing a key so each distinct address is fetched once. The fetch stage throttles on the wall clock, sends a structured country-constrained query, and handles a throttle response by waiting the interval the server named. The persist stage writes each result, including explicit no-match results, and commits immediately so the store doubles as the resume checkpoint. Four stages, and the store is the checkpoint normalise build a stable key query keeps originals dedupe one fetch per key usually a third fewer fetch throttle on the clock structured, constrained persist store no-match too commit every row Storing an explicit no-match is what stops each rerun from retrying every hopeless input at one request per second.
There is no separate checkpoint file because there does not need to be one: the result store already knows what is finished.
What to store per geocode and what each stored field prevents Three panels describing the durable record. The coordinate and object reference panel notes that both are needed because the coordinate answers where and the reference answers what, and that storing only the coordinate makes later re-resolution impossible. The quality panel covers place rank and status, which together distinguish a real address match from a coarser fallback and from an input that matched nothing. The provenance panel covers the resolution timestamp, which is what makes later coordinate drift explainable rather than alarming. Three groups of stored fields, three problems avoided Where and what Latitude and longitude OSM type and identifier Coordinate answers where Reference answers what Without the reference you cannot re-resolve later How good Place rank of the match Explicit ok or no-match Rank exposes a fallback Status stops retry loops A city rank for a house number is not a match When Resolution timestamp UTC, not local Explains later drift Drives re-resolution Without it, every change looks like a regression The middle panel is the one most often omitted, and its absence is why fallback coordinates end up stored as if they were buildings.
Each group answers a question a later maintainer will definitely ask, and none of them can be reconstructed after the fact.

Verification Jump to heading

  • The dedupe ratio is reported and plausible. A list of fifty thousand rows collapsing to fifty thousand distinct keys means normalisation is not working.
  • The observed rate is at or below one per second. Time a hundred-row run; it must take at least a hundred seconds.
  • A rerun fetches nothing. Run the same batch twice; the second run should report everything cached and issue no requests.
  • A killed run resumes correctly. Interrupt mid-batch and restart; the fetch count should drop by exactly the number already stored.
  • Place rank is stored and inspected. Query the store for results whose rank is coarser than an address; those are fallbacks, not matches, and should be reviewed rather than used.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
HTTP 403 on every request No identifying User-Agent Set an agent naming the project and a contact address
Persistent 429 despite sleeping Sleep placed after the request, not measured between Throttle on elapsed monotonic time since the last call
Rerun re-fetches everything Key built from the raw string Normalise with NFKC and casefold before hashing
Every rerun retries the same failures No-match results not persisted Store an explicit no_match status row
Coordinates in a neighbouring country No country constraint Pass countrycodes on every structured query
Crash loses hours of work Commit deferred to the end Commit after each row; the cost is negligible
Suspiciously round coordinates Result is a postcode or city centroid Check place_rank and reject coarse fallbacks

Specification reference Jump to heading

The Nominatim usage policy for the public instance limits clients to an absolute maximum of one request per second, requires a valid User-Agent or Referer identifying the application, and asks that results be cached rather than re-requested. Bulk geocoding of large address lists against the public instance is explicitly outside the policy. See the Nominatim usage policy for the current wording and the recommendation to self-host beyond small volumes.

Frequently Asked Questions Jump to heading

Can I run several workers to go faster?

Not against the public instance. The limit applies to your source, not to each process, so parallel workers simply reach the same ceiling faster and then get blocked. If the batch genuinely needs to finish faster, the answer is a private import, which removes the limit entirely. Within the public instance the only real speed-ups are asking fewer times through deduplication and never asking twice through caching.

Should I normalise the address before sending it?

Normalise the cache key aggressively and the query barely at all. The geocoder’s own tokeniser handles punctuation and case perfectly well, and over-normalising the query — stripping accents, expanding abbreviations incorrectly — can turn a match into a miss. Keeping the two separate lets deduplication be as aggressive as you like without any risk to matching quality.

What should I store for each result?

The coordinate, the matched object’s type and identifier, the place rank, the resolution timestamp, and an explicit status for inputs that did not match. The timestamp makes later drift explainable, the place rank makes fallbacks detectable, and the explicit no-match status is what stops every rerun from retrying hopeless inputs at one request per second.

Is it acceptable to geocode a hundred thousand addresses this way?

Not against the public instance. Even perfectly throttled, that is well over a day of continuous requesting against a shared volunteer-funded service, and the usage policy names bulk geocoding as out of scope. Deduplicate first and you may find the distinct count is far smaller than the row count; if it is still in the tens of thousands, import your own instance instead.

Up one level: Nominatim Geocoding Pipelines.