Skip to main content

The Free First Pass

You have a table of addresses and a hosted geocoder that bills per request — every request, easy row or hard. At today's list prices that's somewhere between twenty cents and five dollars per thousand, depending on the provider and your volume. Multiply it out: a million-row table is $200 to $5,000 per pass, and if the table refreshes nightly, you buy it again tomorrow. The no-cost route caps you on rate instead: the public Nominatim server asks for at most one request per second, which puts the same million rows at eleven days.

None of that is a complaint about the services. OpenCage wraps aggregated open data in a pleasant API with friendly storage terms; Google's rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few metres — the highest tier of the geocode cascade. Sourced from address-point and situs data. coveragecoverageThe fraction of a population or region for which a data source has real, non-placeholder entries — e.g. 47% rooftop coverage on Texas addresses. Distinct from accuracy on the rows that are present. is hard to beat; the public Nominatim instance is a donation to the commons that deserves the gentle use its policy asks for. The waste is on your side of the wire: most rows in a real table are ordinary, well-formed addresses that don't need a premium answer, and each one bills like the hard ones.

So don't send them. Run Mailwoman on your own hardware as the first pass over everything, and spend money only on the residual. By the end of this recipe you'll have that cascade wired up: geocode everything locally first, then route only what the free pass couldn't pin well enough to a paid API. What makes it practical is that Mailwoman's result tells you, per row, how good its answer is.

Pass one: everything, locally

Geocode the whole table against your own server. Batch geocoding covers the endpoint; Ingesting giant CSVs covers the streaming path for files that don't fit in memory.

const res = await fetch("http://localhost:3000/v1/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ addresses }),
})
const { results } = await res.json()

A local pass costs CPU time and nothing else, so "geocode all of it, twice if you like" is, for once, a perfectly reasonable plan.

The routing decision

Every result carries a resolution_tier plus a calibrated uncertainty_m radius. The tiertierInternal versioning of which label classes the model emits. Tier 1 is the coarse components (country, region, locality, postcode); Tier 2 adds venue, street, house_number; Tier 3 (future) would add attention, po_box, and POI venue subtyping. Historically called 'Stage 1/2/3' before the runtime-pipeline naming made that ambiguous. is one of address_point (a rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few metres — the highest tier of the geocode cascade. Sourced from address-point and situs data. or parcelparcelA property polygon or record carrying a situs (site) address and often a separate owner mailing address. County GIS parcel aggregations are a training source for address-point variety and situs-vs-owner divergence. point), interpolated (a house-number estimate along the streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels.), street (a streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. centroid: the query named a streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. but no house numberhouse numberThe numeric or alphanumeric identifier of a building on a street. Mailwoman's house_number component; its position relative to the street name flips between locales.), or admin (a localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighbourhood in the hierarchy. or regionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. centroid) — see the API reference for the full response shape. Together they are the routing decision, made explicit per row. Partition on it:

const kept = []
const residual = []

for (const row of results) {
// Escalate what your use case can't accept. Here: anything without a
// street-level point. If city centroids are fine for you, keep the
// admin tier too and the residual shrinks further.
if ("error" in row || row.lat === null || row.resolution_tier === "admin") {
residual.push(row.input)
} else {
kept.push(row)
}
}

If you'd rather think in metres than tierstierInternal versioning of which label classes the model emits. Tier 1 is the coarse components (country, region, locality, postcode); Tier 2 adds venue, street, house_number; Tier 3 (future) would add attention, po_box, and POI venue subtyping. Historically called 'Stage 1/2/3' before the runtime-pipeline naming made that ambiguous., uncertainty_m is the same decision as a number: escalate anything null or above your threshold.

How big is the residual? Measure it: take a thousand-row sample and count. US streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels.-level is where Mailwoman is strongest today, so a US table typically leaves a small residual; elsewhere more rows land at the admin tiertierInternal versioning of which label classes the model emits. Tier 1 is the coarse components (country, region, locality, postcode); Tier 2 adds venue, street, house_number; Tier 3 (future) would add attention, po_box, and POI venue subtyping. Historically called 'Stage 1/2/3' before the runtime-pipeline naming made that ambiguous., which is exactly what this partition is for. Whatever the number comes out to, every row in kept is a row you didn't buy.

Pass two: spend the budget on the residual

Send what's left to the hosted geocoder of your choice. OpenCage fits this slot well — one key in front of aggregated open data, and a trial tiertierInternal versioning of which label classes the model emits. Tier 1 is the coarse components (country, region, locality, postcode); Tier 2 adds venue, street, house_number; Tier 3 (future) would add attention, po_box, and POI venue subtyping. Historically called 'Stage 1/2/3' before the runtime-pipeline naming made that ambiguous. generous enough to validate the cascade before you subscribe:

async function escalate(address: string) {
const url = new URL("https://api.opencagedata.com/geocode/v1/json")
url.searchParams.set("q", address)
url.searchParams.set("key", process.env.OPENCAGE_API_KEY!)
url.searchParams.set("limit", "1")

const res = await fetch(url)
const { results } = await res.json()

return results[0] ?? null
}

Run the residual sequentially, or at whatever rate your plan allows. This is the one stagestageOne of the dataflow stages in the runtime pipeline (normalize, locale gate, kind classify, phrase group, token classify, sequence correct, reconcile, resolve). Distinct from tier (model vocabulary) and phase (plan milestone). of the pipelinestaged pipelineMailwoman's runtime architecture: a sequence of pure-function stages (normalize → query-shape → locale-gate → kind-classifier → phrase-grouper → classifier → decoder) connected by typed handoffs. Each stage is published as its own npm package. where a rate limit doesn't hurt, because the cascade already shrank the work to fit inside it.

Cache what you buy

A paid answer you fetch twice is a bug. Key a cache on the input address (run it through @mailwoman/normalize first, so trivial variants collapse to one entry) and check it before escalating. One caveat worth reading the fine print for: storage terms differ. OpenCage lets you store results indefinitely and says so plainly; some providers require you to treat results as ephemeral and re-query instead. The cascade compounds with friendly storage terms — a small residual, bought once, stays bought.

What "free" actually costs

Name both sides. The first pass isn't free to stand up: you're hosting a server and its data bundles (the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture., plus per-stateregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. address-point shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. if you want US rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few metres — the highest tier of the geocode cascade. Sourced from address-point and situs data. answers), which means a multi-gigabyte download and a box with some RAM. Free per row, not free to run. If you geocode a few hundred addresses a month, skip all of this and use a hosted API directly; the cascade earns its moving parts when volume times refresh rate makes the per-row meter the thing you're optimizing.