Routing Geocoding Requests by Country Code in Python

Route each address to the geocoder that performs best in its jurisdiction by keying a routing table on the ISO 3166-1 alpha-2 country code, resolving an ordered provider chain with a DEFAULT fallback, and dispatching to the first entry. This page is part of the Dynamic Provider Selection Based on Region guide.

The Routing Table Pattern

The core artifact is a plain dictionary from country code to an ordered list of provider identifiers. First entry is the primary; the rest are fallbacks. A single DEFAULT key catches every code you have not explicitly tuned, so the dispatcher always resolves something.

ROUTING_TABLE: dict[str, tuple[str, ...]] = {
    "US": ("google", "here", "nominatim"),
    "GB": ("google", "os_places", "nominatim"),
    "DE": ("here", "google", "nominatim"),
    "AT": ("here", "google", "nominatim"),
    "CH": ("here", "google", "nominatim"),
    "JP": ("google", "yahoo_japan"),
    "CN": ("baidu", "gaode"),
    "AU": ("here", "google"),
    "DEFAULT": ("nominatim", "google"),
}

Country codes are the right key because every provider documents coverage and pricing per country, and a code is cheap to derive from any parsed address. Subdivision-level routing (US-CA) is a natural extension but country granularity handles the overwhelming majority of production traffic.

Four Sources for a Country Code, Ranked

Routing needs a country before it can pick a provider, and the country is frequently the one field the record does not carry. Four signals are usually available, and they differ sharply in reliability — so take them in order and record which one fired.

Deriving a country code, most reliable source first Four ranked sources. An explicit ISO country field on the record is authoritative. A self-identifying postcode shape is strong but ambiguous for bare five-digit codes. A phone country code is weak because people move. The account billing country is a last resort and must be recorded as a guess. 1 · explicit country field normalise to ISO 3166-1 alpha-2 and trust it — confidence 1.0 authoritative 2 · postcode shape strong for CA, GB, NL, JP, PL; ambiguous for any bare five-digit code strong 3 · phone country code people keep numbers across borders — treat as a hint, never as truth weak 4 · account billing country last resort — stamp country_source="billing" so the guess is visible downstream guess

Recording country_source alongside the code is what makes the guess safe. A record routed on a billing-country assumption that then geocodes to a locality centroid is a very different data point from one routed on an explicit field, and only the stamped source lets a later analysis tell them apart.

Routing Table Structure

Field Type Role Example
Key str (ISO 3166-1 alpha-2) Country the row applies to "DE"
Value[0] str Primary provider for that country "here"
Value[1:] str (ordered) Fallback providers, most-preferred first ("google", "nominatim")
DEFAULT key str Catch-all for unmapped countries ("nominatim", "google")

Keep the value an immutable tuple so a shared table cannot be mutated by a worker mid-run, and load it once at startup rather than rebuilding it per request.

Deriving the Country Code

Parsed components rarely arrive perfectly clean: some carry a full country name, some a lowercase code, some nothing. Normalize with a compile-once alias map and a regex that strips non-letters, defaulting to DEFAULT when no confident code emerges.

"""country_router.py — resolve a provider chain from a parsed address."""
from __future__ import annotations

import re

# Compile-once: strip anything that is not an ASCII letter.
_NON_ALPHA = re.compile(r"[^A-Za-z]")

# Map common full names / legacy codes to ISO 3166-1 alpha-2.
_NAME_TO_ISO: dict[str, str] = {
    "USA": "US", "UNITEDSTATES": "US", "UK": "GB", "UNITEDKINGDOM": "GB",
    "GREATBRITAIN": "GB", "DEUTSCHLAND": "DE", "GERMANY": "DE",
    "JAPAN": "JP", "NIPPON": "JP", "CHINA": "CN",
}

ROUTING_TABLE: dict[str, tuple[str, ...]] = {
    "US": ("google", "here", "nominatim"),
    "GB": ("google", "os_places", "nominatim"),
    "DE": ("here", "google", "nominatim"),
    "JP": ("google", "yahoo_japan"),
    "CN": ("baidu", "gaode"),
    "DEFAULT": ("nominatim", "google"),
}


def derive_country_code(components: dict[str, str]) -> str:
    """Return an ISO 3166-1 alpha-2 code from parsed components, or 'DEFAULT'.

    Args:
        components: Parsed address fields, e.g. {'country_code': 'us'} or
            {'country': 'United States'}.

    Returns:
        A two-letter uppercase ISO code known to the routing table, or
        'DEFAULT' when no confident match is found.
    """
    raw = components.get("country_code") or components.get("country") or ""
    cleaned = _NON_ALPHA.sub("", raw).upper()
    if len(cleaned) == 2 and cleaned in ROUTING_TABLE:
        return cleaned
    if cleaned in _NAME_TO_ISO:
        return _NAME_TO_ISO[cleaned]
    return "DEFAULT"


def resolve_chain(country_code: str) -> tuple[str, ...]:
    """Look up the ordered provider chain, defaulting when unmapped."""
    return ROUTING_TABLE.get(country_code, ROUTING_TABLE["DEFAULT"])

Dispatching Along the Chain

The dispatcher walks the resolved chain and returns the first successful result, cascading on any provider error. This is the country-aware entry point into a broader fallback chain for failed lookups.

from typing import Any, Callable, Optional

# provider name -> callable(address) -> geocode dict, raising on failure
GeocodeFn = Callable[[str], dict[str, Any]]


def dispatch(
    address: str,
    components: dict[str, str],
    providers: dict[str, GeocodeFn],
) -> Optional[dict[str, Any]]:
    """Route one address to the best provider for its country and geocode it.

    Args:
        address: Raw address string to geocode.
        components: Parsed components used to derive the country code.
        providers: Mapping of provider name to a geocoding callable.

    Returns:
        The first successful geocode result annotated with the provider
        that produced it, or None if every provider in the chain failed.
    """
    chain = resolve_chain(derive_country_code(components))
    for name in chain:
        fn = providers.get(name)
        if fn is None:
            continue
        try:
            result = fn(address)
        except Exception:
            # Provider failed; cascade to the next in the chain.
            continue
        result["_provider"] = name
        return result
    return None

Vectorized pandas variant

For bulk assignment, map the country-code column straight to a primary-provider column with .map, which is far faster than a row-wise .apply on large frames.

import pandas as pd

# Primary provider only (chain[0]) for a fast column assignment.
_PRIMARY: dict[str, str] = {code: chain[0] for code, chain in ROUTING_TABLE.items()}
_DEFAULT_PRIMARY = ROUTING_TABLE["DEFAULT"][0]


def assign_primary_provider(
    df: pd.DataFrame, code_col: str = "country_code"
) -> pd.DataFrame:
    """Add a 'primary_provider' column derived from each row's country code.

    Args:
        df: Frame containing a normalized ISO country-code column.
        code_col: Name of that column.

    Returns:
        A copy of df with a 'primary_provider' column.
    """
    out = df.copy()
    codes = out[code_col].astype(str).str.upper()
    out["primary_provider"] = codes.map(_PRIMARY).fillna(_DEFAULT_PRIMARY)
    return out

One Lookup, Then a Chain

The routing table returns an ordered chain rather than a single provider, and the dispatcher walks it. Keeping the walk in one place — rather than scattering if country == … across call sites — is what makes the behaviour testable: the table is data, the walk is a loop, and every decision it makes is reconstructable from the log.

Walking the chain returned by the routing table A dispatcher receives an ordered chain for country DE. It checks each provider in turn: provider B is skipped because its breaker is open, provider A answers successfully, and the chain position and provider name are stamped onto the result. A note explains that exhausting the chain sends the record to the dead letter queue rather than raising. country = DE [B, A, osm] position 0 — B breaker open → skip position 1 — A answers in 190 ms result stamped provider=A chain_position=1 Skipping an unhealthy provider costs nothing here because the health check reads shared state rather than probing. Exhausting the chain is a normal outcome, not an exception: write the record to the dead letter queue with the reason from each attempt, so a later replay knows what has already been tried.

The stamped chain_position is the field that pays for itself. Aggregated per day it is a direct read-out of primary-provider health, and it is available without any extra instrumentation — the number of records answered at position one rises the moment position zero starts to degrade.

Edge Cases

Missing or ambiguous country field

An address with no country signal (a bare street line) yields an empty string, which derive_country_code maps to DEFAULT. That is safe but coarse — for higher accuracy, run the string through a locale-aware parser first so a real code is present before routing. Reliable code derivation depends on upstream international address format standardization.

Legacy or non-canonical codes

Inputs like UK (not the ISO GB) or USA (alpha-3) will not match a two-letter table key directly. The _NAME_TO_ISO alias map absorbs the common ones; extend it from your own data rather than assuming input is already canonical, and log any value that falls through to DEFAULT so you can spot systematic gaps.

Some countries restrict which providers may legally geocode their addresses (China is the canonical case). The routing table encodes this as data — "CN": ("baidu", "gaode") deliberately omits Western providers — so compliance lives in one auditable place instead of scattered if statements. Confirm each chain against provider terms; the accuracy trade-offs are covered under comparing geocoding accuracy across providers.

Testing a Routing Table Without Network Calls

Routing logic is easy to unit test and almost never is, because the tests get entangled with HTTP. Separate the two: the table lookup and chain walk are pure functions over a fake provider registry, so the interesting cases — an unmapped country, an exhausted chain, a wildcard fall-through — run in milliseconds with no network at all.

Where to put the seam between routing and HTTP Two layers separated by a seam. Above the seam, the country derivation, table lookup and chain walk are pure functions tested exhaustively with stub providers. Below the seam, the HTTP client and response parsing are covered by a handful of integration tests against recorded responses. Pure — exhaustively unit tested derive_country() · lookup_chain() · walk_chain(stub_registry) cases: unmapped country · every provider unhealthy · wildcard fall-through · empty chain the seam — one interface, one fake implementation Impure — a handful of integration tests HTTP client · auth · response parsing, exercised against recorded provider responses

The payoff shows up during incidents. When routing misbehaves at three in the morning, a suite that reproduces the decision path in milliseconds — without credentials, without network, without quota — is the difference between confirming a hypothesis in a minute and waiting for a staging batch to run.

Integration Note

Country-code routing is the decision layer that sits above dispatch: it picks which provider chain to try, while the parent dynamic provider selection based on region guide adds the quota and health checks that decide whether the chosen provider is actually usable right now. Pair the table with an accuracy baseline from comparing geocoding accuracy across providers so each country’s ordering reflects measured precision rather than a guess, and feed misses into the fallback chain for failed lookups.