Choosing a Geocoder: Coverage, Licence and Cost Compared
On this page
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
print(shortlist(must_store=True, monthly_volume=250_000, countries=["GB"]))
print(shortlist(must_store=False, monthly_volume=2_000, countries=["GB", "IE", "FR", "DE"]))
['self-hosted Nominatim', 'self-hosted Pelias', 'national address register']
['self-hosted Nominatim', 'self-hosted Pelias', 'commercial API', 'hosted open geocoder']
Licence first, coverage on your sample, disagreement between providers
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.
A provider-agnostic bake-off
The data used on this page
A real sample: sixty food businesses from the Food Standards Agency register for Herefordshire, with the addresses the council holds and the coordinates the FSA publishes — which, unusually for a bake-off, gives the answers something to be checked against. The two providers are both open and OpenStreetMap-based, and both public: Nominatim and Photon, each asked at most once a second. The register is a copy of the API's answer saved on 10 October 2026, and the GeoNames country files are copies saved on 9 October, so both stay the ones measured here.
import json
import os
import time
import pandas as pd
import requests
if not os.path.exists("herefordshire.json"):
# live: requests.get("https://api.ratings.food.gov.uk/Establishments", headers={"x-api-version": "2"},
# params={"localAuthorityId": 383, "pageSize": 5000}, timeout=300) # Herefordshire
# read instead as the API answered on 10 October 2026, so the register stays put
reply = requests.get("https://data.spatialworkflow.io/fhrs-api-383-2026-10-10.json", timeout=300)
reply.raise_for_status()
json.dump(reply.json()["establishments"], open("herefordshire.json", "w"))
est = pd.DataFrame(json.load(open("herefordshire.json")))
has_point = est["geocode"].map(lambda g: bool(g) and g.get("latitude") is not None)
est = est[(est["PostCode"].fillna("").str.len() > 4) & has_point]
sample = est.sample(60, random_state=7)
queries = [", ".join(x for x in (r.AddressLine1, r.AddressLine2, r.AddressLine3, r.AddressLine4, r.PostCode) if x)
for r in sample.itertuples()]
truth = [(float(g["latitude"]), float(g["longitude"])) for g in sample["geocode"]]
print(f"{len(est):,} businesses; sample of {len(queries)}, e.g. {queries[0]!r}")
1,363 businesses; sample of 60, e.g. 'Whitegates Care Home, 25 Hereford Road, Bromyard, HR7 4ES'
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)
The second provider takes a dozen lines once the interface exists — and both clients are wrapped to wait a second between requests, as both services ask:
class Photon:
name = "photon"
PRECISION = {"house": "house", "street": "road", "locality": "city", "district": "city",
"city": "city", "county": "region", "state": "region", "country": "country"}
def __init__(self, base="https://photon.komoot.io", 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):
r = self.session.get(f"{self.base}/api/", params={"q": query, "limit": 1}, timeout=30)
r.raise_for_status()
features = r.json()["features"]
if not features:
return None
props, (lon, lat) = features[0]["properties"], features[0]["geometry"]["coordinates"]
return Hit(lat, lon, self.PRECISION.get(props.get("type"), "unknown"), None,
props.get("name", ""), self.name)
class Polite:
"""At most one request a second, whatever the caller does."""
def __init__(self, geocoder, interval=1.1):
self.inner, self.interval, self.last = geocoder, interval, 0.0
self.name = geocoder.name
def geocode(self, query, country=None):
time.sleep(max(0.0, self.interval - (time.monotonic() - self.last)))
self.last = time.monotonic()
return self.inner.geocode(query, country)
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}")
ladder = ["house", "road", "postcode", "city", "region", "country"]
for level in ladder + sorted(set(levels) - set(ladder) - {"no match"}) + ["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")
return results
UA = "spatialworkflow.io guide (geocoder bake-off)"
if not os.path.exists("bakeoff.json"): # one real run, kept for re-runs
results = bake_off(queries, [Polite(Nominatim(ua=UA)), Polite(Photon(ua=UA))])
json.dump({n: [h.__dict__ if h else None for h in hits] for n, hits in results.items()},
open("bakeoff.json", "w"))
else:
saved = json.load(open("bakeoff.json"))
class Replay: # the saved answers, in order
def __init__(self, name):
self.name, self.hits = name, iter(saved[name])
def geocode(self, query, country=None):
hit = next(self.hits)
return Hit(**hit) if hit else None
results = bake_off(queries, [Replay(name) for name in saved])
print("\nagainst the FSA's own coordinates")
for name, hits in results.items():
errors = sorted(haversine_m((h.lat, h.lon), t) for h, t in zip(hits, truth) if h)
print(f" {name:10} median {statistics.median(errors):7,.0f} m, "
f"within 100 m {sum(e <= 100 for e in errors)} of {len(errors)} answered")
nominatim
house 13 21.7%
road 20 33.3%
unknown 4 6.7%
no match 23 38.3%
photon
house 31 51.7%
road 11 18.3%
city 3 5.0%
unknown 7 11.7%
no match 8 13.3%
both matched: 36 of 60
median disagreement 0 m
90th percentile 178 m
over 1 km 2 pairs
against the FSA's own coordinates
nominatim median 50 m, within 100 m 25 of 37 answered
photon median 47 m, within 100 m 37 of 52 answered
Three things in that run are worth more than the headline. The two geocoders answered different amounts of the file — Photon 52 of 60, Nominatim 37 — which is the coverage question, answered on this file rather than on famous addresses. Where both answered they agreed almost exactly, because both read OpenStreetMap: agreement between two geocoders built on the same data measures their shared reference, not the truth. And the truth column — the coordinates the FSA itself publishes — is what showed that both were typically about 50 m from it. Without a reference like that, the disagreement test in step 3 is only as independent as the data behind the two providers.
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()
The parquet is GeoNames, one country file at a time, with the three columns the query uses:
import io
import zipfile
frames = []
for country in ("GB", "IE", "NL", "SO"):
if not os.path.exists(f"{country}.zip"):
# live: https://download.geonames.org/export/dump/{country}.zip (rebuilt nightly);
# read instead as saved on 9 October 2026, so the counts stay put
reply = requests.get(f"https://data.spatialworkflow.io/geonames-{country}-2026-10-09.zip",
timeout=300)
reply.raise_for_status()
open(f"{country}.zip", "wb").write(reply.content)
with zipfile.ZipFile(f"{country}.zip") as archive:
frames.append(pd.read_csv(archive.open(f"{country}.txt"), sep="\t", header=None,
usecols=[6, 8, 14], names=["fclass", "country", "population"],
keep_default_na=False, dtype={"fclass": str, "country": str}))
pd.concat(frames).to_parquet("gazetteer.parquet")
for row in gazetteer_coverage("gazetteer.parquet", ["GB", "IE", "NL", "SO"]):
print(row)
('GB', 109181, 43712, 6987)
('IE', 29935, 12159, 1119)
('NL', 22953, 7805, 3656)
('SO', 17538, 4512, 113)
Reference-data volume is not accuracy, but it is a ceiling: a geocoder cannot find a place its reference data does not contain, whichever API is in front of it.
Coverage depends on your data
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.
Worst rows, country codes and update cadence
- 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.