Geocode a CSV of customer addresses
Let's say you have a CSV of customer addresses exported from a CRM, and you need coordinates on a map before Friday. By the end of this page you'll have run twenty of them through the geocoder twice — once with a Node loop that writes JSON lines, once with a single CLI command that writes GeoJSON — and you'll know what each path does with a row that can't be geocoded. About twenty minutes.
Prerequisites
Three things, and the second one will stop you partway through if it isn't done.
-
The first-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. install, plus one more package for reading the CSV:
npm install mailwoman @mailwoman/neural @mailwoman/neural-weights-en-usnpm install @mailwoman/resolver @mailwoman/resolver-wof-sqlite spliterator -
candidate.dbon disk, at<data root>/wof/candidate.db. Coordinates come from 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., which is a 1.65 GB download you do once, and it lands at that path. Your first ten minutes has the command. Every transcript below assumes you ran it; none of them exports anything. -
A CSV whose columns you know. This page uses customers.csv — twenty invented companies at real streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. addresses across New York, Illinois, California, Massachusetts and the District of Columbia. Download it next to your script.
candidate.db on its own answers at the admin tier: each row resolves to its citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy.'s centroid rather
than to its building, which is what every transcript below shows. The loop asks for streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. too,
finds none at this point in the sequence, and degrades to admin without complaining. Step 6 pulls one
state and runs the same script again, so you can see what changes and what does not.
1. Look at the file
head -4 customers.csv
customer_id,company,street,city,state,postal_code
C-1001,Harbor & Vine Catering,1275 Pennsylvania Ave NW,Washington,DC,20004
C-1002,Beacon Ridge Analytics,800 F St NW,Washington,DC,20004
C-1003,Tidewater Print Shop,1000 Jefferson Dr SW,Washington,DC,20560
Six columns, one address split across four of them, and further down the file a company name with a comma inside it, wrapped in quotes. Your own export has that row too.
2. Write the loop
Save this as geocode-csv.mjs. It builds the geocoder once, walks the file, and writes one JSON object
per line.
import { createWriteStream } from "node:fs"
import { NeuralAddressClassifier } from "@mailwoman/neural"
import { createWOFResolver } from "@mailwoman/resolver"
import { AddressPointSqliteLookup, StreetInterpolator, WOFCandidateTableLookup } from "@mailwoman/resolver-wof-sqlite"
import { geocodeAddress, ShardProvider } from "mailwoman/geocode-core"
import { mailwomanDataRoot, resolveCandidateDBPath } from "mailwoman/resolver-backend"
import { CSVSpliterator } from "spliterator"
const classifier = await NeuralAddressClassifier.loadFromWeights({ locale: "en-US" })
// The same search the CLI runs: --candidate-db, then $MAILWOMAN_CANDIDATE_DB, then <data root>/wof/candidate.db.
const lookup = new WOFCandidateTableLookup({ databasePath: resolveCandidateDBPath() })
const resolver = createWOFResolver(lookup)
// Street shards, when any are on disk under $MAILWOMAN_DATA_ROOT. Absent ones degrade to admin.
const shards = new ShardProvider({ AddressPointSqliteLookup, StreetInterpolator }, mailwomanDataRoot())
const out = createWriteStream("customers.geocoded.jsonl")
const counts = { matched: 0, mismatched: 0, skipped: 0 }
// enableQuoteHandling is opt-in: without it a quoted "Rowan, Fitch & Co." splits into two columns.
for await (const row of CSVSpliterator.fromAsync("customers.csv", { mode: "object", enableQuoteHandling: true })) {
if (!row.street || !row.city) {
console.warn(`skip ${row.customer_id ?? "(no id)"} — street or city column is empty`)
counts.skipped++
continue
}
const query = [row.street, row.city, row.state, row.postal_code].filter(Boolean).join(", ")
const result = await geocodeAddress(query, { classifier, resolver, shards: shards.for, defaultCountry: "US" })
// hierarchy[].name is what the gazetteer matched; row.city is what you sent. Compare them.
const matched = result.hierarchy.find((node) => node.tag === "locality")
if (matched && matched.name.toLowerCase() === row.city.toLowerCase()) {
counts.matched++
} else {
console.warn(
`mismatch ${row.customer_id} — sent "${row.city}", gazetteer returned "${matched?.name ?? "nothing"}"`
)
counts.mismatched++
}
out.write(
JSON.stringify({
customer_id: row.customer_id,
company: row.company,
query,
lat: result.lat,
lon: result.lon,
resolution_tier: result.resolution_tier,
uncertainty_m: result.uncertainty_m,
matched_locality: matched?.name ?? null,
region: result.region,
}) + "\n"
)
}
out.end()
shards.close()
lookup.close()
console.log(
`matched ${counts.matched}, mismatched ${counts.mismatched}, skipped ${counts.skipped} → customers.geocoded.jsonl`
)
Five decisions in there are worth naming.
The classifier, the lookup, the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. and the shardshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. provider are built once, outside the loop. Loading the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' is the expensive part of a geocode, and hoisting it out is why the row count barely moves the clock: the same script over one row runs in 1.35 s and over twenty in 1.53 s.
shards: shards.for is what lets this script get more precise later. ShardProvider resolves the
per-state rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few meters — the highest tier of the geocode cascade. Sourced from address-point and situs data. and interpolationinterpolationA geocoding technique that estimates a coordinate along a street segment based on the house number range. Used as the middle tier of Mailwoman's geocode cascade when exact address-point data is unavailable. databases by filename under $MAILWOMAN_DATA_ROOT, opens each one
once, and returns nothing for a state you have not downloaded. Omit it and the loop is pinned to citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy.
centroids no matter what you pull. Step 6 is the proof.
enableQuoteHandling: true is opt-in and you want it. Without it, CSVSpliterator splits on every
comma, so "Rowan, Fitch & Co." becomes two columns and every field after it shifts left — including
the address. The row still geocodes. It geocodes the wrong string.
The address is rebuilt from four columns with join(", "). The parser takes free text, so handing
it commas between the parts you already know are separate costs nothing and removes a guess.
hierarchy[].name is compared against the citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. you sent. name is 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.'s own name for
the place it matched; value is your input spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree.. Step 4 is what that comparison is for.
3. Run it
node geocode-csv.mjs
matched 20, mismatched 0, skipped 0 → customers.geocoded.jsonl
head -3 customers.geocoded.jsonl
{"customer_id":"C-1001","company":"Harbor & Vine Catering","query":"1275 Pennsylvania Ave NW, Washington, DC, 20004","lat":38.904831,"lon":-77.016216,"resolution_tier":"admin","uncertainty_m":null,"matched_locality":"Washington","region":"DC"}
{"customer_id":"C-1002","company":"Beacon Ridge Analytics","query":"800 F St NW, Washington, DC, 20004","lat":38.904831,"lon":-77.016216,"resolution_tier":"admin","uncertainty_m":null,"matched_locality":"Washington","region":"DC"}
{"customer_id":"C-1003","company":"Tidewater Print Shop","query":"1000 Jefferson Dr SW, Washington, DC, 20560","lat":38.904831,"lon":-77.016216,"resolution_tier":"admin","uncertainty_m":null,"matched_locality":"Washington","region":"DC"}
Twenty rows in 1.53 s wall on the machine this page was verified on, modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' load included. The three DC
rows share a coordinate because they share a citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy., and resolution_tier: "admin" says so — three
different buildings, one centroid, and uncertainty_m: null because the admin tier has no radius to
report. The shardshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. provider was asked for DC and had nothing to give.
JSON lines rather than one JSON array: the file is appendable, streamable, and survives a crash halfway through with the rows written so far still readable.
4. Break it on purpose
Add three rows your CRM will eventually produce — one with an empty streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels., one at a place that does not exist, one truncated mid-line — and run it again.
cat >> customers.csv <<'EOF'
C-1021,Ninefold Optics,,Portland,OR,97205
C-1022,Larkspur Tooling,42 Nonexistent Ln,Zzyzx Falls,ZZ,00000
C-1023,Half Row Co,88 Beacon St
EOF
node geocode-csv.mjs
skip C-1021 — street or city column is empty
mismatch C-1022 — sent "Zzyzx Falls", gazetteer returned "Falls Township"
skip C-1023 — street or city column is empty
matched 20, mismatched 1, skipped 2 → customers.geocoded.jsonl
Two of the three announce themselves before any work happens: an empty streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. column and a row that ran out of columns both fail the guard at the top of the loop, and neither costs a geocode.
The third is the one to design for.
tail -1 customers.geocoded.jsonl
{"customer_id":"C-1022","company":"Larkspur Tooling","query":"42 Nonexistent Ln, Zzyzx Falls, ZZ, 00000","lat":40.168649,"lon":-74.791478,"resolution_tier":"admin","uncertainty_m":null,"matched_locality":"Falls Township","region":"ZZ"}
Zzyzx Falls, ZZ is not a place, and the row came back with a coordinate anyway — in Pennsylvania. 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. matched on Falls, found Falls Township, and returned it. Nothing in the result object is
false: the coordinate is Falls Township's, and matched_locality says Falls Township. Only the
assumption that a coordinate means a match is false.
A returned coordinate is a match against a 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., not a check that the address exists — so compare
hierarchy[].name against the value you sent and route the disagreements to review, which is the
comparison already in the loop above. It costs one string compare per row and it is the difference
between twenty-one usable coordinates and twenty.
5. The one-command version
mailwoman registry run does ingest, geocode and export in one call. It is the CLI in front of the
record matcher, so what it writes is an entity set rather than a row dump.
Two things about it before you run it. Unlike geocode, it will not start without a --resolve-db or
$MAILWOMAN_WOF_DB, even though the candidate 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. is what answers — so pass 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.'s own path to satisfy the check. And its output properties are the record matcher's normalized
view of your row, not your row: your customer_id rides in sourceIds.
npx mailwoman registry run customers.csv --infer-mapping \
--resolve-db "$MAILWOMAN_DATA_ROOT/wof/candidate.db" --out customers.geojson
[resolver] candidate-table backend (demo-parity, population-first): /tmp/mw-trial/mailwoman-data/wof/candidate.db
registry: 20 rows → 20 records (20 geocoded) → 20 entities (26 candidate pairs)
wrote 20 features → customers.geojson
jq '.features[0]' customers.geojson
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-77.016216,
38.904831
]
},
"properties": {
"entityID": "entity-0",
"recordCount": 1,
"cohesion": null,
"sourceIds": [
"C-1001"
],
"sources": [],
"name": null,
"organization": "harbor and vine catering",
"address": "Pennsylvania Ave NW 1275, 20004 Washington",
"geocodeTier": "admin"
}
}
20 rows → 20 records → 20 entities is the line to read: twenty inputs in, twenty entities out, so no
two records were linked. 26 candidate pairs counts the comparisons the blockingblockingThe first stage of entity resolution: generate candidate record pairs using cheap, high-recall keys (geo cell, canonical address, phone) instead of comparing every record to every other (O(n²)). The matcher only scores pairs that survive blocking. pass thought worth
scoring, out of the 190 a full cross-product of twenty records would have been — the pass runs whether
or not it finds anything, and on a file of distinct customers it changes nothing.
Reach for this one when you want GeoJSON without writing the conversion. Reach for the loop when you want your own columns to survive.
6. Make the same script more precise
Nothing above needs to change to get building-level coordinates — only the data root does. Pull one
jurisdiction (Improve geocode precision walks through what this
downloads and what it costs) and run geocode-csv.mjs again, untouched:
npx mailwoman data pull us --only dc
node geocode-csv.mjs
matched 20, mismatched 0, skipped 0 → customers.geocoded.jsonl
head -4 customers.geocoded.jsonl | jq -c '{customer_id, resolution_tier, uncertainty_m, lat, lon}'
{"customer_id":"C-1001","resolution_tier":"address_point","uncertainty_m":1,"lat":38.89566505262116,"lon":-77.02925468482408}
{"customer_id":"C-1002","resolution_tier":"address_point","uncertainty_m":1,"lat":38.89698648071389,"lon":-77.02328617926172}
{"customer_id":"C-1003","resolution_tier":"address_point","uncertainty_m":1,"lat":38.88877947111117,"lon":-77.02595808242873}
{"customer_id":"C-1004","resolution_tier":"interpolated","uncertainty_m":98,"lat":38.900203906079575,"lon":-76.99797771359188}
The four DC rows no longer share a coordinate, and the summary line is identical because nothing about
the file or the loop changed. The sixteen rows in other states still read admin — precisionprecisionOf the spans the model labeled as a given tag, the fraction it got right. High precision means few false positives. Paired with recall to compute F1. is bought
per jurisdiction, not per install.
One number to know if you compare this against mailwoman geocode on the same address: C-1004 reports
uncertainty_m: 98 here and 141 from the CLI. Both are right. 98 m is the raw half-segmentsegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context. radius,
which is what geocodeAddress returns by default; the CLI multiplies it by DC's conformal factor of
1.44 for a roughly 90% bound, and 98 × 1.44 = 141. Pass interpCalibration to geocodeAddress when you
want the calibrated figure from the library — the per-regionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. table lives in
interp-calibration.ts.
What you have now
Two files: customers.geocoded.jsonl, one line per input row with your ids intact, and
customers.geojson, a FeatureCollection you can drag into QGIS or hand to a map library. Both carry
resolution_tier, the field that tells you whether a coordinate is a city centroidlocality centroidThe representative center point of a city or locality, used as a coarse coordinate when no exact address point is available — the coarsest tier of the geocode cascade. or a building — and
a loop wired to pick up every streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. shardshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. you ever download without an edit.
Next
- Improve geocode precision — what step 6 downloaded, what the four
tiers mean, and what
uncertainty_mmeasures in each. - Run the API server — the same geocoder behind an HTTP endpoint, for the services that can't load a modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.'.