Batch Geocoding with geopy Without Rate-Limit Errors

Twelve thousand addresses pushed through geolocator.geocode in a plain .apply() will collect an HTTP 429 inside the first second and a blocked IP inside the first minute, and the run will look like it simply found nothing. This guide is for anyone who has to geocode a whole column and needs the job to finish, finish politely, and survive being killed halfway through; it sits under Geocoding & Address Pipelines in Geospatial Data Ingestion & Processing Workflows.

Why This Approach / What Goes Wrong

geopy is a client library, not a scheduler. It sends a request the moment you call it, so a pandas .apply() over a column fires as fast as the interpreter and the socket allow — typically twenty to fifty calls a second. The public OpenStreetMap endpoint permits one. The mismatch is not marginal, it is two orders of magnitude, and the server's answer is a 429 followed by a block keyed to your IP address and user agent together.

The obvious patch — time.sleep(1) at the bottom of a loop — is wrong in two directions at once. It adds a second on top of however long the request took, so a run that should take 1.0 s per address takes 1.6 s, and it does nothing at all about failures. RateLimiter fixes the first half properly: it records the clock time at which the previous call started and sleeps only the remainder of min_delay_seconds, making the delay a floor on the interval rather than an addition to it. A call that itself took 0.7 s waits 0.4 s more, not 1.1 s more.

The second half is failure handling, and this is where the defaults betray you. RateLimiter is constructed with swallow_exceptions=True, which means any GeocoderServiceError that outlives max_retries is caught and turned into return_value_on_exception, whose default is None — the exact value the geocoder returns when it honestly found no match. A run that hit a wall of 429s and a run against a column of nonsense addresses produce byte-identical output. Neither the retry counter nor the server's Retry-After header is anywhere in that output.

Unpaced burst versus a 1.1 second paced request stream Two request timelines drawn over the same stretch of time. The upper timeline is a bare pandas apply: eight calls leave in half a second, the first two return 200 OK and the rest come back as HTTP 429 Too Many Requests, after which a dashed bar shows every later request being refused no matter how slowly it is sent. The lower timeline wraps the same geocoder in RateLimiter with min_delay_seconds set to 1.1, spacing six calls 1.1 seconds apart; all six return 200 OK and no 429 is ever issued. The closing note is that pacing costs about a second per address and always finishes, whereas the burst costs nothing and then goes nowhere. Two ways to spend the same minute Bare loop — sites["addr"].apply(geolocator.geocode) 8 calls in 0.5 s HTTP 429 Too Many Requests the server stops answering blocked — later requests are refused however slowly you send them RateLimiter(min_delay_seconds=1.1) — one call at a time 1.1 s 1.1 s 1.1 s 1.1 s 1.1 s 200 OK 200 OK 200 OK 200 OK 200 OK 200 OK Pacing costs about a second per address and always finishes; the burst costs nothing and then goes nowhere.
The delay is a floor on the interval between call starts, which is why a slow response does not add to it — and why a burst can never be recovered by slowing down afterwards.

The third failure is operational rather than protocol-level. A paced run over twelve thousand distinct addresses takes about three and a half hours. Anything that lasts that long will eventually be interrupted — a laptop lid, an OOM killer, a dropped VPN — and an in-memory result list evaporates with it. The fix is not a bigger try/except but a durable store written as the run proceeds, so that the second attempt starts where the first stopped instead of at zero.

Prerequisites

python -m pip install "geopy>=2.4,<3" "geopandas>=1.0" "shapely>=2.0" \
                      "pandas>=2.2" "pyproj>=3.6"

The on-disk store below uses sqlite3 from the standard library, so there is nothing further to install and no server to run. One environment detail is worth knowing before you debug a timeout: geopy selects geopy.adapters.RequestsAdapter when requests is importable and falls back to URLLibAdapter when it is not, so connection reuse, proxy resolution and TLS behaviour can differ between two machines running identical geopy versions. Pin requests explicitly if your pacing measurements need to be reproducible.

Step-by-Step Implementation

1. Build one geocoder with a real identity. The rate limit is counted against the pair of your IP address and your user agent, so the user agent is not decoration — it is the thing being throttled. Name the application and give a contact address; the library default is answered with 403, not with data.

from geopy.geocoders import Nominatim

geolocator = Nominatim(
    user_agent="permit-audit/0.4 (data-team@cityworks.example)",
    timeout=10,          # seconds before geopy raises GeocoderTimedOut
)

2. Wrap it in RateLimiter and switch off the dangerous defaults. Every argument here is a deliberate override, not boilerplate.

from geopy.extra.rate_limiter import RateLimiter

geocode = RateLimiter(
    geolocator.geocode,
    min_delay_seconds=1.1,     # floor on the interval between call STARTS
    max_retries=2,             # 2 retries = 3 attempts, and only for
                               # GeocoderServiceError subclasses
    error_wait_seconds=5.0,    # flat pause between attempts — not exponential
    swallow_exceptions=False,  # a failure must raise, never masquerade as None
)

swallow_exceptions=False is the single most important line on this page. With it, an exhausted retry chain raises and your own handler decides what the row means; without it, the same chain returns None and the row is filed as "address not found". Note also that error_wait_seconds is flat — three attempts against an overloaded service are spaced 5 s, 5 s, which is exactly the pattern a server under load least wants, and it is blind to the Retry-After header the server actually sent.

3. Give every failure mode a different name. The exception hierarchy is precise, and the ordering of the except clauses matters because GeocoderRateLimited is a subclass of GeocoderQuotaExceeded — catch the parent first and you will never see retry_after.

from geopy.exc import (
    GeocoderAuthenticationFailure,
    GeocoderQuotaExceeded,
    GeocoderRateLimited,
    GeocoderTimedOut,
    GeocoderUnavailable,
)

def resolve(query: str):
    """Return (status, location, seconds_to_wait_before_trying_again)."""
    try:
        location = geocode(query, exactly_one=True, addressdetails=True)
    except GeocoderRateLimited as exc:            # HTTP 429 — must precede the parent
        return "throttled", None, float(getattr(exc, "retry_after", None) or 30.0)
    except GeocoderQuotaExceeded:                 # daily/plan allowance spent
        return "quota", None, 300.0
    except (GeocoderTimedOut, GeocoderUnavailable):   # no reply, or 502/503
        return "transient", None, 5.0
    except GeocoderAuthenticationFailure:         # 401/403 — retrying cannot help
        raise
    if location is None:
        return "empty", None, 0.0                 # the provider genuinely has no match
    return "ok", location, 0.0

The distinction that pays for itself is empty versus everything else. An empty is a fact about the world — the index has no such address — and caching it permanently saves the same fruitless request on every future run. A throttled or transient result is a fact about the moment, and caching it as final would silently discard a row that would have resolved five seconds later.

Four geopy exceptions, what they mean and what to do about each A four-row table. GeocoderRateLimited is HTTP 429: the server asked you to back off and sent a Retry-After header; RateLimiter retries after a flat five seconds and ignores that header; your code should sleep for retry_after and then make one more attempt. GeocoderTimedOut means no reply arrived inside the ten second timeout and the request may still have landed; RateLimiter retrying is exactly right here, capped by max_retries. GeocoderUnavailable is HTTP 502 or 503, a service that is down or overloaded, usually momentary; retry, then park the row with status error so the next run picks it up. GeocoderAuthenticationFailure is HTTP 401 or 403 from a bad key or a rejected user agent; RateLimiter retries it anyway because it is a GeocoderServiceError, which is pointless, so let it raise and stop the run. A closing note warns that with swallow_exceptions left at its default of True, all four arrive as None, indistinguishable from an honest no-match. Four failures that look identical in a bare loop geopy exception What the server did RateLimiter's reaction What your code must do GeocoderRateLimited HTTP 429 asked you to back off Retry-After in the header retries after a flat 5 s ignores Retry-After sleep(exc.retry_after) then one more attempt GeocoderTimedOut no HTTP status no reply inside timeout the request may have landed retries — correct here this is what it is for let RateLimiter handle it bounded by max_retries=2 GeocoderUnavailable HTTP 502 / 503 down or overloaded usually momentary retries after a flat 5 s then gives up park the row as an error the next run picks it up GeocoderAuthentication Failure HTTP 401 / 403 bad key, or a user agent the policy rejects outright retries anyway — futile it is a service error too let it raise, stop the run no amount of waiting helps With swallow_exceptions left at its default of True, all four arrive as None — indistinguishable from an honest no-match.
Four causes, four correct responses; the default configuration collapses all of them into a single silent None.

4. Make the store the checkpoint. A SQLite file keyed on the normalised address does three jobs at once: it deduplicates, it makes reruns free, and it is the resume marker — there is no separate offset to keep in sync with the data.

import json, sqlite3, unicodedata
from pathlib import Path

def open_store(path=Path("permit_geocode.sqlite")):
    con = sqlite3.connect(path, timeout=30)
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("""
        CREATE TABLE IF NOT EXISTS resolved (
            address_key TEXT PRIMARY KEY,
            status      TEXT NOT NULL,     -- 'ok' | 'empty' | 'error'
            lat         REAL,
            lon         REAL,
            raw         TEXT
        )
    """)
    con.commit()
    return con

def address_key(text: str) -> str:
    """Same address in, same key out — forever. Change this and you invalidate the store."""
    folded = unicodedata.normalize("NFKC", str(text)).casefold()
    return " ".join(folded.replace(",", " ").split())

def pending(con, keys):
    """Whatever is not already settled. Order preserved, duplicates collapsed."""
    settled = {row[0] for row in con.execute(
        "SELECT address_key FROM resolved WHERE status IN ('ok', 'empty')")}
    return [k for k in dict.fromkeys(keys) if k not in settled]

dict.fromkeys deduplicates while preserving order, which matters more than it looks: a customer table of twelve thousand rows routinely holds only eight or nine thousand distinct addresses, and that reduction is pure saved quota. Rows written with status = 'error' are deliberately not in the settled set, so a transport failure is recorded for auditing yet still retried on the next pass.

5. Run the batch so that a crash costs nothing.

import time

def run_batch(con, keys, commit_every=25):
    todo = pending(con, keys)
    print(f"{len(keys)} rows, {len(todo)} still to resolve")
    started = time.perf_counter()
    for n, key in enumerate(todo, start=1):
        status, location, wait = resolve(key)
        if status in {"throttled", "quota", "transient"}:
            time.sleep(wait)                      # honour the server's own number
            status, location, wait = resolve(key) # one deliberate second pass
        row = (key, status, None, None, None)
        if status == "ok":
            row = (key, "ok", location.latitude, location.longitude,
                   json.dumps(location.raw))
        elif status != "empty":
            row = (key, "error", None, None, None)
        con.execute(
            "INSERT OR REPLACE INTO resolved (address_key, status, lat, lon, raw)"
            " VALUES (?, ?, ?, ?, ?)", row)
        if n % commit_every == 0:
            con.commit()
    con.commit()
    return len(todo), time.perf_counter() - started

Storing location.raw verbatim rather than only the coordinate pair is what lets you re-grade results later without spending another request; the payload is a few hundred bytes and the request costs a second. Committing every twenty-five rows bounds the loss from a hard kill to under thirty seconds of work, and because the primary key is the address, replaying those rows is idempotent rather than duplicative.

A crashed run resumed from the on-disk store Two horizontal progress bars over the same twelve thousand addresses. In run one the process is killed at row 8,412: the first portion of the bar is filled and labelled resolved and committed, and the remainder is labelled never reached. In run two the identical script is started again with no flags; the first 8,412 addresses are served from the store as cache hits costing zero network requests, and only the remaining 3,588 are fetched. A dashed vertical line links the crash point in the first bar to the boundary in the second. A footnote shows that the pending set is computed as every key minus the keys already stored, so there is no offset counter, no resume flag and no lost work. The store is the checkpoint process killed at row 8,412 8,412 resolved and committed never reached Run 1 12,000 rows rerun → pending = every key minus the keys already stored 8,412 store hits — zero requests 3,588 fetched Run 2 same script, no flags pending = [k for k in keys if k not in settled] no offset counter, no resume flag, no lost work
Because the resume boundary is derived from what is already stored, the second run needs no argument, no flag, and no memory of the first.

6. Attach the coordinates in EPSG:4326, then project before measuring anything. Every mainstream geocoder answers in WGS 84, and geopy hands the pair back latitude-first while points_from_xy wants longitude first — the swap is silent because both numbers are plausible.

import geopandas as gpd
import pandas as pd
from shapely.geometry import Point

store = open_store()
permit_sites = pd.read_csv("permit_applications.csv", encoding="utf-8-sig")
permit_sites["address_key"] = permit_sites["site_address"].map(address_key)
fetched, elapsed = run_batch(store, permit_sites["address_key"].tolist())

resolved = pd.read_sql_query(
    "SELECT address_key, status, lat, lon FROM resolved WHERE status = 'ok'", store)
permit_sites = permit_sites.merge(resolved, on="address_key", how="left")

located = permit_sites[permit_sites["lat"].notna()].copy()
sites = gpd.GeoDataFrame(
    located,
    geometry=gpd.points_from_xy(located["lon"], located["lat"]),  # x = lon, y = lat
    crs="EPSG:4326",                                              # declare it, never infer
)

Keep that frame in EPSG:4326 for storage and for anything a web map will consume. The moment a distance appears — how far each permit site is from the works depot, how tightly a group of results has stacked on one street centreline — reproject first, because a degree of longitude is 111 km at the equator and 82 km in Detroit. estimate_utm_crs() reads the data's own extent and returns the correct zone; never substitute EPSG:3857, whose scale factor grows with latitude and turns a fixed threshold into a moving one. The mechanics of the GeoDataFrame and the projection machinery in Coordinate Systems with PyProj both apply unchanged here.

metric_crs = sites.estimate_utm_crs()             # e.g. EPSG:32617 for Michigan
sites_m = sites.to_crs(metric_crs)
depot = (gpd.GeoSeries([Point(-83.045, 42.331)], crs="EPSG:4326")
           .to_crs(metric_crs).iloc[0])

sites["depot_distance_m"] = sites_m.geometry.distance(depot).to_numpy()
far = sites[sites["depot_distance_m"] > 40_000]   # metres, because the CRS is metric

Verification

Three properties define a correct run: nothing is outstanding, no coordinate is nonsense, and the pacing actually held.

import numpy as np

# 1. The run is complete — a second pass has nothing left to fetch.
keys = permit_sites["address_key"].tolist()
assert pending(store, keys) == [], "resume set is non-empty — call run_batch again"

# 2. Every stored coordinate is a plausible WGS 84 pair, and not lon/lat swapped.
ok = pd.read_sql_query("SELECT lat, lon FROM resolved WHERE status = 'ok'", store)
assert ok["lat"].between(-90, 90).all(), "latitude out of range — arguments swapped"
assert ok["lon"].between(-180, 180).all()
assert not (ok["lat"].abs().lt(1e-9) & ok["lon"].abs().lt(1e-9)).any(), "Null Island"

# 3. Pacing held: seconds of wall clock per network call must clear min_delay_seconds.
per_call = elapsed / max(fetched, 1)
print(f"{fetched} fetched in {elapsed:.0f}s -> {per_call:.2f} s/call")
assert fetched == 0 or per_call >= 1.0
# 3588 fetched in 3954s -> 1.10 s/call

print(pd.read_sql_query(
    "SELECT status, count(*) AS n FROM resolved GROUP BY status", store
).to_string(index=False))
# status     n
#  empty   541
#  error    57
#     ok  8319

The s/call figure is the honest audit of the policy claim: if it drops below min_delay_seconds the pacing was bypassed somewhere — usually a second RateLimiter instance, or a worker pool. The error count should be a rounding error; a few hundred means the endpoint was struggling and those rows deserve another pass rather than a manual review.

Edge Cases & Debugging

Frequently Asked Questions

Does min_delay_seconds=1 guarantee I stay inside a one-request-per-second policy? Not on its own. The value is a floor on the interval between call starts within a single RateLimiter instance, measured against a monotonic clock, so nothing prevents a second instance, a second process, or a thread pool from tripling your real rate. Use 1.1 rather than 1.0 so that clock granularity and DNS jitter cannot push two calls into the same second, keep resolution single-threaded against any shared endpoint, and treat a 429 as a signal to slow the whole run down rather than to retry harder.

Should I use RateLimiter's retries or write my own? Both, at different layers. Leave max_retries at one or two to absorb the momentary blips — a dropped connection, a single 503 — because that path is cheap and already written. Handle 429 yourself, because RateLimiter's wait is a flat error_wait_seconds that ignores the Retry-After value the server just sent you, and honouring that number is the difference between being throttled and being blocked. If you want the library out of the retry business entirely, set max_retries=0 and keep it purely as a pacer.

Can I speed this up with threads or asyncio? Not against a shared public endpoint — concurrency there is the fastest route to a ban, and geopy ships AsyncRateLimiter for providers whose terms actually permit parallel streams. The real throughput levers are ordered differently: deduplicate the column first (typically a 25–35% reduction), let the store absorb every rerun, and once a batch exceeds roughly ten thousand distinct addresses, run your own instance, where the only limit is your hardware.

What should happen to addresses that come back empty? Store them as empty and keep the row in the frame with a null geometry. A negative result is expensive information — it cost a full request — and caching it stops every future run re-attacking the provider with the same unmatchable strings. Give those entries a review date rather than treating them as permanent: provider indexes improve, and re-running only the empty rows after a data refresh is a one-line WHERE clause. Dropping the rows instead corrupts every count downstream.