Reverse Geocoding Workflows in Python

As part of the Multi-API Routing & Fallback Chains architecture, reverse geocoding is the direction most likely to dominate your bill and least likely to be designed for. Forward geocoding runs once per address; reverse geocoding runs once per position report, and position reports arrive continuously. This page covers the workflow that keeps that volume affordable — precision targeting, redundancy compression, cell-based caching and result validation.

The single most important decision comes first and is easy to skip: what precision does the answer actually need? A delivery confirmation needs a building; a driver activity report needs a street; a tax or coverage report needs an administrative area. Each answer has a different cost, a different cache strategy, and in the administrative case, no external call at all.

Prerequisites

Precision Decides Everything Downstream

Three precision targets and what each implies A three-row comparison. Administrative area answers come from local boundary polygons, need no external call and cache at a coarse cell. Street-level answers come from a provider or self-hosted service and cache at roughly a 150-metre cell. Building-level answers need an address-point dataset, cache at roughly a 40-metre cell, and are the most expensive per call. Precision target Data source Cache cell Cost per call administrative area country, region, locality local boundary polygons point-in-polygon, in process geohash 5 none street level road name and block provider or self-hosted road geometry needed geohash 7 · 150 m metered building level a specific delivery point address-point dataset coverage varies by market geohash 8 · 40 m metered, highest

The first row deserves attention because it removes the external call entirely. If the question is “which country is this vehicle in” or “which service area does this stop fall in”, a point-in-polygon test against boundary data answers it in microseconds, offline, with no quota and no provider dependency. A surprising share of reverse-geocoding spend is administrative questions being asked of a rooftop-precision API.

Step 1 — Compress Before You Look Up

Position streams are enormously redundant. A parked vehicle reports the same location every thirty seconds; a vehicle in traffic moves a few metres between reports. Filtering by movement is the single largest reduction available, and it happens before any cache is consulted.

from __future__ import annotations

from dataclasses import dataclass
from math import asin, cos, radians, sin, sqrt


@dataclass(frozen=True)
class Ping:
    device_id: str
    lat: float
    lon: float
    ts: float


def haversine_m(a_lat: float, a_lon: float, b_lat: float, b_lon: float) -> float:
    """Great-circle distance in metres."""
    p1, p2 = radians(a_lat), radians(b_lat)
    dp, dl = p2 - p1, radians(b_lon - a_lon)
    h = sin(dp / 2) ** 2 + cos(p1) * cos(p2) * sin(dl / 2) ** 2
    return 2 * 6_371_000 * asin(sqrt(h))


def compress(pings: list[Ping], min_move_m: float = 60.0) -> list[Ping]:
    """Keep only pings that moved meaningfully from the last kept position."""
    kept: list[Ping] = []
    last: dict[str, Ping] = {}
    for p in sorted(pings, key=lambda x: (x.device_id, x.ts)):
        prev = last.get(p.device_id)
        if prev is None or haversine_m(prev.lat, prev.lon, p.lat, p.lon) >= min_move_m:
            kept.append(p)
            last[p.device_id] = p
    return kept

Choose min_move_m from the precision target, not from intuition. For street-level answers a sixty-metre threshold removes most redundancy without ever changing the answer, because two points sixty metres apart on the same road reverse-geocode identically. For building-level answers the threshold must be smaller, and the compression ratio falls accordingly.

Step 2 — Cache by Cell, Not by Coordinate

Caching on an exact coordinate is close to useless: floating-point positions almost never repeat. Rounding the coordinate to a cell whose size matches the precision target turns a zero-hit cache into one that answers the large majority of queries.

import redis

CELL_PRECISION = {"area": 5, "street": 7, "building": 8}


def cache_key(lat: float, lon: float, target: str, provider: str) -> str:
    """Cell-based cache key — nearby coordinates collapse onto one entry."""
    from geohash_encoder import encode      # see the geohash guide for an encoder
    cell = encode(lat, lon, CELL_PRECISION[target])
    return f"rev:v2:{provider}:{target}:{cell}"


def get_or_reverse(r: redis.Redis, lat: float, lon: float, target: str,
                   provider: str, ttl_s: int = 30 * 86_400) -> dict:
    key = cache_key(lat, lon, target, provider)
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)
    result = call_provider(lat, lon, target)          # your dispatch layer
    r.set(key, json.dumps(result), ex=ttl_s)
    return result

The cell size is a correctness decision as well as an economic one. A cell that is too coarse returns the address of a neighbouring building for every point in it, and the error is invisible — a plausible address, wrong by one door. Sizing the cell below the precision you are claiming keeps that from happening.

Step 3 — Validate the Answer Against the Question

Every reverse geocoder returns something. The interesting question is how far away that something is, because a provider with no nearby address will happily return one several hundred metres off with no hint that it did.

Distance between the query point and the returned address A query coordinate is shown with three candidate returned addresses at increasing distances. The nearest, within the precision target, is accepted. One at several times the target goes to review with a lowered confidence. One far outside the target is rejected as not answering the question that was asked. query point within the target → accept the returned address is the place that was asked about 2–4× the target → review plausible, but record the distance and lower the confidence far outside → reject the provider answered a different question; fall back or return the area Providers rarely report this distance; compute it yourself from the coordinate they echo back.

Most providers echo the coordinate of the address they matched. Computing the haversine distance between that and your query point costs nothing and converts an unqualified answer into a measured one — the same idea as detecting geocoding outliers, applied in the opposite direction.

Performance and Batching

After compression and caching, the surviving lookups are dispatched exactly like forward geocoding: concurrently, under a rate limiter, with the resilience stack around each call. The numbers below are a representative reduction for a two-hundred-vehicle fleet.

Stage Coordinates Reduction
raw pings, 30 s interval 180,000
after movement compression at 60 m 41,000 −77%
after cell cache lookup 6,200 −85% of the remainder
billable provider calls 6,200 3.4% of raw

Both reductions come from the same insight: the question being asked repeats far more often than the answer changes. That is also why the cache TTL can be long — a street name at a given cell is stable for months, and the freshness rules that apply to forward geocoding are much weaker here.

Storing the Result Beside the Track

Where the resolved address lives matters as much as how it was obtained. A common mistake is to write the address back onto every position row, which multiplies storage by the ping count and makes a later re-resolution a full table rewrite. Storing the cell-to-address mapping in its own table and joining on demand keeps both the storage and the update cost proportional to places rather than to reports.

That separation also makes provenance possible. The mapping table can carry the provider that answered, the distance between the query point and the returned address, the cell precision in force and the time of resolution — none of which belongs on a position row, and all of which you will want when a route report looks wrong three months later.

There is a retention argument too. Position tracks are usually subject to a shorter retention policy than business records, because they are personal data about a driver’s movements. A design that embeds addresses in the track forces the address history to expire with it; a design that keeps them separate lets the track be deleted on schedule while the delivery record survives with the address it needed.

Finally, keeping the mapping separate makes the cache and the database mutually reinforcing. The mapping table is a durable version of the same information the cache holds, so a cold cache can be warmed from it in one query rather than by re-calling the provider — the same relationship described for Redis and Postgres caching patterns on the forward path.

Choosing Between a Provider and a Local Answer

Reverse geocoding is unusual in that a good local implementation is genuinely competitive with a commercial provider for many workloads. The deciding factors are coverage in the markets you actually operate in, the precision you need, and how much operational appetite you have for maintaining a dataset.

For administrative answers the local option wins outright: boundary data is small, stable, freely available for most of the world, and answers in microseconds. For street-level answers a self-hosted service is competitive where road geometry is well mapped. For building-level answers the question turns on whether an authoritative address-point file exists for your market — where it does, local snapping is dramatically cheaper at fleet volumes; where it does not, a provider is the only realistic option.

Most mature pipelines end up running both, with the local path first and a provider behind it for the tail. That arrangement gets the cost profile of the local option and the coverage of the commercial one, at the price of maintaining two code paths — which the fallback chain already models, so the marginal complexity is small.

Whichever arrangement you choose, decide it per precision target rather than once for the whole pipeline. The right answer for an administrative query is frequently different from the right answer for a rooftop one, and treating them as a single decision forces one of them to be wrong.

Edge Cases

Coordinates on a boundary. A point on a municipal boundary can legitimately reverse to either side, and small GPS jitter will flip it back and forth. Where the answer feeds billing or jurisdiction, snap the answer per device per day rather than per ping so it does not oscillate.

Zero-zero and null island. A failed GPS fix commonly reports (0, 0). Filter it before the lookup, or you will pay to learn about the Gulf of Guinea repeatedly.

Ocean and airspace coordinates. Vessels and aircraft produce positions with no address anywhere nearby. Detect them by the validation distance and return an area-level answer rather than a nonsensical street.

Very high ping frequency. Some trackers report every second. Compress on time as well as distance — one lookup per device per minute is almost always enough, and the extra pings add nothing but cost.

Privacy considerations. Position histories are personal data in most jurisdictions. Cache the cell, never the device identifier alongside it, and keep retention on the compressed track separate from retention on the address results.

The Reduction, End to End

From 180,000 pings to 6,200 calls A four-stage funnel for a two-hundred-vehicle fleet. Raw pings number 180 thousand. Movement compression leaves 41 thousand. Cell-cache lookups leave 6.2 thousand. Those become the billable provider calls, about three percent of the raw volume. 200 vehicles, one day, street-level precision raw pings 180 000 after movement 41 000 −77% for free, before any lookup after cell cache 6 200 3.4% of raw — the billable figure Both reductions are local. Neither depends on a provider feature, a contract term, or anything you have to negotiate.

Troubleshooting

Cache hit rate near zero. The key is built from an unrounded coordinate somewhere — usually a float formatted into the key directly rather than passed through the cell encoder.

Addresses change between identical positions. Two providers are answering, or the cache key omits the provider. Include it: their answers differ, and mixing them produces a track that appears to teleport between conventions.

Spend rising with flat fleet size. Ping frequency changed, or the movement threshold was lowered. Both show up immediately in the compression ratio, which is worth emitting as a metric for exactly this reason.

FAQ

Why is reverse geocoding volume so much higher than forward geocoding volume?

Forward geocoding happens once per address; reverse geocoding happens once per position report. A fleet of 200 vehicles reporting every 30 seconds produces around 180,000 coordinates a day against a few thousand addresses, so the same pipeline can be dominated by the reverse side even though the address count is small.

What cell size should a reverse-geocoding cache use?

Match it to the precision you need. A seven-character geohash covers about 150 metres and suits street-level answers; eight characters covers about 40 metres and suits building-level answers. Too coarse returns the neighbour’s address, too fine caches nothing.

How do I know the returned address is actually correct?

Measure the distance from the query coordinate to the coordinate the provider returns for that address. A large gap means the provider snapped to something further away than you intended — usually the nearest known address rather than the place you asked about.

Should reverse geocoding use the same provider as forward geocoding?

Not necessarily. Coverage and pricing differ between the two operations, and reverse volume is usually far higher, so the economics can favour a different provider or a self-hosted service. Keep the routing decision separate rather than assuming one provider serves both.

Can reverse geocoding be done entirely offline?

For administrative areas, yes — a point-in-polygon lookup against boundary data answers country, region and locality with no external call. Street and rooftop precision needs an address point dataset, which is available for some markets and not for others.