Auditing address data quality with a geocoding scorecard
Run your CRM through batch geocoding and read confidence scores as a data-quality diagnosis. Spot formatting debt and dead records before they cost you.
Most CRMs rot quietly. Addresses that were accurate on entry drift over years — businesses move, postcodes change, data-entry errors compound, international records arrive in formats the original data model never anticipated. The people who entered the data moved on. The validation rules that were meant to catch problems were never actually enforced. And now you have a quarter-million address rows and a growing suspicion that a significant fraction of them are garbage.
The hard part is not knowing how much garbage. Without a number, you cannot prioritise remediation. You cannot convince the business to fund a cleaning sprint. You cannot compare this quarter to last quarter and show that things are getting worse (or that your cleaning effort is working).
This post describes a method for putting that number on the table: run your address database through batch geocoding, read the confidence scores as a diagnostic grade, and express the result as a scorecard that you can report and track over time. The audit is straightforward and the first sample run costs nothing beyond the time to export a CSV.
Why batch geocoding is a diagnostic, not just an enrichment
Geocoding converts an address string to coordinates. That is the enrichment story. But the geocoding engine is also doing something else along the way — it is parsing the input, matching it against a reference dataset of 504M+ addresses across 63 countries, and deciding how confident it is in the match.
That confidence score is a quality signal about your input data, not just about the match. A well-formed, unambiguous address — "123 Main Street, Austin, TX 78701" — returns a high confidence score because the parser found every component in the right place and the match in the reference data was exact. A garbled input — "123 Main st austin, tx" — may return a lower confidence score because the parser had to guess about abbreviation intent and postcode. An entirely dead record — "1 Corporate Drive, Springdale" with no state and a company that moved two years ago — may fail to geocode at all, or return a low-confidence result anchored to a postcode centroid rather than a real address.
These are three meaningfully different quality categories:
- Geocodes at high confidence — the address is parseable, matchable, and probably still valid.
- Geocodes at low confidence — the address is usable but has formatting problems, ambiguities, or is matched at a coarser level (city, postcode) than you want.
- Does not geocode — the record is dead, malformed beyond the parser's tolerance, or outside the coverage area.
Reading these three categories across your full database gives you a diagnosis. The share of high-confidence matches is your headline quality score.
Setting up your own thresholds
Before you run anything, decide what the bands mean for your specific use case. The thresholds below are examples — the right values depend on your risk tolerance, your downstream use, and what action each band triggers.
| Band | Confidence range (example) | Suggested label | Suggested action | |---|---|---|---| | Healthy | ≥ 0.8 | Good | No action required | | Formatting debt | 0.5 – 0.79 | Review | Queue for cleaning; still usable | | At risk | 0.3 – 0.49 | Suspect | Block from outbound / flag in CRM | | Dead | < 0.3 or no match | Unusable | Suppress or re-collect from customer |
Set those thresholds in your own policy document before you look at the data. If you decide what "healthy" means after seeing the numbers, you will unconsciously move the line to make the result look better. Fix the thresholds first, run the audit second, report the result honestly.
Running the audit: the web batch tool
If you want to start without writing any code — which is the right call for a first audit — the CSV2GEO web batch tool handles the whole workflow:
- Export your address table as a CSV or Excel file from your CRM or data warehouse.
- Upload it at csv2geo.com and map your column headers to the expected fields (street, city, postcode, country).
- The tool geocodes each row and returns a download with the original columns plus
lat,lng,confidence,match_level, and a handful of diagnostic fields. - Each address row consumes one credit. The free tier gives you 3,000 calls per day with no credit card, which is enough for a representative sample of most databases.
For a first audit, do not geocode your full database immediately. Pull a stratified sample — 500 rows from each region, business unit, or data source that feeds the CRM. The sample run costs nothing beyond the free tier allowance, surfaces the quality distribution within an hour, and tells you whether a full-database run is worth scheduling. If the sample shows 92% high-confidence results, the picture is probably healthy and you do not need to rush. If it shows 40% high-confidence, you have a remediation case to build.
Step 1: Export a representative sample
Pull a sample that is large enough to be statistically useful but small enough to fit comfortably within the free tier. Five hundred to two thousand rows is the right range for most CRMs.
The sample must be representative. Do not cherry-pick your cleanest data — pull a truly random subset, or better, pull stratified by the data sources you are most uncertain about. If your CRM has domestic records entered by your own team and international records imported from a partner, sample both separately. They will almost certainly have different quality profiles, and the difference is the interesting finding.
Export to CSV with at minimum these columns: an identifier (account ID, record ID), the full address broken into components if possible (street, city, region, postcode, country), and the date the record was last verified or updated if you have it. The update date lets you correlate confidence score with record age in your analysis — a classic finding is that records older than three years have meaningfully worse match rates than recent ones.
Step 2: Upload and map columns
In the web batch tool, upload the CSV and map your column names to the geocoding fields. The tool expects to know which column is the street line, which is the city, and so on. If your CRM stores addresses in a single concatenated field ("123 Main Street, Austin, TX 78701"), map the whole thing to the q (free-text query) field — the parser handles it. If the components are separated, map them individually; the parser produces better results with structured inputs. There is a longer treatment of why that matters in the post on cleaning inputs before geocoding.
Do not discard the country column even if your database is ostensibly all domestic. You will often find a small percentage of international records that crept in through partner imports, acquired company data, or customer self-entry. The geocoder needs the country to route those correctly.
Step 3: Interpret the confidence output
Download the enriched CSV and open it. Focus on three columns: confidence, match_level, and — if present — result_type.
`confidence` is a float between 0 and 1. This is your primary grading variable. A 1.0 is an exact match at the address level. A 0.0 means the geocoder could not place the record at all. Everything in between is a spectrum of increasing ambiguity.
`match_level` tells you how coarsely the geocoder matched — address, street, postcode, city, or country. An address that matches at postcode level was not found as an actual address; the engine fell back to the centre of the postcode. This matters enormously for any downstream use that relies on precise location, but it is less obvious than a confidence number. A record can have confidence: 0.7 and match_level: postcode — nominally "mid-range confidence" but actually telling you the specific address was not found. Apply a separate filter: any record with match_level coarser than street is automatically in the "suspect" band regardless of the confidence number.
`result_type` (where available) gives you a human-readable signal — exact_match, interpolated, centroid, and so on. These map roughly onto the match levels but sometimes give you more granular diagnostic information, particularly for records that were matched by interpolation along a road segment versus records that matched a real address point.
See the full confidence score explanation for a deeper walk-through of how these fields are calculated and what they mean in practice.
Step 4: Build the scorecard
Once you have the enriched CSV, the scorecard calculation is simple arithmetic. In Python:
import csv
from collections import Counter
BANDS = [
("healthy", lambda c, ml: c >= 0.8 and ml == "address"),
("formatting_debt", lambda c, ml: 0.5 <= c < 0.8 or (c >= 0.8 and ml != "address")),
("suspect", lambda c, ml: 0.3 <= c < 0.5),
("dead", lambda c, ml: c < 0.3),
]
counts = Counter()
with open("enriched_sample.csv") as f:
for row in csv.DictReader(f):
confidence = float(row["confidence"] or 0)
match_level = row.get("match_level", "")
for label, test in BANDS:
if test(confidence, match_level):
counts[label] += 1
break
total = sum(counts.values())
print(f"Total records: {total}")
for label, count in counts.items():
print(f" {label:20s}: {count:6d} ({100 * count / total:.1f}%)")
quality_score = 100 * counts["healthy"] / total
print(f"\nAddress quality score: {quality_score:.1f}%")Or in Node:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const BANDS = [
{ label: 'healthy', test: (c, ml) => c >= 0.8 && ml === 'address' },
{ label: 'formatting_debt', test: (c, ml) => (c >= 0.5 && c < 0.8) || (c >= 0.8 && ml !== 'address') },
{ label: 'suspect', test: (c, ml) => c >= 0.3 && c < 0.5 },
{ label: 'dead', test: (c, _ml) => c < 0.3 },
];
const counts = Object.fromEntries(BANDS.map(b => [b.label, 0]));
let headers = null;
const rl = createInterface({ input: createReadStream('enriched_sample.csv') });
for await (const line of rl) {
if (!headers) { headers = line.split(','); continue; }
const cols = Object.fromEntries(line.split(',').map((v, i) => [headers[i], v]));
const c = parseFloat(cols.confidence || '0');
const ml = cols.match_level || '';
for (const band of BANDS) {
if (band.test(c, ml)) { counts[band.label]++; break; }
}
}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
console.log(`Total records: ${total}`);
for (const [label, count] of Object.entries(counts)) {
console.log(` ${label.padEnd(20)}: ${count} (${(100 * count / total).toFixed(1)}%)`);
}
console.log(`\nAddress quality score: ${(100 * counts.healthy / total).toFixed(1)}%`);Or if you prefer a one-liner in bash with the downloaded CSV already in hand:
curl -s -o enriched_sample.csv \
"https://csv2geo.com/api/v1/geocode/batch?..." # — use the web tool for first runs
awk -F',' 'NR>1 {
c = $NF+0
if (c >= 0.8) healthy++
else if (c >= 0.5) debt++
else if (c >= 0.3) suspect++
else dead++
total++
}
END {
printf "Healthy: %d (%.1f%%)\n", healthy, 100*healthy/total
printf "Debt: %d (%.1f%%)\n", debt, 100*debt/total
printf "Suspect: %d (%.1f%%)\n", suspect, 100*suspect/total
printf "Dead: %d (%.1f%%)\n", dead, 100*dead/total
}' enriched_sample.csvThe headline number is the healthy percentage. Everything else is the breakdown that tells you where to spend remediation effort.
Step 5: Run the full database and schedule it quarterly
Once the sample confirms your methodology is working, scale to the full database. If it is large — hundreds of thousands of rows — use the REST API directly rather than the web tool, so you can run in parallel, handle retries, and write results back to your database rather than to a file.
import csv, os, time, requests
API = "https://csv2geo.com/api/v1/geocode"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode_row(row):
params = {
"api_key": KEY,
"street": row.get("street", ""),
"city": row.get("city", ""),
"state": row.get("state", ""),
"postal": row.get("postcode", ""),
"country": row.get("country", ""),
}
for attempt in range(4):
r = requests.get(API, params=params, timeout=20)
if r.status_code == 429:
time.sleep(2 ** attempt)
continue
r.raise_for_status()
result = r.json().get("results", [{}])[0]
return {
"record_id": row["record_id"],
"confidence": result.get("confidence"),
"match_level": result.get("match_level"),
"lat": result.get("lat"),
"lng": result.get("lng"),
}
return {"record_id": row["record_id"], "confidence": None,
"match_level": None, "lat": None, "lng": None}
with open("crm_export.csv") as fin, \
open("audit_results.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(
fout,
fieldnames=["record_id", "confidence", "match_level", "lat", "lng"]
)
writer.writeheader()
for row in reader:
writer.writerow(geocode_row(row))
time.sleep(0.05) # stay within rate limits; tune to your planFor large volumes, consider the concurrency patterns post — you can run several workers in parallel and finish a 200,000-row audit in an hour rather than a day. When you do, implement proper backoff; the exponential backoff post has the exact retry pattern that keeps you below the rate limit without unnecessary sleeping.
Once the full run completes, write the four band counts and the headline quality score to a tracking table keyed by run date. That is the foundation of your quarter-over-quarter trend. One number per quarter. That is the deliverable for the business.
What the scorecard tells you — and what it does not
The scorecard is a leading indicator. A falling quality score tells you that data is rotting faster than it is being cleaned. A rising quality score tells you that your remediation efforts are working, or that you have improved your data-entry validation. Both are useful things to know.
What the scorecard does not tell you is *why* quality has changed. For that you need to drill into the bands and look at patterns: are the low-confidence records concentrated in one data source? One country? One time period? One sales team's import? The band breakdown is where that diagnostic work happens.
A particularly useful sub-analysis: join the audit results back to your CRM record-age field. Plot median confidence score by record vintage. If you see a clear negative slope — older records have lower confidence — you have a number for the business: "address data has a half-life of approximately X years in our CRM." That is a convincing argument for implementing re-verification at renewal time.
Another useful cut: join to data source or ingestion channel. CRM records entered by your own customer-success team may have 85% healthy scores. Records imported from a partner list may have 45%. That is a supplier quality conversation backed by a number.
Communicating the findings to non-technical stakeholders
The engineers on your team will understand a confidence distribution. Your VP of Sales will not. Two translations that work in practice:
The "delivery failure rate" framing. Express the dead + suspect percentage as the share of your outbound that is flying blind. "Twenty-two per cent of our customer records cannot be confidently mapped to a location. Any outbound campaign — mail, field sales routing, local promotions — is operating on bad data for one in five contacts." This lands.
The "wasted cost" framing. If you know your average cost per direct-mail piece and your dead-record percentage, the arithmetic is embarrassingly simple. "We send 40,000 physical pieces per year. If 18% of our addresses are suspect, we are mailing 7,200 pieces to addresses that are probably wrong. At £0.60 per piece that is £4,320 per campaign in provable waste, and we have four campaigns a year." A cost number is more persuasive than a quality percentage in most budget conversations.
Neither of these framings requires you to fabricate industry benchmarks. You do not need to say "the average company has a 38% dead-record rate" — you do not have that data, and neither does anyone who claims to. Your own number, computed from your own data, is more credible and more actionable than any invented benchmark.
Observability: making the scorecard part of your data pipeline
The scorecard is most useful when it runs automatically, not as a quarterly manual exercise. If your data warehouse has a daily or weekly ETL, add a geocoding quality check as a stage in that pipeline. Emit the four band counts as metrics to your monitoring system. Alert when the healthy percentage drops below a threshold you set.
This is standard pipeline observability applied to address data — the same pattern described in detail in the geocoding pipeline observability post. The one thing specific to address quality is that you want to track the metric *at the segment level*, not just as a global aggregate. A global quality score that stays flat can hide a deteriorating segment — for example, a partner data feed that is gradually worsening while your own data holds steady.
Cost planning for a recurring audit
At 3,000 free calls per day, a weekly micro-audit of a 3,000-record random sample costs nothing. At paid pricing starting from $54/month for 100,000 calls, a full quarterly audit of a 200,000-record database costs roughly $108 per quarter — two months of the entry tier. The pricing page has the current brackets.
One practical note: if you are running the audit in addition to your regular geocoding workload, plan the audit runs to avoid your peak production hours. The audit is a bulk, latency-tolerant job; it does not need to compete with real-time geocoding calls on your rate limit. Schedule it overnight or over a weekend, run at a moderate concurrency, and let it finish without hurry.
Credits are consumed per address row, not per API call. A batch call that sends 50 addresses uses 50 credits. The math is linear and predictable — no surprise billing from large batches.
FAQ
Do I need to geocode my entire database to get a useful result? No. A stratified random sample of 500 to 2,000 records per data source or region gives you a statistically meaningful quality estimate. Start with a sample run within the free tier, validate the methodology, then decide whether a full-database audit is worth the cost. For most organisations, the sample finding is alarming enough to justify the full run immediately.
What confidence threshold should I use to define "healthy"? That is a policy decision for your organisation, not a technical question we can answer on your behalf. The example thresholds in this post — 0.8 and above for healthy — are a reasonable starting point for most CRM use cases. If your downstream use requires precise coordinates (field-service routing, logistics), you may want to set the healthy threshold higher. If you only need city-level accuracy, you can set it lower. Fix the threshold before you look at the data.
My addresses are in a single concatenated field. Do I need to split them first? The geocoder handles free-text input through the q parameter. You will get better results — higher confidence scores, more precise match levels — if you split components before sending, because the parser performs better with structured input. The post on cleaning and parsing inputs before geocoding walks through how to do that split reliably. For a first audit, send the concatenated field as q and note where the confidence scores are unexpectedly low — that pattern often flags records where concatenation obscured structure the geocoder needed.
Can I use the audit results to clean the data, not just diagnose it? Yes. For records in the "formatting debt" band, the enriched output includes the geocoder's parsed and normalised address components. You can write those normalised components back to your CRM as a corrected version of the input, with a flag that the record was auto-corrected and should be reviewed. For records in the "dead" band, the only reliable path is re-collection — contact the customer, prompt them to verify their address at next login, or remove the record from active campaigns.
How often should I run the audit? Quarterly is the minimum cadence for a CRM that is actively growing or receiving partner imports. Monthly is better if you have a high-volume import pipeline or a fast-moving market. Weekly micro-audits on a random sample are cheap enough within the free tier that there is no reason not to run them continuously and alert on drift. See the observability post for the monitoring pattern.
Does the audit respect GDPR and similar data-protection requirements? The geocoding API processes the address strings you send. If your CRM records contain personal data attached to those addresses, you are responsible for ensuring that your use of the API complies with your own data-protection obligations — the same as with any third-party data processor. The API does not store address data beyond the request/response lifecycle; there is no record retention that would create a long-lived data-processing relationship for addresses that you do not send again.
What if a large fraction of my records are outside the 63 supported countries? Records outside the coverage footprint will fail to geocode or return very low confidence scores. For an international database, segment the audit by country first and run each segment separately. Records in unsupported countries are a separate problem from records in supported countries with formatting issues — treat them as a distinct band rather than lumping them into "dead." Check the current country list before scoping a full international audit.
Related Articles
- Geocoding confidence scores explained — what the confidence field actually measures and how to interpret edge cases
- Cleaning and parsing inputs before geocoding — why structured inputs return better scores and how to split concatenated address fields
- Observability for geocoding pipelines — how to emit quality metrics to your monitoring stack and alert on score drift
- Benchmarking geocoding APIs — honest numbers — what to measure when evaluating geocoding quality, applied here to your own data
- Caching geocoding results — 90% cost reduction — how to cache audit results so quarterly re-runs cost a fraction of the first pass
---
*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 →