Configuring Google Maps Fallback to OpenStreetMap

Configure a deterministic fallback chain that attempts the Google Maps Geocoding API first, intercepts explicit failure states, and re-routes the identical address payload to OpenStreetMap Nominatim — all as part of the Implementing Fallback Chains for Failed Lookups pattern.

Provider API Parameter Reference

The two providers share no common request schema. The table below captures every parameter that the fallback wrapper must set correctly for each provider before sending a request.

Parameter Google Maps Geocoding API Nominatim Search API
Endpoint https://maps.googleapis.com/maps/api/geocode/json https://nominatim.openstreetmap.org/search
Address field address=<string> q=<string>
Auth key=<API_KEY> (query param) None (open, rate-limited by IP)
Result count Returns all matches; use results[0] limit=1 caps results
Output format Always JSON format=json required
Required headers None beyond Content-Type User-Agent identifying app + contact
Coordinate path results[0].geometry.location.{lat,lng} [0].{lat,lon} (strings, cast to float)
Formatted address results[0].formatted_address [0].display_name
Success signal status == "OK" Non-empty list
Quota failure status == "OVER_QUERY_LIMIT" HTTP 429 or HTTP 403 (IP ban)
No match status == "ZERO_RESULTS" Empty list []

Two Corpora, Two Shapes of Answer

Falling back from a commercial provider to OpenStreetMap data is not a like-for-like swap. The response schemas differ, the coverage differs by geography, and — most consequentially — the licence terms differ. Mapping one onto the other is a deliberate translation, and the fields that have no counterpart are as important as the ones that do.

Mapping a commercial response onto a Nominatim response A three-column table. The left column lists commercial response fields, the middle column gives the Nominatim equivalent, and the right column records the translation note: direct mappings for coordinates and formatted address, a derived mapping from place rank to accuracy tier, and no counterpart for the plus code or place identifier. Commercial field Nominatim equivalent Translation geometry.location lat / lon direct — cast to float formatted_address display_name direct, but ordering differs location_type place_rank + osm_type derived — write the table explicitly place_id · plus_code none leave null — never synthesise one

The last row is a real trap. Downstream code that assumes a place identifier is always present will crash or, worse, silently key a cache on None for every fallback result. Make the field nullable in the schema from the start, and assert on the answering provider rather than on the presence of a provider-specific field.

Failure-State Routing Table

Not every Google Maps failure warrants a Nominatim call. Routing to the secondary provider for client-side errors wastes quota and adds latency. The decision must be driven by the specific status code returned.

Google Status / Error Action
OK with results Normalize and return immediately — do not call Nominatim
ZERO_RESULTS Optionally delegate to Nominatim for fuzzy or administrative-level match
OVER_QUERY_LIMIT Mandatory fallback; pause Google requests for 60–120 s
REQUEST_DENIED Mandatory fallback; alert on auth misconfiguration
INVALID_REQUEST Halt chain — input is malformed; fix upstream before retrying
ConnectionError / Timeout Exponential backoff then delegate to Nominatim
HTTP 5xx Treat as transient; delegate to Nominatim after one brief retry

Fallback Request Flow

The diagram below shows the decision path a single address takes through the routing wrapper — from the initial Google call through failure-state evaluation to the Nominatim fallback and final schema normalization.

Google Maps to Nominatim Fallback Flow Flowchart showing address input routed to Google Maps first; on OK the result is normalized and returned; on OVER_QUERY_LIMIT, REQUEST_DENIED, timeout, or ZERO_RESULTS the request is delegated to Nominatim; Nominatim result is normalized or a null record is returned if both fail. Address input Google Maps Geocoding API POST /geocode/json?address=…&key=… Google status? OK Normalize & return fallback trigger Enforce Nominatim rate limit ≥ 1 s since last OSM request Nominatim Search API GET /search?q=…&format=json&limit=1 Normalize or return null record provider: "osm" | provider: "none"

Minimal Runnable Implementation

The class below compiles to a single-import, drop-in component. It handles schema normalization, timeout management, Nominatim rate-limit compliance, and structured logging in under 100 lines.

import time
import logging
from typing import Dict, Optional

import requests
from requests.exceptions import RequestException, Timeout, HTTPError

logger = logging.getLogger(__name__)

# Provider endpoints — defined at module level for clarity
_GOOGLE_URL: str = "https://maps.googleapis.com/maps/api/geocode/json"
_OSM_URL: str = "https://nominatim.openstreetmap.org/search"
_OSM_MIN_INTERVAL: float = 1.0  # Nominatim Usage Policy: max 1 req/sec


class GeoFallbackResolver:
    """
    Routes geocoding requests to Google Maps, then Nominatim on failure.

    Handles schema normalization, timeout management, and strict
    Nominatim rate-limit compliance (1 req/sec per the usage policy).
    """

    def __init__(
        self,
        google_api_key: str,
        user_agent: str = "DataPipeline/1.0 (admin@example.com)",
    ) -> None:
        self.google_api_key = google_api_key
        self._osm_headers: Dict[str, str] = {"User-Agent": user_agent}
        self._last_osm_request: float = 0.0

    # ------------------------------------------------------------------
    # Rate limiting
    # ------------------------------------------------------------------

    def _enforce_osm_rate_limit(self) -> None:
        """Block until Nominatim's 1 req/sec policy is satisfied."""
        elapsed = time.time() - self._last_osm_request
        if elapsed < _OSM_MIN_INTERVAL:
            time.sleep(_OSM_MIN_INTERVAL - elapsed)
        self._last_osm_request = time.time()

    # ------------------------------------------------------------------
    # Schema normalizers
    # ------------------------------------------------------------------

    def _normalize_google(self, payload: Dict) -> Optional[Dict]:
        if payload.get("status") != "OK" or not payload.get("results"):
            return None
        loc = payload["results"][0]["geometry"]["location"]
        return {
            "lat": loc["lat"],
            "lon": loc["lng"],  # Google uses "lng", not "lon"
            "address": payload["results"][0].get("formatted_address", ""),
            "provider": "google",
        }

    def _normalize_osm(self, payload: list) -> Optional[Dict]:
        if not payload:
            return None
        result = payload[0]
        return {
            "lat": float(result["lat"]),
            "lon": float(result["lon"]),
            "address": result.get("display_name", ""),
            "provider": "osm",
        }

    # ------------------------------------------------------------------
    # Public resolver
    # ------------------------------------------------------------------

    def resolve(self, address: str, timeout: float = 5.0) -> Dict:
        """
        Attempt Google Maps; fall back to Nominatim on failure states.

        Returns a dict with keys: lat, lon, address, provider.
        provider is "none" when both resolvers fail.
        """
        # 1. Primary: Google Maps Geocoding
        try:
            params = {"address": address, "key": self.google_api_key}
            resp = requests.get(_GOOGLE_URL, params=params, timeout=timeout)
            resp.raise_for_status()
            data = resp.json()

            google_status = data.get("status", "")
            if google_status == "INVALID_REQUEST":
                logger.error("Malformed address, halting chain: %s", address)
                return {"lat": None, "lon": None, "address": address, "provider": "none"}

            if google_status in ("OVER_QUERY_LIMIT", "REQUEST_DENIED"):
                logger.warning(
                    "Google quota/auth failure (%s), falling back to OSM",
                    google_status,
                )
            else:
                normalized = self._normalize_google(data)
                if normalized:
                    return normalized
                logger.warning(
                    "Google returned status=%s for: %s", google_status, address
                )
        except (Timeout, HTTPError, RequestException) as exc:
            logger.warning("Google network failure for '%s': %s", address, exc)

        # 2. Fallback: OpenStreetMap Nominatim
        logger.info("Delegating to OSM for: %s", address)
        try:
            self._enforce_osm_rate_limit()
            params = {"q": address, "format": "json", "limit": 1}
            resp = requests.get(
                _OSM_URL, params=params, headers=self._osm_headers, timeout=timeout
            )
            resp.raise_for_status()
            normalized = self._normalize_osm(resp.json())
            if normalized:
                return normalized
            logger.warning("OSM returned empty results for: %s", address)
        except (Timeout, HTTPError, RequestException) as exc:
            logger.error("OSM fallback failed for '%s': %s", address, exc)

        return {"lat": None, "lon": None, "address": address, "provider": "none"}

Vectorized pandas usage

import pandas as pd

resolver = GeoFallbackResolver(google_api_key="YOUR_KEY")

# resolve() is synchronous; use a progress-bar-friendly apply
df = pd.DataFrame({"raw_address": ["1600 Pennsylvania Ave NW, Washington DC", "10 Downing St, London"]})
results = df["raw_address"].apply(resolver.resolve).apply(pd.Series)
df = pd.concat([df, results], axis=1)
# df now has columns: raw_address, lat, lon, address, provider

For high-volume batches, wrap the resolver in a thread-pool executor (concurrent.futures.ThreadPoolExecutor) or convert _enforce_osm_rate_limit to await asyncio.sleep and switch to an async HTTP client. The Building Async Geocoding Requests in Python guide covers the async conversion in detail.

Rate Limits Are the Binding Constraint

The public Nominatim instance permits roughly one request per second and expects a real contact address in the user agent. That is more than adequate as a fallback for the few percent of records a primary provider misses, and completely inadequate as a primary. The arithmetic below is what decides whether you may use the public endpoint at all.

Does the fallback volume fit inside 1 request per second? A three-step calculation. A nightly batch of 1.2 million rows with a 4 percent primary miss rate produces 48000 fallback lookups. At one request per second the public endpoint supplies 86400 lookups per day, so the volume fits only if the fallback work is spread across the whole day rather than compressed into the batch window. nightly batch 1 200 000 rows 4% primary miss rate fallback lookups 48 000 after cache and dedup 1 req/s budget 86 400 / day 13.3 h of continuous calling It fits the day and not the batch window. Queue fallback work to a separate slow lane rather than blocking the batch, and write the results back on completion. If the miss rate ever doubles, the slow lane silently stops keeping up — so alert on queue depth, not on error rate, and self-host once the sustained volume passes roughly half the budget.

Self-hosting removes the ceiling and replaces it with an operational commitment: an import that takes hours, a machine with substantial memory, and a refresh cadence to keep the extract current. Where that trade lands is covered in Nominatim versus Pelias for self-hosted geocoding.

Edge Cases and Failure Modes

1. ZERO_RESULTS on a Valid Address

Google returns ZERO_RESULTS for addresses that exist but fall outside its coverage — typically rural routes, informal settlements, or recently constructed streets. The fallback to Nominatim is warranted here: Nominatim’s OSM data often includes community-mapped addresses that commercial providers miss. However, if the address is structurally garbled (missing city/state, transposed house number), Nominatim will also return an empty list. Flag these records for upstream address parsing and standardization rather than re-querying indefinitely.

# Adjusted: always try OSM on ZERO_RESULTS
if google_status in ("OVER_QUERY_LIMIT", "REQUEST_DENIED", "ZERO_RESULTS"):
    # fall through to Nominatim block — no early return
    pass

2. Nominatim Serving a Geocoding Artifact

Nominatim occasionally returns a match at the centroid of an administrative boundary rather than a precise street address — this happens when only the city or postal code is indexed for a given record. The display_name will be unusually short (no street component) and the coordinates will be at the centre of a city or district. Validate by checking that display_name contains at least three comma-separated components before accepting the result.

def _normalize_osm(self, payload: list) -> Optional[Dict]:
    if not payload:
        return None
    result = payload[0]
    display_name: str = result.get("display_name", "")
    if display_name.count(",") < 2:
        # Centroid match — too coarse for address-level use
        return None
    return {
        "lat": float(result["lat"]),
        "lon": float(result["lon"]),
        "address": display_name,
        "provider": "osm",
    }

3. Sustained OVER_QUERY_LIMIT Without a Circuit Breaker

If Google quota is exhausted for a multi-hour batch run, every request will hit Nominatim. Without a circuit breaker, the mandatory 1-second inter-request delay throttles the entire pipeline to 1 address/second. Implement a simple counter-based circuit breaker that bypasses the Google call entirely once consecutive OVER_QUERY_LIMIT responses exceed a threshold, and reopens only after a cooldown period.

class GeoFallbackResolver:
    _OPEN_THRESHOLD = 5      # failures before opening the circuit
    _COOLDOWN_SECONDS = 90   # seconds before probing Google again

    def __init__(self, google_api_key: str, user_agent: str = "DataPipeline/1.0") -> None:
        self.google_api_key = google_api_key
        self._osm_headers: Dict[str, str] = {"User-Agent": user_agent}
        self._last_osm_request: float = 0.0
        self._google_fail_count: int = 0
        self._circuit_open_until: float = 0.0

    def _google_circuit_open(self) -> bool:
        return time.time() < self._circuit_open_until

    def _record_google_failure(self) -> None:
        self._google_fail_count += 1
        if self._google_fail_count >= self._OPEN_THRESHOLD:
            self._circuit_open_until = time.time() + self._COOLDOWN_SECONDS
            logger.warning("Google circuit opened for %d s", self._COOLDOWN_SECONDS)
            self._google_fail_count = 0

Integration Note

This page covers the concrete two-provider configuration. It slots directly into the broader Implementing Fallback Chains for Failed Lookups pattern, which defines the full provider priority matrix, exponential backoff strategy, and async executor. Once the resolver is wired up, route its provider tag into your API quota tracking and cost management layer so that Nominatim fallback events appear as a separate cost line — a sustained fallback rate above 20–30% indicates a quota misconfiguration or budget issue that needs addressing at the infrastructure level, not in the resolver itself.