Handling addresses that won't geocode: an ops playbook
Practical ops playbook for the 2-5% of addresses that fail or return low confidence: triage buckets, re-query patterns, and precision decisions.
Every geocoding pipeline has a residue. You batch a hundred thousand rows, the engine hums through ninety-seven thousand of them cleanly, and then the remaining two to five thousand sit there, staring at you, with confidence scores below your threshold or no result at all. Someone senior looks at the completion chart, sees it is not 100%, and says: "Just fix the failures."
That sentence — "just fix the failures" — is how a data quality engineer loses three weeks of their life to a never-shrinking queue, re-geocoding the same bad rows over and over, applying ad-hoc manual fixes with no structure, and shipping a result that is no more auditable than when they started.
This post is the alternative. A defined exception workflow: four triage buckets, three progressive re-query patterns, a validation step using reverse geocoding, and a clear decision rule for when to intentionally accept lower precision rather than spin the queue indefinitely. The goal is not perfection — it is a workflow that makes 100% automation *credible to leadership*, because you have a documented, reproducible process for every row that does not geocode cleanly on the first pass.
Why the residue exists
Before the triage, the taxonomy. Failures cluster into four root causes, and each one calls for a different fix. Treating them as one homogeneous pile is the reason "just fix the failures" never ends.
Typos and transpositions. Street number digits swapped, postcode missing a digit, street suffix wrong (St where it should be Ave). These are fixable programmatically or with a thirty-second human glance. They are the most common bucket and the cheapest to clear.
Incomplete addresses. Unit or suite number appended in the wrong field, city omitted, state written as the full word when the parser expects the two-letter code, postcode from a neighbouring city. The address is real; the record is malformed. Fixable, but requires knowing what the complete address should look like — which sometimes means a lookup against a reference dataset or a call to the originating data source.
Genuinely new construction. The building exists, occupants are moving in this month, but it does not yet appear in address reference data. This is not a data entry error. No amount of re-querying will fix it until the data propagates — typically weeks to months. The correct response is to park the row with a pending_new_build flag, set a retry schedule, and move on.
Not actually an address. A P.O. Box masquerading as a street address, a building name with no street component, a landmark name, a business name without a number, a grid reference pasted into the address field by accident. These will never geocode to a parcel-level result and should not be treated as fixable — the input is fundamentally the wrong data type.
Getting this classification right on the first triage pass is what determines whether you spend two hours on the residue or two weeks. The confidence score and the API's returned precision level are your primary signals for which bucket a row belongs in.
Reading the confidence score as a triage signal
The CSV2GEO geocoder returns a confidence score — a number between 0 and 1 — alongside each result. The score reflects how closely the returned location matches the input string, and it is the foundation of the triage system described in this post. A full treatment of how the score is constructed and what its components mean is in Geocoding Confidence Scores Explained; the short version for operational use is this:
- 0.90 and above: High confidence, street-address precision. These rows ship. No triage needed.
- 0.70–0.89: Moderate confidence. The result is likely correct but you should spot-check a sample — roughly five percent of rows in this band will be wrong in a way that matters for your use case. Use these in production but tag them so you can re-examine if downstream anomalies surface.
- 0.50–0.69: Low confidence. Probable match at street or locality level. Route to the triage queue. Do not ship without a re-query attempt.
- Below 0.50: Very low confidence or no result. Route to triage. Most rows here are typos, incomplete addresses, or not-an-address entries.
The response also includes a precision field — values like address, street, locality, postcode, country. A row with confidence 0.82 but precision locality is telling you something different from a row with confidence 0.82 and precision address. For most use cases, locality precision means the engine placed the point at the centroid of a suburb rather than at a specific parcel. Whether that is acceptable depends entirely on your downstream use case, and we will come back to that decision.
The source of truth: what the batch output gives you
If you are running CSV2GEO's web batch tool, the output CSV already contains the columns you need to drive the triage queue without writing a line of code: confidence, precision, match_type, and the returned lat/lng. Filter those columns in a spreadsheet or in your data warehouse, partition by the confidence bands above, and you have the queue.
The triage queue is not a product feature — it is a view of your output file. Your ticket system, your spreadsheet, your internal ops dashboard: whichever tool your team already uses to track work is the right place to manage it. The API gives you the signals; the workflow is yours to own.
A minimal example of pulling the low-confidence rows into Python for programmatic triage:
import csv
LOW_CONFIDENCE_THRESHOLD = 0.70
INPUT = "geocoded_output.csv"
TRIAGE = "triage_queue.csv"
with open(INPUT) as fin, open(TRIAGE, "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(fout, fieldnames=reader.fieldnames)
writer.writeheader()
for row in reader:
score = float(row.get("confidence") or 0)
if score < LOW_CONFIDENCE_THRESHOLD:
writer.writerow(row)The resulting file is your review queue. Everything else ships. The separation is the important part — do not mix high-confidence production rows with low-confidence triage rows in the same downstream table, even temporarily. Downstream consumers will treat them identically unless you encode the distinction explicitly.
Progressive re-query: three passes before human review
Before anything touches a human, run three automated re-query passes. Each pass attempts a progressively simplified or enriched form of the address. Many rows that fail on the first attempt succeed on the second or third.
Step 1: Strip unit noise and re-query
The most common cause of low confidence on an otherwise valid address is noise in the unit or apartment field getting merged into the street address string. 123 Main St Apt 4B Chicago IL 60601 often geocodes cleanly; 123 Main St Apt 4B, Unit 2, Suite A Chicago IL 60601 does not, because the redundant unit tokens confuse the parser.
Pass one: strip anything that matches /(apt|unit|ste|suite|#|floor|fl)\s*[\w\d]+/i from the address string and re-query.
import re
import os
import requests
API = "https://csv2geo.com/api/v1/geocode"
KEY = os.environ["CSV2GEO_API_KEY"]
UNIT_PATTERN = re.compile(
r"\b(apt|apartment|unit|ste|suite|floor|fl|#)\s*[\w\d\-]+\b",
re.IGNORECASE,
)
def strip_unit_noise(address: str) -> str:
return UNIT_PATTERN.sub("", address).strip(" ,")
def geocode(address: str) -> dict:
r = requests.get(
API,
params={"q": address, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
results = r.json().get("results", [])
return results[0] if results else {}
def requery_pass1(row: dict) -> dict:
cleaned = strip_unit_noise(row["address"])
if cleaned == row["address"]:
return row # nothing to strip; skip pass
result = geocode(cleaned)
if result.get("confidence", 0) >= 0.70:
row.update(result)
row["triage_pass"] = "pass1_unit_stripped"
return rowIn practice this clears twenty to thirty percent of the triage queue.
Step 2: Add postcode and re-query
If the original address record has a postcode field that was not included in the geocoding string, append it and retry. The postcode narrows the search considerably and often breaks a tie between identically-named streets in different municipalities.
def requery_pass2(row: dict) -> dict:
if not row.get("postcode"):
return row # no postcode available; skip pass
enriched = f"{row['address']}, {row['postcode']}"
result = geocode(enriched)
if result.get("confidence", 0) >= 0.70:
row.update(result)
row["triage_pass"] = "pass2_postcode_added"
return rowStep 3: Try locality-level geocoding
If two passes against the full address string have not produced a usable result, drop to the most reliable components: street name, city, state. This deliberately targets street-level or locality-level precision. It will not place the point at the specific parcel, but it will confirm that the street exists in the city the record claims — which is useful for detecting the not-an-address bucket.
def requery_pass3(row: dict) -> dict:
locality_q = f"{row.get('street_name', '')}, {row.get('city', '')}, {row.get('state', '')}"
result = geocode(locality_q.strip(", "))
precision = result.get("precision", "")
confidence = result.get("confidence", 0)
if confidence >= 0.60 and precision in ("street", "locality", "postcode"):
row.update(result)
row["triage_pass"] = "pass3_locality"
row["precision_accepted"] = precision
return rowA row that geocodes to street or locality precision in pass three is a candidate for intentional lower-precision acceptance, discussed below. A row that returns nothing at confidence ≥ 0.60 even at locality level is almost certainly either new construction or not-an-address.
Step 4: Classify the survivors
After three passes, the rows that have not resolved belong in one of two bins:
- Probably new construction: the address looks syntactically correct, components exist (street name geocodes, postcode is valid), but the specific house number returns nothing. Flag as
pending_new_build, schedule a retry in 30 days. - Probably not-an-address: the input returns nothing even at locality level, or the components are inconsistent (the postcode belongs to a different state than the city). Flag as
invalid_input, route to the originating data source for correction.
Step 5: Human review for the genuine hard cases
What remains after the four automated steps should be genuinely ambiguous — a small fraction of the original residue. Route these to a human reviewer with the original input, the best geocoding result from the three passes, and a satellite or aerial map centred on the best-guess location. The reviewer's job is one of three actions: confirm the result, manually adjust the pin, or mark the row as unresolvable.
For the manually-placed pin — where a reviewer has dragged a point to what they believe is the correct location — use reverse geocoding to validate before writing the coordinate to production.
Using reverse geocoding to validate a manually-placed pin
When a reviewer manually places a pin on a map, you have a coordinate but no machine-validated address. Reverse geocoding turns that coordinate back into a structured address and lets you check whether what the reviewer placed makes sense before it propagates downstream.
curl -G "https://csv2geo.com/api/v1/reverse" \
--data-urlencode "lat=41.8827" \
--data-urlencode "lng=-87.6233" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"The response gives you a structured address, a confidence score, and a distance in metres between your input coordinate and the nearest known address point in the dataset. That distance is the validation signal.
def validate_manual_pin(lat: float, lng: float, expected_address: str) -> dict:
r = requests.get(
"https://csv2geo.com/api/v1/reverse",
params={"lat": lat, "lng": lng, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
result = r.json().get("results", [{}])[0]
returned_address = result.get("formatted_address", "")
distance_m = result.get("distance_m")
confidence = result.get("confidence", 0)
return {
"validated": confidence >= 0.75 and (distance_m is None or distance_m < 100),
"returned_address": returned_address,
"distance_m": distance_m,
"confidence": confidence,
}If the reverse-geocoded address is within 100 metres and broadly matches the expected street name, the pin is confirmed. If the reverse geocoder returns an address on a different street or more than 200 metres away, the reviewer placed the pin in the wrong location. Route it back for re-review with the reverse-geocoded result displayed alongside the original input. The distance-in-metres logic is covered in more depth in Reverse Geocoding Accuracy and the Distance in Meters.
Node version of the same validation:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function validateManualPin(lat, lng) {
const url = `${API}/reverse?lat=${lat}&lng=${lng}&api_key=${KEY}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`http ${r.status}`);
const data = await r.json();
const result = data.results?.[0] ?? {};
return {
validated: (result.confidence ?? 0) >= 0.75
&& (result.distance_m == null || result.distance_m < 100),
returnedAddress: result.formatted_address,
distanceM: result.distance_m,
confidence: result.confidence,
};
}When to accept lower precision on purpose
A common mistake is treating "below threshold confidence" as a binary failure that requires a perfect address-level result or nothing. In practice, many use cases can operate correctly on street-level or locality-level precision, and refusing to accept that precision wastes time on rows that are operationally fine.
The decision rule is: what is the worst thing that happens if this point is off by 100 metres? By 500 metres? By 2 kilometres?
For marketing territory assignment, a postcode-centroid is usually fine. For emergency dispatch, you need address-level precision and a human must verify every low-confidence row before the address enters the system. For demographic analysis aggregated to census tracts, locality precision is perfectly adequate. For parcel-level risk assessment, address precision is required.
Document the precision threshold for your use case before you start the triage, not after. If your downstream consumer can tolerate locality precision, then pass-three rows flagged as precision_accepted: locality ship to production with a precision column that the consumer can filter on. That is honest — the consumer knows the quality of each row — and it clears the majority of the queue without human intervention.
What you must not do is silently accept lower precision without encoding it in the output. A point at a postcode centroid that is labelled in the database as address-level is a data quality debt that will compound invisibly until someone uses it for something that requires address-level precision and gets a 2-kilometre error.
The business case for defining this workflow
From an operational perspective, a defined exception workflow — documented buckets, documented passes, documented precision policy — is what makes it reasonable to tell leadership that the geocoding pipeline is "done". Without it, the residue is a permanent line item on every weekly ops review, growing and shrinking unpredictably, with no clear definition of what done looks like.
With it, done looks like this: every row in the input file has one of four states in the output — high_confidence, low_confidence_accepted, pending_new_build, or invalid_input. The proportions of each are tracked over time (see Observability for Geocoding Pipelines for the metrics to instrument). If invalid_input grows suddenly, that is a signal about a change in your upstream data source quality, not a call for ad-hoc manual fixing. The workflow surfaces the signal; leadership sees a process, not a pile.
The free tier — 3,000 calls per day — is more than enough to run the three re-query passes on a typical daily residue for most mid-sized operations. Paid tiers start at $54 per month for 100,000 calls; see csv2geo.com/pricing/api for current brackets.
Frequently Asked Questions
What confidence score should I use as the threshold for triage? 0.70 is a reasonable general-purpose threshold for routing rows to re-query. Lower it to 0.80 if your downstream use case is sensitive to positional accuracy (insurance underwriting, emergency dispatch, regulatory reporting). Raise it to 0.60 if your use case aggregates points to large geographies and a 500-metre error is immaterial. The key is documenting the threshold and applying it consistently — not tuning it row by row.
Can I run all three re-query passes in parallel to save time? Yes, but do not merge the results blindly. Parallel passes mean a row may succeed in both pass one and pass two with different coordinates. Your merge logic should prefer the highest-confidence result, and you should log which pass produced it. Running passes sequentially with early exit is simpler and sufficient for most batch sizes.
What is the right retry schedule for `pending_new_build` rows? New construction typically propagates into address reference data within four to twelve weeks of a certificate of occupancy being issued — though this varies by region. A 30-day retry cadence is a reasonable default. After three failed retries (90 days), escalate to manual_review — either the address has a data-entry error, or the construction project stalled. See Idempotent Geocoding — Safe to Retry for how to structure retries safely.
Should the reviewer see the map before or after the three automated passes? After. Sending rows to human review before running automated passes wastes reviewer time on problems that code can solve in milliseconds. The reviewer should only see rows where the automated passes have genuinely failed — typically ten to twenty percent of the original residue after three passes.
How do I detect not-an-address rows without human review? Several signals in combination: pass-three returns no result even at locality level; the input string contains zero digits (most street addresses have a house number); the string matches common business-name patterns. None of these signals is conclusive alone, but three-way agreement is strong evidence. Route these to invalid_input automatically and log the reasons — it gives your upstream data quality report something concrete to act on.
What should the output schema look like so downstream consumers can trust it? At minimum: geocode_status (high_confidence / low_confidence_accepted / pending_new_build / invalid_input), precision (the API-returned precision string), confidence (the raw score), triage_pass (which pass produced the result, or null for first-pass successes), and manual_review_validated (boolean, for rows that went through a reviewer). Any downstream consumer who filters on geocode_status = high_confidence AND precision = address will get the clean subset without needing to understand your triage internals.
Does running multiple passes against the same row count as multiple API credits? Yes. Each geocoding call costs one credit regardless of whether it is a first attempt or a re-query. The free tier (3,000 calls per day) and paid tiers cover this without special consideration for retries — retries are ordinary calls. For very large residues, batch the re-queries alongside your normal daily geocoding volume rather than running them as a separate burst that could approach rate limits. Rate-limiting mechanics are covered in Rate Limiting — Token Bucket vs Leaky Bucket.
Related Articles
- Geocoding confidence scores explained — what the score is built from and how to use it as an operational signal
- Reverse geocoding accuracy and the distance in meters — how to interpret the distance field when validating a manually-placed pin
- Benchmarking geocoding APIs — honest numbers — how to measure match rate and precision distribution in your own data
- Idempotent geocoding — safe to retry — structuring retries so repeated calls on the same row are safe and auditable
- Observability for geocoding pipelines — the metrics to instrument so the residue is a dashboard signal, not a surprise
---
*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 →