Building a geocoder whose world index rebuilds in minutes
Theo Mathieu ·
I build and run OpenGeocoding on my own: a geocoding engine written in Rust, and a hosted API in front of it. This post covers why I built it, how it works, how I measure it and where it is still weak.
Updated 21 October 2026: the benchmark and coverage figures now measure release 0.18.0.
The problem: an index you can’t rebuild
Geocoding at scale means turning a lot of messy text into coordinates. Take a team that receives a few million company addresses, job locations and profile locations a month, in dozens of languages and formats, and needs a country, a region and, where the data allows, a house for each one. The engine’s unscored test sample is 1,000 real strings of that kind.
I first tried to build on Nominatim, the geocoder behind openstreetmap.org. It is good software, but its build time was the problem. Its installation guide strongly recommends 128 GB of RAM or more for a planet import, requires at least 1 TB of disk, and describes the import as taking around 2.5 days on a well-configured machine.
When a rebuild takes days, you don’t rebuild often. Data updates wait. It also means you can’t test a change to parsing or ranking against the whole world, because the thing you would test against takes days to produce. I wanted an index I could rebuild whenever the code or the data changed.
What I built
OpenGeocoding is one Rust process. It parses an address with libpostal, looks it up in a read-only index and answers. There is no database, and nothing is downloaded at runtime: the index and the libpostal models are mounted read-only.
The index is built from Overture Maps, with OpenStreetMap, OpenAddresses and GeoNames as additional inputs. Places, streets and postcodes go into Tantivy, the Rust equivalent of Lucene; house numbers go into finite-state transducers beside it. Dropping the Nominatim base for a search-engine index was the most important decision in the project.
The last recorded world build, on 17 October 2026, took 409 seconds on an 18-core Apple M5 Max with 64 GB of RAM, with a 32.2 GB peak memory footprint and a result of about 16 GB on disk. Source downloads are not included (build record and methodology). This is not a like-for-like comparison with a Nominatim import: the inputs, the indexing work and the hardware all differ. What it changed is how I work. I rebuild the index whenever the builder or one of its source datasets changes.
Rules per country, not exceptions per address
Every country writes addresses differently, and libpostal, a statistical parser, gets some of them wrong. The tempting fix is to patch the address that failed. I try never to do that, and to write the rule for the country instead.
Postcodes are the simplest example. The engine knows the postcode shape of 21 countries:
("gb", r"^[A-Z]{1,2}\d[A-Z\d]?(\d[A-Z]{2})?$"),
("nl", r"^\d{4}([A-Z]{2})?$"),
("ca", r"^[A-Z]\d[A-Z](\d[A-Z]\d)?$"),
("pt", r"^\d{4}(\d{3})?$"),
("us", r"^\d{5}(\d{4})?$"),
("de", r"^\d{5}$"), The table is used to recover a postcode the parser labelled as something else, and to decide whether a postcode belongs to the country the query names. Other rules work the same way: German, Austrian and Swiss street names with a misspelt “strasse”, Portuguese streets under the national register’s abbreviated names, Japanese block addresses the engine reads itself because libpostal splits them, and Korean road-name addresses resolved through their province, city and district hierarchy.
What keeps this honest is a gate. Every pull request that touches the engine runs the full test suite on a full world index, base against head. A case that newly fails, or fails in a worse way, fails the change, and expectations are never loosened to make it pass (evaluation guide). A one-off patch for one address usually breaks another one somewhere else, and the gate shows it.
One process, in Rust
An early version ran the index as a separate process and called it over HTTP. Merging the two into one process took one connection from about 220 to about 360 requests per second, and memory from 6.0 GB to 3.5 GB. Later work on how lookups use Tantivy cut CPU time per request from 8.2 ms to 1.4 ms, mostly by merging index segments at build time and by filtering document kinds in the collector instead of walking a term with 40 million postings. These were single measurements on a busy laptop, recorded in the lookup history, and they are not comparable with the benchmark below.
The hosted API is a separate service
The geocoder stays stateless: no authentication, no database, no writes. Everything commercial lives in a separate service. A Rust edge in front of the geocoder holds API keys, per-second limits and credit balances in memory, so no request waits on Postgres. Every 5 seconds it flushes usage counts and reloads keys and balances. Each flush writes its batch number and the usage counts in one transaction, so a retried batch is never billed twice. If Postgres goes down, the edge keeps serving from its last snapshot. Only successful replies are billed.
How accuracy is measured
The test corpus takes its expected answers from official registries and public reference sources: Wikidata, France’s Base Adresse Nationale, the Dutch BAG, the US Census geocoder, postcodes.io, Kartverket, swisstopo, CartoCiudad and other national address registries. Never from Nominatim, from another geocoder, or from this engine. A case passes when the first result is in the right country, within the case’s distance tolerance and at the right precision (house, street or town), and any house number or postcode it returns agrees with the request.
I sent every provider the same queries, with no country or proximity hints, between 17 and 21 October 2026, and scored release 0.18.0 against their answers on 21 October (scorecard and methodology):
| Service | Matched corpus (11,671 queries) | US/Canada subset (1,945) |
|---|---|---|
| OpenGeocoding | 90.6% | 97.0% |
| Geoapify | 83.5% | 81.1% |
| OpenCage | 64.4% | 50.8% |
| geocode.maps.co | 54.0% | 38.7% |
| Geocodio | not measured | 89.4% |
I ran this evaluation myself, on my own corpus, so read it with the caveats in the scorecard. Several national registries used as expectations (the US, the Netherlands, France, Norway, Switzerland, Spain, Czechia) are also inputs to the index, so in those countries a pass measures fidelity to the register, not independent accuracy. The matched corpus is the cases every provider had answered when I scored it, not a random sample, and most of them were available while I developed the engine; on the fifth of locations held out from development, OpenGeocoding passed 89.6%. The OpenGeocoding row ran locally on an index from the deployed builder, close to but not identical to production. These are pass rates on a test corpus, not a production accuracy estimate.
Performance
On the hosted API, the geocoder’s average processing time was 5 ms over about 87,864 completed requests in the 24 hours to 19 October 2026, 11:55 CEST. That excludes the edge, the network and response delivery (measurement details).
For throughput, I benchmarked release 0.14.0 on 19 October 2026: one Kubernetes pod, 16 concurrent connections, three runs of 20,000 requests each. The runs reached 2,675, 2,656 and 1,774 queries per second, with p95 latency between 18.4 and 29.9 ms, and all 60,000 requests completed without errors. The pod requested one CPU with no limit and used 4.1 to 5.0 cores of a shared Intel Core i5-13500. The client cycled through 1,000 repeating queries with the parse cache warm, and called the pod directly, bypassing the ingress, TLS and the edge. It is a short warm-cache run, not a sustained capacity figure. The third run was slower than the first two; the methodology and raw record has all three.
Where it is weak
House-level coverage across most of Asia and Africa is still poor. In the coverage measurement of the release 0.18.0 world index, India has 85,265 house numbers for 1.46 billion people, China 44,624, Indonesia 68,083, Pakistan 96,338, Nigeria 7,248 and Ethiopia 423. The United States has about 112 million, France 26 million, Japan 38 million and Taiwan 5.5 million. In the weak countries, expect a town or a street, not a house. The benchmark shows the same thing: of 136 street and house cases in Asian countries such as Malaysia, the Philippines, India, Vietnam and Thailand, OpenGeocoding passed 29, and of 416 Korean registry addresses, 54.
The engine is strongest where open address data is dense: North America, most of Europe, Australia and New Zealand, and a few others such as Japan and Taiwan. Outside those, treat it as a town and region geocoder until you have checked your own addresses. The index has streets in 240+ countries and territories, but streets everywhere is not houses everywhere. Asia and Africa are what I’m working on next.
The licence
The engine is source-available under PolyForm Shield 1.0.0, not open source. You can read it, run it, modify it and redistribute it, including commercially, and you can self-host it inside your own product. What you can’t do is offer a product that competes with it, for example by taking the engine and selling it as a geocoding API. A free competing service counts too. I picked it so the project can’t simply be taken and resold as someone else’s API.
The data keeps its own licences: OpenStreetMap and parts of Overture are under the ODbL, and the attributions page lists every source.
Pricing
The free plan has 50,000 requests a month at 2 requests per second, with no card. Paid plans start at $29 a month for 300,000 requests. Autocomplete is free, and when credits run out billable calls stop instead of creating an overage charge (pricing).
Try it
The fastest way to judge it is the demo on the homepage: type an address from your own data, no account needed. The API docs cover search, autocomplete, reverse geocoding and regions, and the engine is on GitHub. If it gets your country wrong, I want to hear about it.