Skip to main content

Ingesting Giant CSVs

Someone hands you a national dataset as a single CSV — the NPPES provider registry (millions of rows), an FCC broadband availability drop, a stateregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. address export. You need every row normalized into the same shape, and ideally a coordinate on each one. Two things stand in the way: the file won't fit in memory, and geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. a million addresses one after another takes hours. This recipe is the shape that handles both — a streaming normalizenormalizeStage 1 of the runtime pipeline: deterministic input preprocessing (Unicode NFC, punctuation normalization, whitespace collapse). Returns a NormalizedInput with an offsetMap that maps normalized positions back to the raw input. core you can hold in your headattention headOne of several parallel attention computations in a layer, each free to focus on a different kind of relationship between tokens. Their outputs are concatenated — 'multi-head attention'. Mailwoman uses 4 heads., plus an optional threaded geocode 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). you bolt on only when the per-row cost earns it.

Start with a stream, not a file

normalizeCSV (from @mailwoman/registry) takes a path and a column mapping and hands back an async iterable of SourceRecords. It reads the header, then yields one normalized record per row — it never holds more than a row or two in memory, so the file size doesn't matter.

import { normalizeCSV } from "@mailwoman/registry"

const mapping = {
id: "NPI",
name: "Provider Name",
organization: "Provider Organization Name",
address: ["Address Line 1", "City", "State", "Postal Code"],
}

for await (const record of normalizeCSV("nppes.csv", { mapping })) {
// record.id, record.name, record.organization, record.raw …
sink.write(record)
}

The mapping is the whole interface: each field names the column (or columns) it draws from. A field with several columns — like address above — gets them joined in order, so four NPPES columns become "500 N Hiatus Rd Ste 200, Pembroke Pines, FL, 33026". The original row survives verbatim on record.raw, so nothing is lost; downstream stagesstageOne 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). recompute from it rather than trusting a lossy projection.

What you don't get from normalizeCSV is a coordinate. record.address is undefined here, on purpose — normalizing (column-map, 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. the name, canonicalize the org) costs microseconds a row, and that's the cheap, single-threaded core. GeocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. is a different animal.

Why geocoding is a separate stage

The instinct with a million rows is "throw it on all my cores." That instinct is right for expensive work and actively wrong for cheap work, and the line between them is sharper than it looks.

Dispatching a row to a worker threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. and getting the result back costs something fixed — serialize the row out, deserialize the result in, a few microseconds of structured-clone either way. Normalizing a row costs about the same. So threading the normalizenormalizeStage 1 of the runtime pipeline: deterministic input preprocessing (Unicode NFC, punctuation normalization, whitespace collapse). Returns a NormalizedInput with an offsetMap that maps normalized positions back to the raw input. step spends a microsecond to save a microsecond: you'd run it across eight cores and watch it get slower, because the main threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. now spends all its time packing and unpacking messages. We measured exactly this on light CSV work — 0.3–0.9× of single-threaded. Don't threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. the cheap 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)..

GeocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. is the opposite. Each address runs a neural 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. and several lookups against a multi-gigabyte 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. — milliseconds, not microseconds, a thousand times the dispatch cost. There the fixed overhead vanishes into the work, and threadsthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. pay off. So the split writes itself: normalizenormalizeStage 1 of the runtime pipeline: deterministic input preprocessing (Unicode NFC, punctuation normalization, whitespace collapse). Returns a NormalizedInput with an offsetMap that maps normalized positions back to the raw input. on the main threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases., geocode in workers.

Compose them, and filter in between

geocodeStream (from mailwoman/geocode-stream) is the threaded half. It takes the record stream and a geocoder config, runs the addresses across a worker pool, and yields the records back with address populated. Because it consumes an async iterable and produces one, it composes directly onto normalizeCSV — and the main threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. sits between them, which is exactly where you want your filter:

import { normalizeCSV } from "@mailwoman/registry"
import { geocodeStream } from "mailwoman/geocode-stream"

const geocode = {
wofDBPath: "/data/wof/admin-global-priority.db",
dataRoot: "/data",
locale: "en-US",
country: "US",
}

const normalized = normalizeCSV("nppes.csv", { mapping })

// Cheap, on the main thread: drop rows you'll never geocode before paying for a worker.
async function* onlyWithAddress(records) {
for await (const r of records) if (r.raw?.["Address Line 1"]) yield r
}

for await (const record of geocodeStream(onlyWithAddress(normalized), { mapping, geocode })) {
sink.write(record) // record.address is now populated
}

That filter is the quiet win. GeocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. is the expensive 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)., so every row you discard before it is a worker dispatch you never pay for. NormalizenormalizeStage 1 of the runtime pipeline: deterministic input preprocessing (Unicode NFC, punctuation normalization, whitespace collapse). Returns a NormalizedInput with an offsetMap that maps normalized positions back to the raw input. a million rows, keep the 300 K with a usable address, and only those reach the pool. Filtering is a microsecond; geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. is milliseconds — do the cheap rejection first.

How the workers stay cheap

A worker can't receive your geocoder — a 4 GB SQLite handle and a loaded neural 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.' don't survive a postMessage. So they don't cross. geocodeStream sends each worker only the serializable geocode config (paths, localelocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for.), and the worker rebuilds its own classifier, WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. lookup, 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 shardshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. provider at startup. After that, only the config went out and only the enriched record comes back.

Two consequences worth holding onto:

  • The DB is opened per worker, read-only. Each worker opens its own handle to the same 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. file; the OS page cache is shared underneath, so you're not paying for N copies of the data, but you are paying for N readers contending on it (more on that next).
  • Records arrive in completion order, not input order. A pool finishes rows as workers free up, so don't zip the output back onto your input by position. Re-key by record.id — which is why the mapping always carries one.

Don't reach for all your cores

This is why geocodeStream defaults its concurrency low instead of to your core count, and it surprises people: geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. looks CPU-bound, but it is latency- and memory-bound. Every row makes random reads into that multi-gigabyte WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. database, and the classifier already spreads each inferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. across several cores. Stack more workers on top and they don't get more compute; they contend for the same memory bandwidth and the same DB pages.

A sweep over real NPPES addresses on a 16-core box, single 4 GB 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.:

WorkersThroughputSpeedup
143 rows/s
257 rows/s1.4×
351 rows/s1.2×
447 rows/s1.1×
643 rows/s

Throughput peaks at two workers and declines from there — by six, you're back to single-threaded, having spent six cores to get there. Capping per-worker inferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. threadsthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. didn't move it either; the ceiling is the shared database, not the CPU. So treat concurrency as something you sweep for your data and your disk, starting low. The win from threading geocode is real but modest (~1.4×), and the way to lose it is to ask for more.

If your 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. fits in RAM, or you've sharded it across disks, your curve will sit higher — measure it. The default (min(4, cores)) is deliberately conservative so the out-of-the-box behavior helps rather than thrashes.

When to stop at normalize

Not every ingest needs a coordinate. If you're loading records to dedupe by name and org, or to join on an ID, the address never gets geocoded — so there's no heavy 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). to threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases., and normalizeCSV on its own is the whole job. Reaching for geocodeStream there would only add worker overhead to microsecond work, the exact lossloss functionA number measuring how wrong the model's predictions are on a batch of examples. Training minimizes it. Mailwoman's loss combines per-token negative log-likelihood with the CRF sequence loss. from two sections ago. ThreadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. the 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). that earns it; leave the cheap one alone.

Once you have geocoded SourceRecords, Geocode-first record matching covers the dedup/entity-resolution step this ingest usually feeds, and Displaying results on a map covers turning the output into a map you can eyeball.