Nominatim vs Pelias vs the Census geocoder for parcel work
Geocoder comparisons usually rank accuracy, and for parcel work that is the least useful axis — because the county’s own address-point layer beats all of them, and the geocoder’s job here is to generate and confirm candidates rather than to be authoritative. What actually decides the choice is more mundane: does the service tell you how precise each result is, does it cover rural addresses, can you run it against a bulk batch without violating its terms, and can you archive its responses. This guide compares the three on those properties. It is the sourcing decision behind address geocoding & parcel matching.
Diagnosis: the four properties that actually matter jump to heading
Does it report a result kind? This is the single most important property and the one most often ignored. A service that returns a coordinate without saying whether it is a rooftop, a street interpolation or a place centroid cannot be used safely, because the pipeline has no way to distinguish a five-metre answer from a five-kilometre one. Everything in validating geocode results against parcel centroids depends on it.
Can you send a batch? Matching 14 000 permits one request at a time against a rate-limited public endpoint takes hours and is usually against the terms of use. A batch endpoint or a self-hosted instance changes the shape of the pipeline.
What are the licence terms on the output? Some licences require attribution, some restrict redistribution of derived data, and a permit-to-parcel table is derived data. This constrains what you can publish, not just what you can compute.
Can you archive and replay? A match made in March must be reproducible in September, which means storing the raw response. A service whose terms forbid caching results makes that impossible and effectively rules itself out for audited work.
Step-by-step implementation jump to heading
1. Compare them on those properties, not on accuracy claims jump to heading
| Property | Nominatim (OSM) | Pelias | US Census Geocoder |
|---|---|---|---|
| Result-kind field | osm_type + class/type; precision inferred |
match_type (exact/interpolated/fallback), layer |
tigerLineId + side; interpolation is implicit |
| Batch endpoint | none (self-host and loop) | none built in (self-host and loop) | yes — up to 10 000 rows per file |
| Self-hostable | yes, well-trodden | yes, designed for it | no |
| Underlying data | OpenStreetMap | configurable (OSM, OA, WOF, custom) | TIGER/Line |
| Rural US address coverage | patchy — depends on local OSM contributors | as good as the sources you load | strong, since TIGER is the address frame |
| Address-point support | only where OSM has them | yes, if you load an address-point source | no — line interpolation only |
| Licence on output | ODbL (share-alike concerns for derived data) | depends on loaded sources | US public domain |
| Public endpoint suitable for bulk | no (usage policy) | depends on operator | yes, within documented limits |
The row that most often decides it in practice is the last-but-one. ODbL’s share-alike provisions make some organisations uncomfortable about a published parcel-match table derived from Nominatim, whereas TIGER-derived results carry no such constraint — a legal property rather than a technical one, and one that a benchmark will never surface.
2. Normalise the result kind at the boundary jump to heading
Whichever you choose, translate its vocabulary into your own immediately, so downstream code never branches on a provider’s field names.
from dataclasses import dataclass
@dataclass(frozen=True)
class Geocode:
x: float
y: float
kind: str # 'address_point' | 'rooftop' | 'interpolated' | 'place'
provider: str
raw: dict # archived verbatim for replay
CONFIRMING = {"address_point", "rooftop"}
def from_census(resp) -> Geocode | None:
"""TIGER matches are line interpolations by construction. Labelling them
'interpolated' is not pessimism — it is what they are, and it keeps them from
confirming a match on their own."""
m = (resp.get("addressMatches") or [None])[0]
if not m:
return None
c = m["coordinates"]
return Geocode(c["x"], c["y"], "interpolated", "census", raw=m)
def from_pelias(resp) -> Geocode | None:
feats = resp.get("features") or []
if not feats:
return None
f = feats[0]
props = f["properties"]
kind = {"exact": "address_point",
"interpolated": "interpolated",
"fallback": "place"}.get(props.get("match_type"), "place")
if props.get("layer") not in ("address", "venue") and kind == "address_point":
kind = "interpolated" # an exact match on a street is still a street
lon, lat = f["geometry"]["coordinates"]
return Geocode(lon, lat, kind, "pelias", raw=f)
def from_nominatim(resp) -> Geocode | None:
if not resp:
return None
r = resp[0]
# Nominatim does not state interpolation directly; infer from the class/type
# and the presence of a house number in the returned address.
has_number = bool((r.get("address") or {}).get("house_number"))
kind = "rooftop" if has_number and r.get("osm_type") in ("way", "relation") \
else "interpolated" if has_number else "place"
return Geocode(float(r["lon"]), float(r["lat"]), kind, "nominatim", raw=r)
Marking every Census result as interpolated looks harsh and is correct: TIGER geocoding places an address proportionally along a street segment, so it is exactly the result kind that must never confirm a match alone. It is still an excellent candidate generator, and for rural US addresses it is often the only one with coverage.
3. Run them as a ladder, not a choice jump to heading
The properties are complementary, so the practical answer is usually more than one.
def geocode(address, providers, archive) -> Geocode | None:
"""Try in order of the precision they can attest to. Stop at the first result
that can CONFIRM; keep the best candidate otherwise."""
best = None
for p in providers: # e.g. [local_address_points, pelias, census]
try:
g = p.geocode(address)
except TransientError:
continue
if g is None:
continue
archive.put(address, p.name, g.raw) # every response, for replay
if g.kind in CONFIRMING:
return g
best = best or g
return best
4. Self-host when the batch size justifies it jump to heading
Fourteen thousand addresses against a public endpoint is a poor citizen and a slow pipeline. Both Nominatim and Pelias are designed to be self-hosted; a single instance loaded with your state’s extract answers a batch in minutes and removes the rate-limit question entirely. The Census geocoder cannot be self-hosted, but its bulk file endpoint accepts ten thousand rows at a time, which serves the same purpose.
Verification & testing jump to heading
def test_census_results_never_confirm():
g = from_census(census_fixture("1240 County Road 27"))
assert g.kind == "interpolated"
assert g.kind not in CONFIRMING
def test_pelias_street_match_is_not_an_address_point():
g = from_pelias(pelias_fixture(match_type="exact", layer="street"))
assert g.kind == "interpolated" # exact on a STREET is still not a point
def test_every_provider_result_is_archived(archive):
geocode("123 N Main St", [pelias, census], archive)
assert archive.count("123 N Main St") >= 1
def test_ladder_stops_at_the_first_confirming_result(archive):
g = geocode("123 N Main St", [address_points, pelias, census], archive)
assert g.provider == "address_points"
assert not pelias.called
Before committing to a provider, run a coverage probe: take 500 addresses stratified between urban and rural parts of your counties, geocode them, and tabulate result kinds by provider. That is a measurement about your own data, which is the only comparison that generalises to your pipeline.
Failure recovery jump to heading
A provider’s terms turn out to forbid caching. The archive is a hard requirement for audited matching, so this rules the provider out for that use. Re-derive the affected matches with a provider you can archive, and keep the original as a candidate generator only.
Coverage worse than expected in rural counties. Add the Census geocoder to the ladder as a fallback rather than switching wholesale. Its TIGER frame is strongest exactly where OSM-based coverage is thinnest, and the ladder makes adding it a one-line change.
A self-hosted instance drifts from the public one. Pin the extract date and record it alongside the archived response, so a match made against a March extract is reproducible even after the instance is rebuilt. Without that, the archive proves what was returned but not what the index contained.
Frequently asked questions jump to heading
Which one is most accurate?
The wrong question for this work, because the county’s own address-point layer beats all three and should be tried first. What separates them is whether they tell you how precise each result is, how they cover rural addresses, and what their licence permits you to publish. A service that returns a slightly better coordinate without a result kind is less useful than one that returns a worse coordinate honestly labelled.
Why treat every Census result as interpolated?
Because that is what TIGER geocoding does: it places an address proportionally along a street segment. The coordinate is not a building, and on blocks with uneven lot widths it lands on the wrong parcel routinely. It remains an excellent candidate generator, particularly for rural addresses where its coverage is the best available — it just cannot be the evidence that confirms a match.
Does the licence really matter if we only use results internally?
It matters as soon as a derived table leaves the building — a published dataset, a report to a client, an API. A permit-to-parcel table built with a share-alike geocoder is derived data, and the obligations may attach to it. This is worth resolving before the pipeline is built rather than after somebody asks to publish the output.
Is running two geocoders wasteful?
No, because the ladder short-circuits: the second is only called when the first returns nothing or returns a result too imprecise to confirm. In practice most addresses resolve at the first rung, and the fallback earns its place on exactly the rural residue that would otherwise be unmatched.
Related jump to heading
- Parent topic: Address Geocoding & Parcel Matching
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Validating geocode results against parcel centroids — what the result kind is actually used for
- Handling PO boxes and unnumbered addresses — the inputs no geocoder should be asked about
- Municipal API Rate Limit Management — the budget a public endpoint imposes on a bulk batch