Batch Geocoding with a Self-Hosted Service

This page solves one exact task: geocoding a large batch of normalized addresses against a reference dataset you control, so that the coordinates are reproducible months later and the reference version behind each one is recorded.

Reproducibility is the whole argument. A hosted geocoder is accurate and effortless, and it improves continuously — which means the coordinate it returns for a store today may differ from the one it returned last quarter, with no record of why. For a site-selection model that a committee will question, that is a problem: the suitability score rests on a catchment, the catchment rests on a coordinate, and if the coordinate cannot be reproduced then neither can the decision.

Prerequisites

  • Python packages: httpx for a connection-pooled client, pandas for the batch frame, and tenacity for bounded retries. Install with pip install httpx pandas tenacity.
  • A running geocoding service with a reference dataset imported — typically an open-source stack fed by national address points and a street network. The specific engine matters less than the ability to pin and record the dataset build.
  • Normalized addresses. The typed components produced by parsing and standardizing US store addresses, each with a stable key.
  • The parent context. Geocoding and address normalization covers the precision tiers this page records and the result store it writes to.

Configuration and execution parameters

Parameter Value for this task Notes
reference_build dated identifier Recorded on every result; the whole point of self-hosting
batch_size 500 Addresses per request; larger batches amortize round trips
concurrency 8 Parallel in-flight batches, tuned to the service’s cores
timeout_s 30 Per batch, not per address
retry_attempts 3 Transient failures only; a no-match is not retried
min_score 0.72 Below this the match is recorded but flagged unusable
structured_query true Send components, not a re-joined string
country explicit Never inferred from the address text

Sending a structured query rather than a flattened string is the setting most often left at the wrong value. Having carefully parsed an address into components, re-joining them into one line asks the geocoder to parse it again with a different parser, and the two will disagree on exactly the awkward records that motivated the parsing work. Passing components through preserves the decisions already made and usually improves the match rate by a few points.

What a pinned reference build buys Between two reference builds six months apart, 96.1 per cent of store coordinates are unchanged, 3.2 per cent move under ten metres, 0.6 per cent move between ten metres and a kilometre, and 0.1 per cent move further. Without a pinned build those movements appear as unexplained drift in catchments and scores. Six months of reference improvement, measured displacement on re-geocode share of records what it usually means unchanged 96.1% the reference did not touch it under 10 m 3.2% a refined address point 10 m to 1 km 0.6% tier improved: street → rooftop over 1 km 0.1% a different match — review it Four per cent of an estate moving is unremarkable and completely invisible without the comparison — which requires having both builds, which requires having pinned the first one. The final row is where the value is: five stores in a national estate that now geocode somewhere else entirely.

Annotated implementation

The client below sends structured batches, records the reference build with every result, and separates a transport failure from a no-match. That distinction matters: the first should be retried, the second should never be.

python
from __future__ import annotations

import asyncio
from dataclasses import dataclass

import httpx
import pandas as pd
from tenacity import retry, stop_after_attempt, wait_exponential

GEOCODER = "http://geocoder.internal:4000/v1/search/structured"
REFERENCE_BUILD = "openaddresses-2026-07-31"     # pinned, recorded on every row
BATCH_SIZE = 500
CONCURRENCY = 8
MIN_SCORE = 0.72


@dataclass(frozen=True)
class Result:
    address_key: str
    lon: float | None
    lat: float | None
    tier: str
    score: float
    reference_build: str


@retry(stop=stop_after_attempt(3),
       wait=wait_exponential(multiplier=1, min=1, max=16))
async def _post_batch(client: httpx.AsyncClient, payload: list[dict]) -> list[dict]:
    """Transport-level retries only. A 4xx is a request bug, not a blip."""
    resp = await client.post(GEOCODER, json={"queries": payload}, timeout=30.0)
    if 400 <= resp.status_code < 500:
        resp.raise_for_status()          # do not retry a malformed request
    resp.raise_for_status()
    return resp.json()["results"]


def _to_result(key: str, hit: dict | None) -> Result:
    if not hit:                          # a genuine no-match, recorded as such
        return Result(key, None, None, "none", 0.0, REFERENCE_BUILD)
    lon, lat = hit["geometry"]["coordinates"]
    return Result(key, lon, lat,
                  hit["properties"].get("accuracy", "unknown"),
                  float(hit["properties"].get("confidence", 0.0)),
                  REFERENCE_BUILD)


async def geocode_frame(df: pd.DataFrame) -> pd.DataFrame:
    """Geocode a frame of parsed components, preserving one row per input row."""
    sem = asyncio.Semaphore(CONCURRENCY)
    batches = [df.iloc[i:i + BATCH_SIZE] for i in range(0, len(df), BATCH_SIZE)]

    async def run(batch: pd.DataFrame) -> list[Result]:
        payload = [{"address": r.number + " " + r.street, "locality": r.city,
                    "region": r.region, "postalcode": r.postcode,
                    "country": "US"} for r in batch.itertuples()]
        async with sem:
            async with httpx.AsyncClient(http2=True) as client:
                hits = await _post_batch(client, payload)
        return [_to_result(k, h) for k, h in zip(batch["address_key"], hits)]

    nested = await asyncio.gather(*(run(b) for b in batches))
    out = pd.DataFrame([r.__dict__ for group in nested for r in group])
    out["usable"] = (out["score"] >= MIN_SCORE) & out["lon"].notna()
    return out

Two properties of this client are worth stating explicitly. Results are returned one per input row, in order, including the no-matches — a client that silently drops failures produces a frame shorter than its input and a join that quietly loses stores. And the reference build is stamped on every row rather than recorded once for the job, so a table assembled from several runs still says which coordinate came from which build.

Failure modes and debugging

A match rate that falls after an import. The usual cause is a reference build that imported partially — a state’s address points missing, a street file that failed to load — rather than anything about the addresses. Compare match rate by region against the previous build before accepting a new one; a uniform drop is a query change, and a drop concentrated in one region is an import problem.

Retries on a permanent failure. A malformed structured query returns a client error for every attempt, and retrying it three times with backoff turns an instant failure into a slow one. Separating client errors from server and network errors, as the code above does, is the difference between a batch that fails in seconds and one that fails in minutes.

Memory growth on large batches. Accumulating every result before writing is fine for a store estate and fatal for a customer file. Writing each batch’s results as they complete keeps the footprint flat, and it means an interrupted job has already persisted most of its work — the same streaming discipline that batch routing needs.

Coordinate order. Almost every geocoding API returns longitude first, and almost every mapping library expects latitude first. The symptom is an estate that plots in the wrong hemisphere, which is at least obvious; the dangerous variant is a market near the diagonal where the transposed points still land on the map. Unpack coordinates by name at the boundary and assert the values are in their expected ranges.

Concurrency against throughput on a four-core service One in-flight batch geocodes 1,900 addresses a second, four batches 6,100, eight batches 7,400, and sixteen batches 7,200 with p99 latency tripling. Throughput saturates at roughly twice the core count, and pushing past it buys nothing while making failures slower. Throughput saturates; latency does not in-flight batches addresses / second p99 batch latency verdict 1 1,900 0.3 s under-used 4 6,100 0.4 s good 8 7,400 0.7 s the setting 16 7,200 2.1 s worse on both Past saturation the extra requests queue inside the service, so a timeout now means a batch that was going to succeed is retried, adding load to a service that is already the bottleneck. Measured on a four-core service with the national reference build resident in memory.

Verification

  • Re-run a sample against the same build. Ten thousand addresses geocoded twice must return identical coordinates. Any variation means something in the service is non-deterministic — usually a tie between equally-scored candidates broken by iteration order — and that is worth fixing before the results are trusted.
  • Compare against a hosted service on a sample. Not to decide which is right, but to find the records where they disagree by more than a few hundred metres. Those are the addresses worth looking at by hand, and there are usually few enough to read.
  • Check the tier distribution against the previous build. A shift toward weaker tiers is the clearest signal that a reference import was incomplete.
  • Assert every input row produced an output row. Cheap, and it catches the class of bug where a batch failure silently shortened the frame.
The reproducibility assertion, run as a gate Ten thousand addresses geocoded twice against the same pinned build return identical coordinates for 9,998 records and differ for two, both of which are addresses matching two equally-scored candidate points. Fixing the tie-break rule takes the count to zero and makes the gate meaningful. Same build, same input, same answer — or find out why not before the tie-break fix 9,998 identical · 2 differing both are corner lots with two address points at the same score after: deterministic tie-break 10,000 identical ties broken by the reference record id, which is stable within a build Two records in ten thousand sounds ignorable until the pipeline claims reproducibility. Either the claim holds exactly or it is a statement about how often it usually holds. Run the check on every build import, not once — a tie-break can regress with an engine upgrade.

Frequently Asked Questions

How much hardware does a self-hosted geocoder need?

Less than teams expect for a national reference: the dominant cost is memory to hold the index, typically tens of gigabytes rather than hundreds, and the CPU requirement scales with query throughput rather than with data size. The heavier cost is the import, which is disk- and time-intensive and wants to run somewhere it will not be interrupted. Sizing the import machine generously and the serving machine modestly is usually the right split.

Should the hosted and self-hosted results be blended?

Only with the source recorded per record, and preferably not at all within one analysis. Blending is tempting when the hosted service matches an address the local build misses, and it produces a dataset whose coordinates come from two references with different conventions and different vintages. If the coverage gap is real, the better answer is to improve the reference import; if it is a handful of records, mark them and move on.

What is the right cadence for updating the reference build?

Quarterly for most retail pipelines, with an out-of-cycle update when a market is added or a known coverage gap is fixed. More often than that spends effort re-validating for improvements measured in single-digit metres; much less often means the estate slowly diverges from the world. Whatever the cadence, treat an update as a change that re-scores rather than as maintenance, and keep the previous build available until the comparison has been read.

Does self-hosting remove the need to record precision tiers?

No — it makes recording them easier and no less necessary. A pinned build tells you the coordinate is reproducible; the tier tells you how good it is. Those are different questions, and a reproducible postal-centroid point is exactly as unsuitable for measuring competitor distance as a non-reproducible one.

← Back to Geocoding & Address Normalization