Migrating geocoding providers without downtime: a field guide
Switch geocoding providers safely using a dual-run window, batch re-geocoding, confidence-score diffing, and a feature-flag cutover. No downtime.
Geocoding providers look interchangeable until the moment you try to swap one out. The API shapes are similar enough. The response field names are close enough. The pricing conversation was short enough. Then you migrate, and three months later a customer-service ticket lands saying that a delivery fleet has been routing to the wrong side of a city boundary, or that a portfolio report is double-counting properties that straddle a county line. The change that felt like an infrastructure detail turns out to have changed the answers.
This post is about doing the migration properly: running the old and new providers in parallel long enough to understand where they disagree, re-geocoding your historical corpus in batch before you cut over, using confidence scores to triage the disagreements that actually matter, and hiding the whole switch behind a feature flag that makes rollback a one-line change. No heroics, no downtime, no surprises six months later.
The mechanics are provider-agnostic. Everything described here is the pattern, not a sales pitch. The REST examples use CSV2GEO as the incoming provider because that is what this blog is about — but the dual-run harness, the diff logic, and the cutover protocol apply equally in the other direction.
Why "just swap the API key" fails
The temptation is real. Both providers take an address string and return a latitude and longitude. How different can the answers be?
Different enough that the word "different" undersells it.
Coverage asymmetries. Two providers that both claim 63-country coverage may have very different depth in any given country. One might have street-level accuracy for Japan and centroid-level for Indonesia; the other might be the reverse. If your dataset is weighted towards one region, the headline coverage number means nothing — you need the per-region breakdown for your actual address distribution.
Interpolation vs. parcel-level matching. For an address like "42 High Street," one provider might interpolate a position along the block based on the odd/even numbering run; another might match to a point geometry derived from a national address register. The interpolated answer and the parcel-centroid answer can differ by 30–80 metres on a city street and by several kilometres in rural areas. Neither is wrong by definition. They are measuring different things.
Normalisation side-effects. A provider that silently normalises "St" to "Street" before matching will match differently than one that treats the abbreviated form as a distinct token. A provider that strips unit/flat numbers before geocoding will assign every flat in a building to the same point. You will not see this from the documentation — you will only see it in the diff.
Historical corpus drift. Your application has been geocoding addresses for some period of time, storing lat/lng in a database, and making downstream decisions based on those coordinates. Switching the live endpoint changes the answers going forward but leaves the historical corpus on the old provider's coordinate system. If your application does spatial joins — "find all records within 500 m of this point" — you can end up with a split corpus where old records and new records are using coordinates from different models. That is subtle and very hard to debug after the fact.
All four of these failure modes are visible in advance if you run the providers side by side and diff the output. None of them are visible if you just swap the key and monitor for 5xx errors.
The four-phase migration plan
Phase 1 — Dual-run window
The dual-run window is a period — typically two to four weeks — where every live geocoding request is sent to both providers. The current provider's response continues to be used by the application. The candidate provider's response is logged but not acted upon. At the end of the window, you have a diff dataset that shows you exactly where the two providers disagree, at what frequency, and with what magnitude.
The dual-run is not a latency experiment. You are not comparing response times here (that belongs in a separate benchmarking exercise — see Benchmarking Geocoding APIs — Honest Numbers). You are comparing *answers*. The question is: for the address distribution your application actually sees in production, how often does the candidate provider return a materially different coordinate?
Phase 2 — Corpus re-geocoding
Once the dual-run window has closed and you have decided to proceed, re-geocode the entire historical corpus through the candidate provider before the cutover. Every address in your database that has a lat/lng stored against it should get a new lat/lng from the new provider, written to a staging column, before the feature flag flips.
This is the step most migrations skip. It is also the step whose absence causes the six-months-later support ticket.
Phase 3 — Confidence-score triage
After the dual-run diff and the corpus re-geocode, you will have a set of addresses where the two providers disagree. Not every disagreement matters. A 2-metre shift on an urban address is noise; a 4-kilometre shift on a rural one is a problem. Confidence scores are the filter that tells you which disagreements to actually investigate.
Phase 4 — Feature-flag cutover and rollback window
The live endpoint switches behind a feature flag. The old integration stays warm for one rollback week. After a clean week, the old integration is retired.
Setting up the dual-run harness
The diff harness is your own code — there is no built-in provider-comparison endpoint. The pattern is simple: a thin wrapper that fires both requests concurrently, writes both responses to a log table, and returns the current provider's answer to the application.
Here is a minimal Python implementation:
import os
import time
import hashlib
import requests
import threading
import sqlite3
CURRENT_KEY = os.environ["GEOCODER_CURRENT_KEY"]
CANDIDATE_KEY = os.environ["CSV2GEO_API_KEY"]
CURRENT_URL = os.environ["GEOCODER_CURRENT_URL"] # your existing provider
CANDIDATE_URL = "https://csv2geo.com/api/v1/geocode"
DB_PATH = "dual_run.db"
def _init_db():
con = sqlite3.connect(DB_PATH)
con.execute("""
CREATE TABLE IF NOT EXISTS dual_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address_hash TEXT,
address TEXT,
curr_lat REAL,
curr_lng REAL,
curr_conf REAL,
cand_lat REAL,
cand_lng REAL,
cand_conf REAL,
distance_m REAL,
ts INTEGER
)
""")
con.commit()
return con
_db = _init_db()
def _haversine_m(lat1, lng1, lat2, lng2):
from math import radians, sin, cos, sqrt, atan2
R = 6_371_000
phi1, phi2 = radians(lat1), radians(lat2)
dphi = radians(lat2 - lat1)
dlam = radians(lng2 - lng1)
a = sin(dphi/2)**2 + cos(phi1)*cos(phi2)*sin(dlam/2)**2
return 2 * R * atan2(sqrt(a), sqrt(1-a))
def geocode_dual_run(address: str) -> dict:
"""
Fire both providers concurrently.
Return the current provider's result to the caller.
Log both results for diffing.
"""
curr_result = {}
cand_result = {}
errors = {}
def call_current():
try:
r = requests.get(
CURRENT_URL,
params={"q": address, "api_key": CURRENT_KEY},
timeout=10,
)
r.raise_for_status()
curr_result.update(r.json())
except Exception as e:
errors["current"] = str(e)
def call_candidate():
try:
r = requests.get(
CANDIDATE_URL,
params={"q": address, "api_key": CANDIDATE_KEY},
timeout=10,
)
r.raise_for_status()
cand_result.update(r.json())
except Exception as e:
errors["candidate"] = str(e)
t1 = threading.Thread(target=call_current)
t2 = threading.Thread(target=call_candidate)
t1.start(); t2.start()
t1.join(); t2.join()
# Parse current provider's lat/lng — adapt field names to your provider
c_lat = curr_result.get("results", [{}])[0].get("lat")
c_lng = curr_result.get("results", [{}])[0].get("lng")
c_conf = curr_result.get("results", [{}])[0].get("confidence")
# Parse CSV2GEO candidate response
cand_first = cand_result.get("results", [{}])[0] if cand_result else {}
k_lat = cand_first.get("lat")
k_lng = cand_first.get("lng")
k_conf = cand_first.get("confidence")
dist = None
if all(v is not None for v in (c_lat, c_lng, k_lat, k_lng)):
dist = _haversine_m(c_lat, c_lng, k_lat, k_lng)
addr_hash = hashlib.sha256(address.lower().strip().encode()).hexdigest()[:16]
_db.execute(
"""INSERT INTO dual_run
(address_hash, address, curr_lat, curr_lng, curr_conf,
cand_lat, cand_lng, cand_conf, distance_m, ts)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(addr_hash, address, c_lat, c_lng, c_conf,
k_lat, k_lng, k_conf, dist, int(time.time())),
)
_db.commit()
# Always return the current provider's answer to the application
return curr_resultThe same pattern in Node:
import Database from 'better-sqlite3';
const CURRENT_URL = process.env.GEOCODER_CURRENT_URL;
const CURRENT_KEY = process.env.GEOCODER_CURRENT_KEY;
const CANDIDATE_URL = 'https://csv2geo.com/api/v1/geocode';
const CANDIDATE_KEY = process.env.CSV2GEO_API_KEY;
const db = new Database('dual_run.db');
db.exec(`
CREATE TABLE IF NOT EXISTS dual_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address TEXT,
curr_lat REAL, curr_lng REAL, curr_conf REAL,
cand_lat REAL, cand_lng REAL, cand_conf REAL,
distance_m REAL, ts INTEGER
)
`);
const insert = db.prepare(`
INSERT INTO dual_run
(address, curr_lat, curr_lng, curr_conf, cand_lat, cand_lng, cand_conf, distance_m, ts)
VALUES (@address,@cLat,@cLng,@cConf,@kLat,@kLng,@kConf,@dist,@ts)
`);
function haversineM(lat1, lng1, lat2, lng2) {
const R = 6_371_000, rad = Math.PI / 180;
const phi1 = lat1 * rad, phi2 = lat2 * rad;
const dPhi = (lat2 - lat1) * rad, dLam = (lng2 - lng1) * rad;
const a = Math.sin(dPhi/2)**2 + Math.cos(phi1)*Math.cos(phi2)*Math.sin(dLam/2)**2;
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
export async function geocodeDualRun(address) {
const [currRes, candRes] = await Promise.allSettled([
fetch(`${CURRENT_URL}?q=${encodeURIComponent(address)}&api_key=${CURRENT_KEY}`).then(r => r.json()),
fetch(`${CANDIDATE_URL}?q=${encodeURIComponent(address)}&api_key=${CANDIDATE_KEY}`).then(r => r.json()),
]);
const curr = currRes.status === 'fulfilled' ? currRes.value?.results?.[0] : null;
const cand = candRes.status === 'fulfilled' ? candRes.value?.results?.[0] : null;
const dist = curr && cand
? haversineM(curr.lat, curr.lng, cand.lat, cand.lng)
: null;
insert.run({
address,
cLat: curr?.lat ?? null, cLng: curr?.lng ?? null, cConf: curr?.confidence ?? null,
kLat: cand?.lat ?? null, kLng: cand?.lng ?? null, kConf: cand?.confidence ?? null,
dist, ts: Math.floor(Date.now() / 1000),
});
// Return current provider's answer unchanged
return currRes.status === 'fulfilled' ? currRes.value : null;
}One important detail: the dual-run adds latency to the caller only if the two calls are serialised. Both implementations above fire them concurrently. The effective latency of the dual-run window is max(current_latency, candidate_latency), not the sum.
Step 1 — Analyse the diff before you commit to anything
After one week of dual-run traffic, query the log table:
-- Where do they disagree by more than 100 m?
SELECT
address,
curr_conf,
cand_conf,
ROUND(distance_m) AS dist_m
FROM dual_run
WHERE distance_m > 100
ORDER BY distance_m DESC
LIMIT 50;The rows you care about are where distance_m is large AND cand_conf is high. A high-confidence candidate result that disagrees with the current provider by 500 m is worth investigating: one of them is wrong, and a high confidence score on the candidate means it is fairly sure of its answer. A low-confidence candidate result that disagrees is less alarming — it signals the candidate is uncertain, which is honest.
This is the core of the confidence-score triage. The CSV2GEO confidence score is documented in Geocoding Confidence Scores Explained. Briefly: scores above 0.8 indicate a strong match to a specific address record; scores below 0.6 indicate the provider is interpolating or matching at street or city level. Use 0.75 as a practical cutoff for "worth investigating."
-- High-value disagreements: candidate is confident and far from current
SELECT
address,
curr_lat, curr_lng, curr_conf,
cand_lat, cand_lng, cand_conf,
ROUND(distance_m) AS dist_m
FROM dual_run
WHERE distance_m > 200
AND cand_conf >= 0.75
ORDER BY distance_m DESC;For each row in this set, manually verify the correct coordinate using a map. You will find roughly three categories:
- Candidate is clearly right — the current provider placed the address on the wrong side of a city boundary or in the wrong postcode entirely. This is evidence for the migration.
- Current provider is clearly right — the candidate has a coverage gap or a normalisation quirk for this address class. Log these as known edge cases and decide whether they are tolerable at their frequency.
- Both are reasonable — both coordinates are within a few hundred metres and both would produce correct downstream behaviour. These are not blockers.
Step 2 — Re-geocode the historical corpus in batch
Once you have decided to proceed, run the historical corpus through CSV2GEO's WEB batch tool before the feature flag flips. The WEB batch tool accepts a CSV of address rows and processes them column-by-column; credits are consumed per address row, not per file. For a 500,000-row corpus at the pricing listed at csv2geo.com/pricing/api, this is a predictable one-time cost that you can quote to finance before you commit.
Write the new lat/lng and confidence to staging columns — new_lat, new_lng, new_confidence — rather than overwriting the live columns. This gives you a clean rollback path: the live columns still hold the current provider's coordinates until the feature flag flips.
ALTER TABLE addresses ADD COLUMN new_lat REAL;
ALTER TABLE addresses ADD COLUMN new_lng REAL;
ALTER TABLE addresses ADD COLUMN new_confidence REAL;
ALTER TABLE addresses ADD COLUMN needs_review BOOLEAN DEFAULT FALSE;After the batch re-geocode loads, run a diff pass over the corpus:
UPDATE addresses
SET needs_review = TRUE
WHERE new_confidence >= 0.75
AND ABS(new_lat - lat) + ABS(new_lng - lng) > 0.005;
-- rough bounding-box proxy for "meaningful difference"The needs_review flag surfaces the rows for a domain-expert pass before cutover. For most corpora, this is 1–5% of rows. A human reviewer with a map takes about 30 seconds per row; a thousand flagged rows is an afternoon of work, not a project.
Addresses that are idempotent-safe to retry — that is, addresses with no downstream side-effects from receiving a new coordinate — can skip the manual review entirely. The post Idempotent Geocoding — Safe to Retry covers how to design your pipeline so that re-geocoding never produces unintended side-effects.
Step 3 — Wire the feature flag
The feature flag is the mechanism that makes the cutover safe. A simple approach: an environment variable or a row in a config table. All geocoding requests route through a single function in your codebase that reads the flag.
import os
FLAG = os.environ.get("GEOCODER_PROVIDER", "current") # "current" or "candidate"
def geocode(address: str) -> dict:
if FLAG == "candidate":
return _call_csv2geo(address)
return _call_current_provider(address)At the database level, the cutover is equally simple:
-- When the flag flips to "candidate", promote the staging columns
UPDATE addresses
SET lat = new_lat,
lng = new_lng,
confidence = new_confidence
WHERE new_lat IS NOT NULL;Run this in a transaction. Time it: on a million-row table with the right index in place it should complete in under a minute. If it takes longer, run it in batches of 50,000 rows with a small sleep between batches to avoid holding the lock for the full duration.
Step 4 — Cut over and watch the first 48 hours
Flip the feature flag. Keep the dual-run harness running for the first 48 hours post-cutover — not to compare results any more, but to catch any address classes that were represented in your live traffic but not in your dual-run window. If distance_m spikes for a new address class, you want to know within hours, not days.
Metrics to watch:
- Error rate. A spike in 4xx or 5xx responses from the new provider is the clearest signal of a problem. See Observability for Geocoding Pipelines for the full set of metrics worth instrumenting.
- Confidence distribution. The histogram of confidence scores for the new provider should look similar to what you saw during the dual-run window. A shift towards lower confidence scores indicates the live traffic distribution differs from the dual-run sample.
- Cache hit rate. If you are running a local cache in front of the geocoding endpoint, the hit rate should stay stable after cutover. A drop in cache hit rate means addresses are coming through that were not represented in the cache warm-up — worth investigating.
The caching layer is also your cost backstop. See Caching Geocoding Results — 90% Cost Reduction for the pattern that means re-geocoding an address you have already geocoded once costs nothing. Run that cache warm-up pass against your most frequent addresses before the cutover, not after.
Step 5 — Keep the old integration warm for a rollback week
Do not tear down the current provider integration on cutover day. Keep the API key active, keep the code path reachable, and keep the environment variable pointing at "candidate". A rollback is then one environment variable change — GEOCODER_PROVIDER=current — with no code deployment required.
The rollback window is one week. In practice, if nothing has gone wrong in 48 hours post-cutover, you are almost certainly fine. But the week-long window costs you almost nothing — keeping a dormant HTTP client function in your codebase for seven days is not a maintenance burden — and it removes the adrenaline from the cutover decision. Engineers make better calls when rollback is trivial.
After the rollback window closes, retire the old integration: remove the code path, deactivate the old API key, drop the FLAG == "current" branch. Leave the dual-run log table in place for 90 days as an audit trail.
What to do about rate limits during the dual-run
Running two providers in parallel means your geocoding throughput is effectively doubled during the dual-run window. For most applications this is invisible — the requests are concurrent, not serialised, and both providers receive the same traffic volume they would individually. For high-volume pipelines (more than a few hundred requests per second), check whether your current provider's rate limit is set to allow the dual-run traffic, or whether you need a temporary uplift.
On the CSV2GEO side, rate-limit handling uses a token-bucket model. Requests that exceed the per-minute bucket get a 429 with a Retry-After header. The right response is exponential backoff with jitter. See Exponential Backoff — When to Retry, When to Stop for the retry budget that keeps the dual-run from amplifying a transient 429 into a cascade. The free tier starts at 3,000 calls/day, which is enough for a small dual-run pilot. Paid tiers start at $54/month for 100,000 calls.
What this migration pattern does NOT solve
Two things worth naming honestly.
Address format coverage gaps. If your corpus contains address formats from countries where the candidate provider has weaker coverage than the current one, the diff will surface this — but the migration plan does not fix the gap. The fix is lobbying the candidate provider for better coverage, or accepting a two-provider architecture where a small percentage of addresses route to the specialist provider. CSV2GEO covers 63 countries at street level; check whether your specific address distribution is within that footprint before committing.
Schema differences in the response. The two providers likely return different field names, different confidence-score scales, and different component structures (street, city, postcode, country). The dual-run harness above maps both to a common internal schema — lat, lng, confidence — before logging. The cutover should promote the candidate to the canonical schema, which means updating any application code that reads provider-specific field names off the geocoding response. Audit those callsites before the cutover, not after.
Frequently Asked Questions
How long should the dual-run window be? Two weeks is a practical minimum for most applications. It gives you coverage of weekly traffic patterns (weekend address distributions often differ from weekday ones) and enough volume to surface low-frequency address classes. Four weeks is better if your application has monthly batch jobs that generate geocoding traffic not visible in daily sampling.
Does running both providers in parallel double my cost? During the dual-run window, yes — you are paying for two providers for the same address. This is the cost of a safe migration. It is bounded in time and predictable in magnitude. Offset it by running a local cache warm-up before the dual-run starts, so repeat addresses hit the cache and do not get billed twice. See Caching Geocoding Results — 90% Cost Reduction for the cache layer design.
What confidence score threshold should I use for triage? 0.75 is a reasonable starting point for "worth a human look." Rows below 0.75 from the candidate provider are uncertain regardless of the distance from the current provider — the candidate is telling you it is not sure of its own answer. Rows above 0.75 that disagree by more than 200 m are the useful signal.
Can I use the WEB batch tool for a corpus that is not a simple address list? The WEB batch tool processes CSV files where one column contains address strings. Credits are per address row. If your data has addresses stored as structured components (street, city, postcode separately) rather than a single string, concatenate them into a single address column before uploading.
What if the candidate provider consistently returns lower confidence scores than the current one? This is a calibration difference, not necessarily a quality difference. One provider might reserve scores above 0.9 for exact parcel-level matches; another might score the same match at 0.75. Calibrate your triage thresholds against the dual-run sample rather than using thresholds derived from your current provider's score distribution.
How do I handle the rollback if I have already promoted the corpus to the new coordinates? The staging-column approach above keeps the old coordinates in place until the UPDATE that promotes the staging columns. If you roll back within the same database transaction window, you can simply roll back the transaction. If the promotion has committed, re-geocode the needs_review rows through the current provider using the same batch process — since those are the only rows with meaningful disagreement, the blast radius of a rollback is bounded to the flagged set.
Is there a built-in provider-comparison or migration tool? No. The diff harness is your own script — the providers do not share a comparison interface. The pattern described here is deliberately simple: a log table, a haversine distance, and a confidence filter. Do not over-engineer it; a SQLite file and 50 lines of Python are enough for the analysis step.
Related Articles
- Caching geocoding results — 90% cost reduction — cache-layer design that absorbs the dual-run cost and slashes ongoing spend
- Idempotent geocoding — safe to retry — pipeline design that makes batch re-geocoding free of unintended side-effects
- Benchmarking geocoding APIs — honest numbers — what to measure during the dual-run beyond distance disagreement
- Geocoding confidence scores explained — how to interpret the confidence field that drives the triage step
- Exponential backoff — when to retry, when to stop — retry budget design for the dual-run window and the batch re-geocode job
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →