Taming Who's On First — making sense of the world's open place data
Mailwoman is an open-source address parser + geocoder that uses Who's On First as its gazetteer. This post is a practical reference on WOF's gotchas and the tooling we built to work around them. Try the demo or see what ships today.
Who's On First is the best open gazetteer we have, and one of the strangest datasets you'll encounter as a developer. This post covers what makes it hard to use, what makes it useful despite the effort, and the tooling we built inside Mailwoman to work with it.
If you've ever tried to answer "what city is this address in?" programmatically with open data instead of a paid geocoding API, you've probably already run into WOF, and it probably raised some questions.
What Who's On First is
WOF is a gazetteer, a structured database of places. It stores places such as countries, regions, counties, cities, neighborhoods, and venues, rather than addresses, roads, or buildings. Each place gets a stable numeric ID, a parent-child hierarchy, multilingual name variants, and a polygon geometry.
It was created by Mapzen (RIP, 2018) as the successor to GeoPlanet (Yahoo's gazetteer, also RIP). The data lives on GitHub as approximately 100 repositories under the whosonfirst-data org, totaling several million individual GeoJSON files. Geocode Earth maintains the canonical SQLite distributions at data.geocode.earth.
WOF gives you something no other open dataset does: a consistent hierarchy with stable IDs. You can take a locality (Houston, id 85922029), follow its parent_id to a region (Texas, id 85688753), follow that to a country (United States, id 85633793), and know the chain is consistent. OpenStreetMap doesn't give you this. GeoNames gives you a partial version. WOF gives you the whole thing, with an opinion on how the world's administrative boundaries nest.
What makes it hard
One file per place
WOF stores each place as a separate .geojson file in a directory tree. A US admin dataset has roughly 120,000 individual files. The French equivalent has about 80,000. Before an analyst can ask a question, the system must open, parse, and index 200,000 JSON files. That setup is a substantial engineering task.
This per-file layout made sense for WOF's original use case: tracking changes to individual places in git. You can see who edited Houston last, what changed, and when. For a geocoder that needs to query "all localities called Houston" across 120K files, it is the wrong access pattern.
The property namespace explosion
A WOF GeoJSON feature's properties object looks like this:
{
"wof:id": 85830005,
"wof:name": "Lawrence Corner",
"wof:placetype": "neighbourhood",
"wof:parent_id": 1729442683,
"wof:country": "US",
"wof:hierarchy": [
{
"continent_id": 102191575,
"country_id": 85633793,
"county_id": 102085493,
"localadmin_id": 404477193,
"locality_id": 1729442683,
"neighbourhood_id": 85830005,
"region_id": 85688689
}
],
"name:eng_x_preferred": ["Lawrence Corner"],
"name:eng_x_variant": ["Lawrence Cor"],
"src:geom": "quattroshapes",
"edtf:inception": "uuuu",
"edtf:cessation": "uuuu",
"geom:area": 0.000047,
"geom:bbox": "-74.73,40.08,-74.72,40.09",
"mz:hierarchy_label": 1
}
There are a few things to notice:
- Namespaced keys everywhere. Each prefix (
wof:,name:,src:,edtf:,geom:,mz:) marks a different source or concern. The schema is a single flat object without nesting, and the key name encodes the meaning. - Name variants are language-coded.
name:eng_x_preferredis the preferred English name.name:fra_x_preferredwould be French.name:zho_x_preferredwould be Chinese. The_x_separator splits language code from name kind (preferred,variant,colloquial,abbr,short). - Some places have dozens of name keys. A major city like Paris has
name:entries in 50+ languages. A rural US neighborhood might have only one. - The hierarchy is pre-computed. Instead of walking
parent_idup the tree at query time, you can read the full ancestry chain that WOF stores in each record. The stored chain is convenient for display, redundant for storage, and occasionally stale when a parent is reclassified.
Brooklyn Integers
WOF IDs are issued by a service called Brooklyn Integers, a distributed ID generator that guarantees uniqueness across the dataset. The IDs are only unique numbers. They are neither sequential, geographically meaningful, nor sortable. That works for lookup, but ID proximity makes no statement about geographic proximity.
Supersession chains
Places get deprecated when a neighboring neighborhood absorbs one, a county boundary changes, or a locality is merged. WOF tracks this via wof:superseded_by arrays. A query that doesn't check supersession may return a place that hasn't existed since 2015.
Parent ID = -1
A parent_id of -1 means "we don't know the parent." A parent_id of 0 means "no parent (this is a continent or Earth itself)." The first French postalcode dataset was ingested with parent_id: -1 for every record, so hierarchy traversal was useless until someone manually assigned parents. Some of those records still have -1.
What we built to tame it
Mailwoman needs WOF for two things:
- Rule classifiers: "is this token a known locality name?" (Used by the locality/region/country dictionaries in the rule-based classifiers.)
- Reconciler concordance scoring: "does this parse's locality/region/country assignment form a valid
parent_idchain in the world?" (Used by Stage 5 joint decoding.)
Each use case has a different access pattern, so we built two layers:
Layer 1: normalized placename index (WOFPlacenameCache)
The rule classifiers only need a fast lookup that answers "is this string a placename in any language?" They need the normalized string and the languages it's valid in, without coordinates, hierarchy, or geometry.
WOFPlacenameCache builds this index by streaming GeoJSON files via TextSpliterator (our line-delimited streaming library), extracting name:* properties, normalizing them (case folding, accent stripping), and inserting into an in-memory Map keyed by the normalized form. The value is a Set of language codes the name appears in.
The normalization matters because WOF stores "São Paulo" with the accent, but user input might arrive as "Sao Paulo" or "SAO PAULO". The index needs to match all three.
Layer 2: per-placetype SQLite DB (PlacetypeDataSource)
The reconciler needs richer queries, such as "give me all localities called Houston with their parent_id chains" and "walk this locality's parent_id up to region and check whether it reaches Texas."
PlacetypeDataSource is a SQLite database per (placetype, language) combination. Schema:
CREATE TABLE records (
id INTEGER NOT NULL,
src TEXT NOT NULL,
name TEXT NOT NULL,
preferred TEXT,
variant TEXT,
colloquial TEXT,
abbr TEXT,
short TEXT,
parent_id INTEGER,
PRIMARY KEY (id, src, name)
);
The table has one row per name variant. "Saint Petersburg", "St. Petersburg", and "St Petersburg" are three rows with the same id and different name/variant/short columns. The reconciler can query any variant form and get the same parent_id chain. That fixes the "not found" problem we hit in testing.
The Piscina pipeline (stalled, documented)
The 120K GeoJSON files can be processed in parallel to build these databases. Our commands/wof/prepare command uses Piscina (a Node.js worker-thread pool) to dispatch files across all available CPU cores. Each worker:
- Reads a GeoJSON file.
- Calls
pluckPlacetypeSpecto extract the structured fields + all name variants per language. - (Should) upsert into the appropriate
PlacetypeDataSource.
Step 3 currently targets Redis, a leftover from an earlier prototype. The migration to SQLite is documented but not yet complete. The design called for in-memory SQLite per worker (zero disk I/O during the hot path) with a consolidation step at the end, but it never got past the design stage.
AsyncSpliterator.asMany — the file that got away
When the data arrives as a single bulk NDJSON dump rather than 120K files, the access pattern changes. Instead of globbing files and dispatching each one, you want to split one huge file into N byte-range chunks and process each chunk in parallel.
AsyncSpliterator.asMany(source, delimiter, concurrency) was built for this case. Given a file handle and a desired concurrency, it seeks to N roughly-equal byte positions in the file, snaps each position to the nearest delimiter boundary (so no line gets split between workers), and returns N independent async iterators that each process their own byte range.
We built it and marked it @internal. We haven't exercised it at scale because the per-file path was sufficient for the repos we use. It is the right primitive for processing the full Geocode Earth SQLite distribution (3+ GB of admin data across all countries).
What's next
These are the next three tasks, in priority order:
- Finish the SQLite migration. The worker targets Redis, and it should target
PlacetypeDataSource. ThepluckPlacetypeSpecoutput already matches the schema. The remaining work is plumbing rather than design. - Wire PlacetypeDataSource into the reconciler. The concordance scoring currently uses the raw WOF
sprSQLite table (from Geocode Earth's distribution). It should use our per-placetype/per-language DBs. Those DBs hold the name variants the raw table doesn't expose. This fixes the "Saint Petersburg not found" class of lookup failures. - Benchmark the in-memory-then-consolidate pattern. If 120K individual writes to the same few DB files from N concurrent workers bottlenecks on SQLite's WAL writer (likely), the in-memory-SQLite-per-worker → ATTACH-and-merge pattern is the fallback. Whether it's needed at all depends on whether step 1 is fast enough without it.
So why put up with WOF?
Every geocoder needs a gazetteer. You can pay for one (Google, here, Mapbox), use an open one (WOF, GeoNames, OSM Nominatim), or build your own from government sources (BAN, NAD, TIGER).
WOF is the best open option for hierarchy and multilingual names, but it's hard to use raw. The per-file layout, the flat namespace, the supersession chains, and the parent_id: -1 holes can each mislead a naive consumer.
We built WOFPlacenameCache, PlacetypeDataSource, the Piscina pipeline, pluckPlacetypeSpec, and AsyncSpliterator.asMany to make WOF usable as a geocoder component. The tooling is incomplete, but the architecture is sound and the incomplete pieces are documented.
If you're building a geocoder or any location-aware system and you need hierarchy and multilingual names from open data, WOF is probably your starting point. We wish someone had told us about the gotchas above when we started.
Where to look
- Who's On First on GitHub — the source repos
- Geocode Earth WOF distributions — pre-built SQLite files
- Spelunker — the official WOF browser/explorer
docs/articles/concepts/whosonfirst-gotchas.md— the stable reference version of this article (data model, gotchas, tooling architecture)docs/articles/concepts/wof-data-pipeline.md— our internal architecture doc for the ingest pipelinedocs/articles/concepts/resolver-and-wof.md— how the runtime resolver queries WOFcore/resources/whosonfirst/— the TypeScript tooling source
