Cutting returned mail before a direct-mail campaign ships
Batch geocode your mailing list before print. Drop low-confidence rows, fix spellings, dedup households — cut returned mail before a stamp is spent.
Every piece of returned mail is a small, fully-documented loss. You have a line item for design, a line item for print, a line item for postage, and a line item for the list — and then, three weeks after the drop, you have a count of pieces that came back with a yellow sticker on them. Each one represents a contact that never happened and a cost that did not move the needle.
Most of that waste is predictable. The bad addresses in your list are, overwhelmingly, not clever frauds — they are stale data, inconsistent spellings, merged CRMs that combined two records for the same household, and unit numbers that a form field captured as apartment numbers or vice versa. A geocoder sees all of that before a single piece goes to print. This post shows how to wire that filter into your pre-campaign workflow: batch geocode the list, triage rows by confidence score, deduplicate households that appear under two different spellings, and hand a clean, coordinate-keyed list to the mail house — rather than handing them the noise and paying to sort it out in returned postage.
Why geocoding is the right first-pass filter
Geocoding is not postal deliverability certification. It does not tell you whether a postal carrier can physically reach an address, whether a mailbox is active, whether a resident has filed a change-of-address form, or whether the building is vacant. There are dedicated services for that — CASS certification and NCOA processing both exist, and if your mail programme is large enough that postage cost is material, you will use them.
What geocoding tells you is whether the address exists as a parseable, locatable point on a street network, and how confident the engine is about that match. Those two facts eliminate a large fraction of the obvious waste before you spend a penny on CASS processing, and they do it cheaply — the geocoding step costs a fraction of a cent per row at any paid tier, well below the cost of one printed piece.
The rows geocoding catches:
- Completely unintelligible addresses. A row that came in as "123 Main" with no city, no state, and no postcode will either fail outright or return a very low confidence score across dozens of candidate streets. A CASS system does not need to see it.
- Street numbers that do not exist on a street that does. "1700 Elm Street" in a town where Elm Street runs from 100 to 899 returns a low confidence, usually with a flag that the input number is outside the known range. That is a data-entry error that postage was about to pay for.
- Abbreviated or misspelled street types. "Blvd" versus "Blvd." versus "Boulevard" are all fine; "Boulveard" is not, and a low confidence score surfaces it for human review.
- City-state-ZIP mismatches. A ZIP code from one state paired with a city in another returns a low confidence or a corrected match that shows you which field was wrong.
- Duplicate households under different spellings. "123 Oak Ave Apt 2B" and "123 Oak Ave #2B" both geocode to the same normalised address and the same coordinate pair. The coordinate is the dedup key. Hit the same household twice and you have doubled your postage to send the same message to one person — which they notice.
None of this replaces CASS. All of it reduces the volume of records that need CASS processing, and it eliminates the records that would fail CASS anyway — which saves you the per-record CASS fee and, more importantly, the per-piece print and postage you would have spent on pieces that were never going to land.
What the CSV2GEO batch endpoint gives you
The web batch tool at csv2geo.com accepts a CSV upload. Each address row costs one credit. The response is a CSV — or JSON if you are calling the API directly — with the input row joined to the geocoder's output: normalised address, latitude, longitude, and a confidence score.
The confidence score is a float between 0.0 and 1.0. It represents the engine's certainty that the normalised result is a correct interpretation of the input. A score of 0.95 means the input matched a known, precise address record with no significant ambiguity. A score of 0.45 means the engine made a reasonable guess but had to resolve several conflicts or fill in significant missing information to get there.
For a mailing list the triage logic is straightforward:
| Band | Action | |---|---| | ≥ 0.80 | Keep — geocoder is confident; proceed to print | | 0.60 – 0.79 | Fix — compare normalised address to input; if they differ materially, flag for human review | | < 0.60 | Drop — cost of verifying exceeds cost of skipping; pull from this drop, verify separately |
The thresholds are a starting point. Adjust them against your own historical returned-mail rate: if you have a dataset with known bad addresses, run it through and see where the confidence scores sit on the bad rows versus the good ones. The Confidence Scores Explained post covers the mechanics in more detail if you want to understand how the score is calculated before you decide on cut-offs.
Building the triage pipeline
Step 1: Export your list and inspect the data
Before you touch the API, look at the list. A quick sort | uniq -c or a pivot table on the city column finds the obvious junk — blank cities, state abbreviations that look like data-entry errors, postcodes that are clearly wrong for the stated state. Catch these in pre-processing and you save credits. The geocoder is not expensive, but there is no point paying it to confirm that a row with no street number and no postcode is bad.
A useful pre-flight check in Python:
import csv, collections
with open("campaign_list.csv") as f:
rows = list(csv.DictReader(f))
# Quick look at the most common cities — obvious junk surfaces here
city_counts = collections.Counter(r.get("city", "") for r in rows)
for city, n in city_counts.most_common(20):
print(f"{n:>6} {city!r}")Anything that shows up as "", "N/A", "na", "unknown", or a repeating garbage string is a drop before the API is called. Pull those rows into a separate file for manual review or discard.
Step 2: Batch geocode the cleaned list
The API accepts addresses as a free-text q parameter. For a mailing list, concatenate the structured fields into a single string per row — street, city, state, postcode — and send them in batches. The web batch tool handles this in the browser; for an automated pipeline you call the API directly.
curl -G "https://csv2geo.com/api/v1/geocode" \
--data-urlencode "q=742 Evergreen Terrace, Springfield, OR 97477" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"A single-record response looks like this:
{
"results": [
{
"input": "742 Evergreen Terrace, Springfield, OR 97477",
"formatted": "742 Evergreen Terrace, Springfield, OR 97477, US",
"lat": 44.0521,
"lng": -123.0868,
"confidence": 0.92,
"match_type": "rooftop"
}
]
}For a list of 20,000 addresses you want to batch the calls — one HTTP request per row is slow and burns latency budget unnecessarily. Use the web batch tool for one-off campaign runs; use the API with concurrent requests for automated pipelines. A Python wrapper that processes a CSV in parallel:
import csv, os, time, requests
from concurrent.futures import ThreadPoolExecutor, as_completed
API = "https://csv2geo.com/api/v1/geocode"
KEY = os.environ["CSV2GEO_API_KEY"]
CONCURRENCY = 10 # stay well inside the rate limit
def geocode_row(row):
q = " ".join(filter(None, [
row.get("address"),
row.get("city"),
row.get("state"),
row.get("postcode"),
]))
try:
r = requests.get(API, params={"q": q, "api_key": KEY}, timeout=20)
r.raise_for_status()
data = r.json()
result = data["results"][0] if data.get("results") else {}
return {**row,
"geo_formatted": result.get("formatted"),
"lat": result.get("lat"),
"lng": result.get("lng"),
"confidence": result.get("confidence"),
"match_type": result.get("match_type")}
except Exception as e:
return {**row, "geo_formatted": None, "lat": None,
"lng": None, "confidence": None, "match_type": None,
"error": str(e)}
with open("campaign_list_clean.csv") as fin:
rows = list(csv.DictReader(fin))
enriched = []
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futures = {pool.submit(geocode_row, r): r for r in rows}
for future in as_completed(futures):
enriched.append(future.result())
time.sleep(0.05) # light throttle; adjust for your plan
fieldnames = list(rows[0].keys()) + ["geo_formatted", "lat", "lng",
"confidence", "match_type", "error"]
with open("campaign_geocoded.csv", "w", newline="") as fout:
writer = csv.DictWriter(fout, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(enriched)The same pattern in Node:
import { createReadStream, createWriteStream } from 'node:fs';
const API = 'https://csv2geo.com/api/v1/geocode';
const KEY = process.env.CSV2GEO_API_KEY;
async function geocodeRow(row) {
const q = [row.address, row.city, row.state, row.postcode]
.filter(Boolean).join(' ');
const url = `${API}?q=${encodeURIComponent(q)}&api_key=${KEY}`;
const r = await fetch(url, { signal: AbortSignal.timeout(20_000) });
if (!r.ok) return { ...row, lat: null, lng: null, confidence: null };
const data = await r.json();
const result = data.results?.[0] ?? {};
return {
...row,
geo_formatted: result.formatted ?? null,
lat: result.lat ?? null,
lng: result.lng ?? null,
confidence: result.confidence ?? null,
match_type: result.match_type ?? null,
};
}Retry logic matters here. Network blips, momentary rate-limit responses, and upstream timeouts all happen during a bulk run. The Idempotent Geocoding — Safe to Retry post covers the design in detail. The short version: write the geocoded output with the original row ID included, so you can re-run failed rows without re-processing successful ones.
Step 3: Apply the keep/fix/drop triage
Once the geocoded CSV is on disk, the triage is a simple filter pass. In Python:
import csv
KEEP_THRESHOLD = 0.80
FIX_THRESHOLD = 0.60
keep, fix, drop = [], [], []
with open("campaign_geocoded.csv") as f:
for row in csv.DictReader(f):
score_raw = row.get("confidence")
if score_raw is None:
drop.append(row)
continue
score = float(score_raw)
if score >= KEEP_THRESHOLD:
keep.append(row)
elif score >= FIX_THRESHOLD:
fix.append(row)
else:
drop.append(row)
def write_csv(rows, path):
if not rows:
return
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
write_csv(keep, "triage_keep.csv")
write_csv(fix, "triage_fix.csv")
write_csv(drop, "triage_drop.csv")
total = len(keep) + len(fix) + len(drop)
print(f"Keep: {len(keep):>6} ({100*len(keep)/total:.1f}%)")
print(f"Fix: {len(fix):>6} ({100*len(fix)/total:.1f}%)")
print(f"Drop: {len(drop):>6} ({100*len(drop)/total:.1f}%)")The fix file is the one that needs a human. Someone on your ops team reviews each row: compare address (the input) to geo_formatted (the geocoder's normalised version). If they differ, the normalised version is probably right. If the geocoder flagged a city-state-ZIP mismatch and the input address is clearly wrong, correct it or drop the record. If the normalised address looks strange — a road that should not plausibly be in that city — it goes to drop rather than keep.
The drop file does not go to the mail house. It goes to whoever owns the source data — the CRM team, the list vendor, the data-hygiene queue — for separate verification. Some percentage of the drops will be rescuable with manual research. Most will not. The point is that you are not paying postage to find that out.
Step 4: Deduplicate by coordinate
This is the step that catches the same household under two spellings. The geocoder normalises both "123 Oak Ave Apt 2B" and "123 Oak Ave #2B" to the same formatted address and the same lat/lng. The coordinate pair is a stable household key.
Round the coordinates to five decimal places before deduping — that is roughly one-metre precision, fine enough to consolidate alternate spellings of the same address without accidentally merging adjacent units in a block of flats.
import csv
from collections import defaultdict
def coord_key(row, precision=5):
lat = row.get("lat")
lng = row.get("lng")
if lat is None or lng is None:
return None
return (round(float(lat), precision), round(float(lng), precision))
seen = {}
deduped = []
dupes = []
with open("triage_keep.csv") as f:
for row in csv.DictReader(f):
key = coord_key(row)
if key is None:
deduped.append(row) # no coordinate, pass through
continue
if key in seen:
dupes.append({"kept": seen[key]["address"],
"dropped": row["address"],
"lat": row["lat"], "lng": row["lng"]})
else:
seen[key] = row
deduped.append(row)
write_csv(deduped, "campaign_final.csv")
write_csv(dupes, "campaign_dupes.csv")
print(f"Deduped list: {len(deduped)} unique households")
print(f"Duplicates removed: {len(dupes)}")The campaign_dupes.csv is worth reviewing, not discarding. A household that appears twice often signals a CRM merge problem — two customer records for the same postal address. Fixing the upstream data saves you postage on every future campaign, not just this one. The Deduplicating Geocoded Addresses with Stable Keys post covers the stable-key pattern in depth if your dedup logic needs to handle multi-unit buildings or addresses that share a lot.
Step 5: Hand the clean list to the mail house
The output of step 4 — campaign_final.csv — has three things the mail house does not always get from a raw CRM export:
- A normalised, geocoder-verified address. Not the raw user input — the standardised form the geocoder returned. This reduces the mail house's own CASS failure rate and avoids having them quote you on a list that is 20% junk.
- A confidence score per row. If the mail house processes the list through CASS and a row that you marked 0.82 comes back as a CASS failure, the confidence score is useful context for diagnosing why. Systematic failures in a certain confidence band suggest a data-quality issue in a particular source field.
- Latitude and longitude. These are useful for saturation-mail geographic targeting — if the campaign has a radius component, the mail house can filter by coordinate range rather than by ZIP code approximation.
The fix file follows a separate path: manual review, then re-geocode the corrected rows, then merge the passing rows into the main list before the print deadline.
The cost arithmetic
Make this calculation with your own numbers, because it varies by print format, postage class, and list size. The structure is always the same.
| Item | Variable | |---|---| | Cost per geocoding credit | ~$0.00054 at the $54/month starting tier (100,000 calls) | | Cost per printed and mailed piece | your input — typically $0.80 to $3.00 depending on format and class | | Drop rate from geocoding triage | your measurement, usually 3–12% of a typical CRM list | | Duplicate rate from coordinate dedup | your measurement, often 2–8% of a list built from merged sources |
The framing that matters: the geocoding step costs less per record than a first-class stamp costs per record by two orders of magnitude. If it surfaces even a 5% drop rate on a 50,000-piece campaign at $1.20 per piece, that is 2,500 pieces × $1.20 = $3,000 in print and postage not spent, against a geocoding cost of 50,000 credits × $0.00054 = $27. The ratio is roughly 110:1 before you count the revenue cost of contacts that never happened.
The free tier — 3,000 calls per day, no credit card — is enough to pilot the triage on a segment of your next list before committing to a paid plan. At 3,000 rows per day, a 50,000-row list takes about 17 days on the free tier. For campaign timelines that allow it, that is a viable way to prove the ROI before any commercial conversation. Pricing for the paid tiers is at csv2geo.com/pricing/api.
What this is not
Be precise about scope when presenting this workflow internally, or you will create expectations the geocoding step cannot meet.
Geocoding is not CASS certification. CASS — Coding Accuracy Support System — is a USPS programme that validates addresses against the USPS delivery point database and assigns a delivery point barcode. It is required for certain postage discounts and bulk-mail rates. Geocoding does not produce CASS certification. It is a cheaper, faster, earlier-in-the-process filter that reduces the volume of records entering the CASS process and improves the pass rate. Position it as a pre-CASS step, not a replacement.
Geocoding is not NCOA processing. NCOA — National Change of Address — matches against the USPS forwarding database to identify records where a resident has moved and filed a change-of-address form. Geocoding does not know whether anyone has moved. If an address geocodes at 0.95, it means the address exists and is locatable; it says nothing about whether the intended recipient still lives there.
A high confidence score is not a deliverability guarantee. A genuinely valid, precisely geocoded address can still be a business that closed, a unit that is vacant, or a building that was demolished since the last data refresh. The geocoder validates that the address exists in the address dataset; it does not validate current occupancy.
None of these limitations reduce the value of the pre-CASS filter. They are just the honest framing that prevents your direct-mail team from abandoning CASS processing because "we geocoded everything already."
Plugging the pipeline into your campaign calendar
A realistic integration into a six-week campaign production schedule:
- Week 1: Pull the list from the CRM. Run pre-flight inspection (blank fields, obvious garbage). Send the cleaned list through the web batch tool or the API. This takes a few hours, not a few days.
- Week 1-2: Triage output arrives within minutes of the API run. Human review on the
fixband happens in week 2. - Week 2: Re-geocode corrected rows from the
fixband. Merge with thekeepband. Run coordinate dedup. Deliver the clean list internally. - Week 3: Clean list goes to CASS processing and the mail house. Expect a higher CASS pass rate than a raw CRM export produces — the geocoding step already removed the most obvious failures.
- Week 4-5: Production and print.
- Week 6: Drop date.
The geocoding step adds half a day to week 1 and half a day to week 2. It reduces the size of the CASS run, usually reduces the CASS failure rate, and reduces the returned-mail count after the drop. The net effect on the campaign calendar is neutral-to-positive.
Frequently Asked Questions
Does geocoding replace CASS and NCOA processing? No. Geocoding validates that an address exists and locates it on a coordinate system. CASS validates addresses against the USPS delivery point database for postage-discount purposes. NCOA identifies records where a resident has filed a change-of-address form. These are different services solving different problems. Geocoding is the cheapest first-pass filter that reduces the volume and improves the quality of records entering the CASS and NCOA process — not a replacement for either.
What confidence score threshold should I use for the drop band? Start with 0.60 as the lower boundary for the fix band, meaning anything below 0.60 goes to drop. Calibrate against your own data: pull a sample of records you know are deliverable and a sample you know are bad, run both through, and see where they cluster. Most real bad addresses — nonexistent street numbers, mismatched city-state-ZIP, completely malformed entries — land below 0.50. Scores between 0.60 and 0.80 are usually real addresses with input-quality problems that a human can fix in ten seconds.
How does the coordinate dedup handle multi-unit buildings? It depends on your precision setting. At five decimal places (roughly one metre), units in the same building but at different points — ground floor versus penthouse, for instance — will have slightly different geocoded coordinates and survive the dedup. At four decimal places (roughly ten metres), they may collapse into a single record if the geocoder places them at the same rooftop point. For a mailing list where apartment and unit numbers are meaningful, use five decimal places and preserve the unit-number field in the dedup key alongside the coordinate. The Deduplicating Geocoded Addresses with Stable Keys post covers the edge cases in depth.
What happens to records that return no result from the geocoder at all? A null result — no latitude, no longitude, no confidence score — goes directly to the drop band. It means the geocoder could not parse the input into a recognisable address at all. Do not send these to the mail house. Route them to your data-hygiene queue for manual review.
Can I re-use the geocoded results for future campaigns against the same list? Yes, and you should. The geocoded coordinates and normalised addresses do not change for addresses that do not change. Store the geocoded output with the original record ID in your CRM or data warehouse, and re-use the geocoded fields for subsequent campaigns against the same record rather than re-billing the API. The Caching Geocoding Results — 90% Cost Reduction post covers the caching pattern in detail — the same logic that works for application-layer caches applies here: cache the geocoded row against the input address string as the key, with a long TTL.
How large a list can the web batch tool handle? The web batch tool is designed for campaign-scale files — tens of thousands of rows in a single upload. For very large lists (hundreds of thousands of rows), the API with concurrent requests and a simple job queue is more appropriate; it lets you process in parallel, handle retries gracefully, and resume a failed run without re-processing completed rows. Both paths cost the same number of credits per row.
Does the geocoder cover international addresses for campaigns that include Canada, the UK, or Australia? CSV2GEO covers 63 countries, so international addresses in those markets are supported. Confidence scores are calibrated globally — a 0.82 from a UK address and a 0.82 from a US address mean the same thing in terms of match quality. For any market where you need CASS-equivalent deliverability certification, you will still need a country-specific postal validation service in addition to the geocoding step.
Related Articles
- Geocoding confidence scores explained — how the confidence score is calculated and what each band actually signals
- Deduplicating geocoded addresses with stable keys — the coordinate-as-key pattern for household dedup, with edge cases for multi-unit buildings
- Caching geocoding results — 90% cost reduction — store geocoded output against your records and skip re-billing on subsequent campaigns
- Benchmarking geocoding APIs — honest numbers — what to measure when evaluating geocoding quality for a mailing-list use case
- Idempotent geocoding — safe to retry — how to design the retry logic for a bulk geocoding run so a partial failure does not cost you double
---
*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 →