Choosing a Geocoder: Coverage, Licence and Cost Compared
Problem statement
"Which geocoder should I use?" has no general answer, because the three things that decide it pull in different directions:
- Coverage โ does it know about the addresses you actually have, in the countries you actually have them?
- Licence โ are you allowed to store the coordinates, put them on a non-provider map, or redistribute them?
- Cost โ per request, per month, or the engineering time to run your own.
A geocoder that is free and excellent in one country can be unusable in the next one, and the failure is invisible: it returns coordinates for everything, they are simply the wrong kind of coordinate.
The trap is choosing on price and discovering the licence six months later, when the results are already in a database you are contractually not allowed to keep.
Quick answer
Decide in this order โ each step eliminates options the next one would have wasted time on:
- Licence first. If you must store coordinates permanently, or show them on a map that is not the provider's, that removes several major APIs before you compare anything else.
- Coverage second. Test on a real sample of your addresses, not on famous ones. Every geocoder finds Buckingham Palace.
- Volume third. Under a few thousand lookups, almost anything works. Above a few hundred thousand, self-hosting usually costs less than the API and always costs more attention.
- Precision fourth. Ask what happens when the match fails โ does it say so, or does it return the town?
def shortlist(*, must_store: bool, monthly_volume: int, countries: list[str]):
"""The decision, in the order that actually eliminates options."""
options = ["self-hosted Nominatim", "self-hosted Pelias", "commercial API",
"national address register", "hosted open geocoder"]
if must_store:
options = [o for o in options if "commercial API" not in o or "check terms" in o]
if monthly_volume > 100_000:
options = [o for o in options if "self-hosted" in o or "register" in o]
if len(countries) > 3:
options = [o for o in options if "register" not in o] # registers are national
return options
Step-by-step solution
1. Read the licence before the docs
Three restrictions appear repeatedly in commercial geocoding terms, and each one invalidates a common architecture:
- No permanent storage of the returned coordinates (sometimes with an exception for a short cache).
- Display-with-our-map clauses requiring the results to be shown on the provider's basemap.
- No redistribution โ you may use the coordinates internally but not pass them to a client or publish them.
Open data has its own conditions rather than none. OpenStreetMap-derived geocoders return data under the ODbL, which requires attribution and imposes share-alike obligations on derived databases. National address registers are frequently open but occasionally licensed per seat.
None of this is a reason to avoid any option. It is a reason to decide it first, because it is the only constraint that cannot be engineered around.
2. Test coverage on your own sample
Take 200 real addresses, stratified by country and by how messy they are, and geocode them against each candidate. Score each result against a precision ladder rather than a binary hit/miss:
rooftop / building the address itself
street the right road, wrong point along it
postcode the right postal unit
locality / city the right town
region / country useless as a location
no match honest
A geocoder that returns "no match" for 15% of a file is often better than one that returns a city centroid for the same 15%, because the first one tells you.
3. Measure the disagreement between two geocoders
Running two geocoders over the same sample and measuring the distance between their answers is the cheapest accuracy proxy available without ground truth:
- Under about 50 m โ both found the address; the difference is reference-data detail.
- 50 m to 1 km โ one is interpolating along a street or using a postcode centroid.
- Over 1 km โ one of them has fallen back to a locality or matched the wrong place entirely.
You do not need to know which one is right to learn a lot. A pair of geocoders that agree within 50 m on 90% of a sample is telling you the sample is easy; one that disagrees by kilometres on a third of it is telling you the file needs work before any geocoder can help.
4. Price the whole job, including the second run
Costs that people forget when comparing per-request prices:
- The re-run. Data arrives monthly and the file is re-geocoded. Cache and you pay once; forget to cache and you pay every month for the same rows.
- The distinct-address discount. A 40,000-row customer file typically contains far fewer distinct addresses. Deduplicate before you send.
- Self-hosting is not free. A national Nominatim import needs substantial disk and a long initial build, plus a person to re-import when the data updates.
- Failure costs. Rows that come back as centroids cost the same as rows that come back correct, and then cost again in the analysis they corrupt.
5. Decide what happens to the low-confidence rows
This is a product decision, not a technical one, and it belongs in the choice of provider because providers differ in how much help they give:
- Reject below a precision level, and report the rejects.
- Fall back to a coarser geography deliberately, storing the level you fell back to.
- Route to human review.
The option that is never acceptable is silently keeping everything at whatever precision arrived.
Code examples
Example 1 โ a provider-agnostic interface
from dataclasses import dataclass
from typing import Protocol
@dataclass
class Hit:
lat: float
lon: float
precision: str # house | road | postcode | city | region | country
score: float | None
label: str
provider: str
class Geocoder(Protocol):
name: str
def geocode(self, query: str, country: str | None = None) -> Hit | None: ...
class Nominatim:
name = "nominatim"
PRECISION = {"house": "house", "building": "house", "office": "house",
"amenity": "house", "road": "road", "postcode": "postcode",
"suburb": "city", "city": "city", "town": "city",
"state": "region", "country": "country"}
def __init__(self, base="https://nominatim.openstreetmap.org", ua="my-app/1.0"):
import requests
self.base, self.session = base, requests.Session()
self.session.headers["User-Agent"] = ua
def geocode(self, query, country=None):
params = {"q": query, "format": "jsonv2", "limit": 1, "addressdetails": 1}
if country:
params["countrycodes"] = country
r = self.session.get(f"{self.base}/search", params=params, timeout=30)
r.raise_for_status()
hits = r.json()
if not hits:
return None
h = hits[0]
return Hit(float(h["lat"]), float(h["lon"]),
self.PRECISION.get(h.get("addresstype"), "unknown"),
float(h.get("importance", 0)), h["display_name"], self.name)
Writing the adapter before choosing the provider is what makes the comparison possible โ and what makes switching later a configuration change rather than a rewrite.
Example 2 โ a bake-off you can run in an afternoon
import math, statistics
def haversine_m(a, b, r=6371008.8):
la1, lo1 = math.radians(a[0]), math.radians(a[1])
la2, lo2 = math.radians(b[0]), math.radians(b[1])
h = (math.sin((la2 - la1) / 2) ** 2
+ math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2)
return 2 * r * math.asin(math.sqrt(h))
def bake_off(sample, geocoders):
"""Coverage by precision level, plus pairwise disagreement."""
results = {g.name: [g.geocode(q) for q in sample] for g in geocoders}
for name, hits in results.items():
levels = {}
for h in hits:
levels[h.precision if h else "no match"] = levels.get(
h.precision if h else "no match", 0) + 1
total = len(hits)
print(f"\n{name}")
for level in ["house", "road", "postcode", "city", "region", "country", "no match"]:
n = levels.get(level, 0)
if n:
print(f" {level:9s} {n:4d} {100 * n / total:5.1f}%")
names = list(results)
if len(names) == 2:
a, b = results[names[0]], results[names[1]]
d = [haversine_m((x.lat, x.lon), (y.lat, y.lon))
for x, y in zip(a, b) if x and y]
d.sort()
print(f"\nboth matched: {len(d)} of {len(sample)}")
print(f" median disagreement {statistics.median(d):8,.0f} m")
print(f" 90th percentile {d[int(.9 * len(d)) - 1]:8,.0f} m")
print(f" over 1 km {sum(x > 1000 for x in d)} pairs")
Example 3 โ the coverage question you can answer offline
import duckdb
def gazetteer_coverage(parquet, countries):
"""How much reference data exists at all for these countries?
A geocoder cannot be better than the reference data underneath it, and
for open geocoders you can inspect that data before you commit.
"""
con = duckdb.connect()
return con.execute(f"""
select country,
count(*) as places,
count(*) filter (where fclass = 'P') as populated_places,
count(*) filter (where population > 0) as with_population
from read_parquet('{parquet}')
where country in ({','.join(f"'{c}'" for c in countries)})
group by 1 order by places desc
""").fetchall()
Reference-data volume is not accuracy, but a country with two orders of magnitude fewer records than its neighbour will geocode two orders of magnitude worse, whichever API is in front of it.
Explanation
Why coverage is not a single number
A geocoder's hit rate is a property of the pair (geocoder, your data). Two files of the same size from the same country can score forty points apart because one is business addresses in a capital city and the other is rural residential.
This is why vendor coverage claims are close to meaningless and why a 200-address sample of your own data settles the question in an afternoon. Stratify the sample: urban and rural, complete and truncated, domestic and foreign. The rural truncated foreign rows are where providers diverge.
Why "free" and "open" are different axes
They are independent, and confusing them causes most licensing accidents:
- Free but closed โ a hosted API with a generous free tier and terms that forbid storage.
- Paid but open data โ a commercial service built on OpenStreetMap, where the coordinates carry ODbL obligations.
- Free and open โ the public Nominatim service, with a strict usage policy: roughly one request per second, a real
User-Agent, and no bulk geocoding. - Neither โ a national register you pay for and may not redistribute.
The public Nominatim endpoint is the one most often misused, because it looks like an API and is actually a demonstration service run on donated hardware. For anything past a few thousand lookups, run your own or use a hosted provider.
Why self-hosting changes the economics rather than removing the cost
Self-hosting turns a per-request cost into a fixed cost: disk, memory, an import that runs for hours, and the discipline to re-import when the source data updates. That trade is excellent above some volume and terrible below it.
It also changes what you can do. A local geocoder can be queried a million times in a loop, joined against in SQL, and re-run on every pipeline execution without a budget conversation โ which usually means the pipeline gets better, not just cheaper.
Why precision reporting is the feature that matters most
Between two providers with similar hit rates, the one that tells you what it matched is worth more than the one that is marginally more accurate. Precision reporting converts an unknown error into a filterable column.
Concretely: a coordinate at the country level sits, in one measured case, 413 km from the address it claims to represent. Nothing in the coordinate pair says so. The addresstype field does.
Edge cases or notes
- Test with your worst rows, not your best. Everyone finds the famous addresses.
- Country codes are a cheap accuracy win. Constraining the search to a country removes most cross-border mismatches.
- Check the update cadence. A geocoder built on a two-year-old extract will not find last year's housing estate.
- Bulk endpoints exist and are usually cheaper per row than the single-address endpoint; check before looping.
- Structured queries beat free text where the API supports them: passing
street,cityandpostalcodeseparately avoids the parsing stage entirely. - Some providers rate-limit by concurrency, not by rate โ four parallel workers can fail where sixteen sequential requests succeed.
- Keep the provider name in the output table. Mixed-provider tables are normal, and a coordinate with no provenance cannot be audited.
- Re-check the licence when the use changes. Internal analysis and a public map are different permissions.
Internal links
- Geocoding explained: from an address string to a coordinate โ what all of them are doing underneath
- How to geocode with Nominatim from Python without being blocked โ the open option, used correctly
- How to build an offline geocoder from open address data โ the self-hosted end of the scale
- Match quality explained: reading a geocoder's confidence score โ comparing scores between providers
- Rooftop, interpolated or centroid: geocoding precision levels โ the ladder used in the bake-off
- How to batch geocode thousands of addresses in Python โ running the chosen provider at volume
- How to cache geocoding results so a rerun costs nothing โ the largest cost saving available
- GIS data sources explained โ licensing in the wider data context
FAQ
Which geocoder is the most accurate?
The question has no answer independent of your addresses. Run a 200-address bake-off on your own sample and score by precision level; the ranking often reverses between countries.
Can I use the public Nominatim service for a batch job?
No. Its usage policy allows roughly one request per second with a genuine User-Agent and explicitly discourages bulk geocoding. For batches, run your own instance or use a provider that sells the capacity.
Am I allowed to store the coordinates I get back?
It depends entirely on the provider's terms. Several major APIs forbid permanent storage or require the results to be displayed on their map. Decide this before choosing, because it is the one constraint you cannot engineer around.
Is self-hosting cheaper?
Above roughly a few hundred thousand lookups a month, usually yes โ and it removes per-request thinking from your pipeline design. Below that, the import, the disk and the maintenance rarely pay for themselves.
How do I compare two geocoders without ground truth?
Measure the distance between their answers on the same sample. Agreement under 50 m means both found the address; disagreement over 1 km means one has fallen back to a locality or matched a different place.
Does a higher confidence score mean a more accurate coordinate?
No. Confidence is about the text match; precision is about the geometry. A confident country-level match is confidently 400 km wrong.