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.
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
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
- 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.
- Use
NFKCpluscasefold. Unicode normalisation folds compatibility forms so visually identical strings hash together, andcasefoldhandles cases thatlower()does not. - 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.
- Skip what is already stored. Resumability needs no checkpoint file: the store is the checkpoint, and
already_doneis the whole resume logic. - 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.
- 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.
- 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.
- 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.
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-AgentorRefereridentifying 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.
Related Jump to heading
- Nominatim Geocoding Pipelines — the parent topic and the ranking model this batch stores.
- Importing Nominatim from an OSM Extract — the answer once the batch stops fitting inside the policy.
- Parsing Nominatim Address Details into Columns — turning stored results into an assertable table.
- Handling Overpass Timeouts and Rate Limits — the same discipline for the query engine.
- Value Standardization & Regex Cleaning — cleaning the input list before any of this starts.
Up one level: Nominatim Geocoding Pipelines.