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:

  1. 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.
  2. Coverage second. Test on a real sample of your addresses, not on famous ones. Every geocoder finds Buckingham Palace.
  3. 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.
  4. 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']
Choosing a geocoder in the order that eliminates options: licence, coverage, volume, precision. decide in this order1licencecan you store the coordinates?2coverageon your own 200 addresses3volumeper request or fixed cost4precision reportingdoes it say what it matched?Licence first: it is the only constraint you cannot engineer around later spatialworkflow.io
Choosing on price and discovering the licence six months later is the expensive order.

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.

Free and open are independent axes: a service can be free and closed, or paid and open. free and open are different questionsclosed dataopen datafree to use!hosted API, no storagepublic Nominatim, 1 req/spaid!commercial, restricted✓hosted OSM, ODbL appliesThe public Nominatim endpoint is the one most often mistaken for an API with capacity spatialworkflow.io
Confusing the two axes is what produces a database you are not allowed to keep.

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.

Two open geocoders on sixty Herefordshire food businesses, checked against the coordinates the FSA publishes. sixty real addresses, two geocodersgeocoderansweredmedian errorwithin 100 mNominatim37 of 6050 m25 of 37Photon52 of 6047 m37 of 52both read OpenStreetMap: where both answered, they agreed to a median 0 mCoverage differed far more than accuracy spatialworkflow.io
Measured in Example 2. Agreement between two geocoders on the same data is not evidence of accuracy.

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.

Reading the distance between two geocoders: under 50 metres, up to a kilometre, or beyond. no ground truth? measure the disagreementunder 50 mreference-data detailboth found the address50 m – 1 kmstreet or postcode centroidone is interpolatingover 1 kmlocality or wrong placeone has fallen backYou do not need to know which one is right to learn where the file is weak spatialworkflow.io
A pair that agrees within 50 m on 90% of a sample is telling you the sample is easy.

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, city and postalcode separately 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.

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.