Geocoding Explained: From an Address String to a Coordinate

On this page

Problem statement

Five ways of asking OpenStreetMap's geocoder for the same front door returned an office, a road, a postcode, a city and a country: matched features from 55 m to 1,619 km across, landing up to 413,682 m from the door, each as a coordinate pair with seven decimal places. None of them was an error (Example 4):

query                            type        bbox span   distance from the door
10 Downing Street, London, UK    office            55 m            0 m
Downing Street, London, UK       road              98 m           50 m
SW1A 2AA, UK                     postcode       1,978 m            0 m
London, UK                       city          73,966 m          440 m
United Kingdom                   country    1,618,789 m      413,682 m

The last row is the one that ruins analyses. A coordinate 414 km from the address is not an error the geocoder reports; it is the honest answer to a vaguer question than you meant to ask.

Quick answer

Treat every geocode as three values, not one: a coordinate, a precision level, and a match confidence. Reject or flag anything below the precision your analysis needs.

import requests

UA = {"User-Agent": "spatialworkflow.io guide (what-is-geocoding)"}   # name your application

def geocode(query, min_precision=("house", "building", "office", "road")):
    """One address in, one annotated result out โ€” or None."""
    r = requests.get(
        "https://nominatim.openstreetmap.org/search",
        params={"q": query, "format": "jsonv2", "addressdetails": 1, "limit": 1},
        headers=UA, timeout=30,
    )
    r.raise_for_status()
    hits = r.json()
    if not hits:                       # HTTP 200 with an empty list is the "no match" signal
        return None
    top = hits[0]
    return {
        "lat": float(top["lat"]),
        "lon": float(top["lon"]),
        "precision": top.get("addresstype"),
        "accepted": top.get("addresstype") in min_precision,
        "matched_text": top["display_name"],
    }
print(geocode("10 Downing Street, London, UK"))
print(geocode("10 Downng Stret, Londn"))
{'lat': 51.5034878, 'lon': -0.1276965, 'precision': 'office', 'accepted': True, 'matched_text': '10 Downing Street, 10, Downing Street, Westminster, Covent Garden, City of Westminster, Greater London, England, SW1A 2AA, United Kingdom'}
None
The size of the feature a geocoder matched, from a 55 metre building to a 1,619 kilometre country. one address, five questions, five answersthe building55 mthe street98 mthe postcode1,978 mthe city74.0 kmthe country1,619 km01.7M mBounding-box span of the matched feature โ€” the coordinate looks identical in all five spatialworkflow.io
Every one of these is a valid coordinate pair with six decimal places.

What a geocoder does, its reference data, what to keep

1. Understand what the geocoder is actually doing

Every geocoder, commercial or open, runs the same four stages:

  1. Parse the string into components โ€” house number, street, locality, region, postcode, country. This is guesswork on free text, and it is where most of the failure lives.
  2. Normalise each component โ€” case, accents, punctuation, abbreviations (St for both Street and Saint). This is lossy by design: 22.5% of the 5,231,941 populated-place names in GeoNames contain a non-ASCII character, and stripping accents makes two or more distinct names in the same country identical in 51,298 cases (Example 5). A normaliser that folds Mรถnchengladbach into Monchengladbach helps the typist and costs the geocoder a distinction.
  3. Search an index of reference data for candidates that match some of the components.
  4. Rank the candidates and return the best few, usually with a score.

Nothing in that pipeline requires a full match. A geocoder that finds only the country is still successful by its own definition, and it will happily return a country centroid.

2. Know which reference data is underneath

The coordinate is a property of the geocoder's reference database, not of the world: an address register gives a building, an interpolated street range a guess along a segment, a postcode a representative point. Two geocoders 30 m apart is normal; 30 km apart means one fell back a level. Choosing a geocoder compares what each provider holds.

3. Keep what the ranking knew, not just its answer

Store the matched level (addresstype in Nominatim), the score and the raw response with every coordinate; the precision levels and match quality guides cover what each one means.

4. Expect the same text to mean different places

Normalisation merges names, and names were never unique to begin with. Ask for "Springfield, USA" with limit=1 and you get Illinois; ask for three and Massachusetts scores 0.611 against Illinois's 0.613 (Example 4). A single-result geocode hides that the ranking was a coin flip, which is why batch work should request several candidates and keep the gap.

5. Validate against something you already trust

The cheapest and strongest check is a spatial one: you usually know which region each record should fall in. Run on the Food Standards Agency's own published coordinates for Herefordshire (Example 3), it found 3 businesses placed in Wales and 77 sharing a coordinate with more than five others โ€” and it could not see the 418 records with no coordinate at all, which have to be counted separately. The checks in code, for your own files, are in how to validate geocoding results.

The four stages every geocoder runs: parse, normalise, search and rank. a geocoder is a search engine, not a dictionaryparseguess the partsnormalisefold the spellingsearchfind candidatesrankpick oneNothing in the pipeline requires a full match โ€” a country is a successful result spatialworkflow.io
The ranking step always has a top element, which is why "no match" has to be a deliberate choice.

A geocode result worth storing

The data used on this page

Three real sources. Nominatim, OpenStreetMap's geocoder, answers the queries (one a second, as its usage policy asks). GeoNames' allCountries dump supplies every populated place (feature class P) with its name and country for the name statistics. And for the fallback check, the Food Standards Agency's food hygiene ratings API publishes a coordinate for each of Herefordshire's food businesses, which Example 3 tests against the ONS local authority boundaries (December 2024, generalised).

import json
import os
import zipfile

import pandas as pd
import requests


def fetch(url, path, **kw):
    if not os.path.exists(path):
        with requests.get(url, stream=True, timeout=600, **kw) as reply:
            reply.raise_for_status()
            with open(path, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)


fetch("https://download.geonames.org/export/dump/allCountries.zip", "allCountries.zip")
if not os.path.exists("places.parquet"):
    with zipfile.ZipFile("allCountries.zip") as z, z.open("allCountries.txt") as f:
        gn = pd.read_csv(f, sep="\t", header=None, usecols=[1, 6, 8], quoting=3,
                         names=["name", "fclass", "country"], keep_default_na=False)
    gn[gn.fclass == "P"][["name", "country"]].to_parquet("places.parquet")

fetch("https://api.ratings.food.gov.uk/Establishments?localAuthorityId=383&pageSize=5000",
      "herefordshire.json", headers={"x-api-version": "2"})
fetch("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
      "Local_Authority_Districts_December_2024_Boundaries_UK_BUC/FeatureServer/0/query"
      "?where=1%3D1&outFields=LAD24NM&outSR=4326&f=geojson", "lad24.geojson")
print(f"{len(pd.read_parquet('places.parquet')):,} GeoNames populated places; "
      f"{len(json.load(open('herefordshire.json'))['establishments']):,} Herefordshire food businesses")
5,231,941 GeoNames populated places; 1,784 Herefordshire food businesses

Example 1 โ€” a geocode result worth storing

from dataclasses import dataclass, asdict
import json


@dataclass
class Geocode:
    query: str
    lat: float | None
    lon: float | None
    precision: str | None       # house | road | postcode | city | country | None
    score: float | None
    matched_text: str | None
    source: str
    raw: str                    # the untouched response, as JSON text


PRECISION_RANK = {
    "house": 0, "building": 0, "office": 0, "amenity": 0,
    "road": 1, "postcode": 2, "suburb": 3, "city": 4, "state": 5, "country": 6,
}


def rank(g: Geocode) -> int:
    """Lower is better. Unknown precision is treated as the worst."""
    return PRECISION_RANK.get(g.precision, 9)


def usable(g: Geocode, worst_allowed="road") -> bool:
    return g.lat is not None and rank(g) <= PRECISION_RANK[worst_allowed]
for query in ("10 Downing Street, London, UK", "United Kingdom"):
    hit = geocode(query)
    g = Geocode(query, hit["lat"], hit["lon"], hit["precision"], None, hit["matched_text"],
                "nominatim", json.dumps(hit))
    print(f"{query:30} precision {g.precision:8} usable for street-level work: {usable(g)}")
10 Downing Street, London, UK  precision office   usable for street-level work: True
United Kingdom                 precision country  usable for street-level work: False

Storing raw looks wasteful until the first time somebody asks why a shop is in the sea. It is a few hundred bytes per row and it turns an argument into a lookup.

Example 2 โ€” how far apart the precision levels actually are

import math


def haversine_m(a, b, r=6371008.8):
    lat1, lon1 = map(math.radians, a)
    lat2, lon2 = map(math.radians, b)
    h = (math.sin((lat2 - lat1) / 2) ** 2
         + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2)
    return 2 * r * math.asin(math.sqrt(h))


door     = (51.5034878, -0.1276965)     # the address itself
street   = (51.5032088, -0.1271283)     # the road it is on
city     = (51.5074456, -0.1277653)     # "London"
country  = (54.7023545, -3.2765753)     # "United Kingdom"

for label, point in [("street", street), ("city", city), ("country", country)]:
    print(f"{label:8s} {haversine_m(door, point):10,.0f} m")
street           50 m
city            440 m
country     413,077 m

A 440 m error is invisible on a national map and fatal in a catchment analysis. A 413 km error is visible from space and still arrives as a valid-looking coordinate pair.

Example 3 โ€” flag the fallbacks before they reach the map

import geopandas as gpd


def flag_suspicious(points: gpd.GeoDataFrame, regions: gpd.GeoDataFrame,
                    region_col="region"):
    """Mark geocodes that fall outside the region their record claims."""
    joined = gpd.sjoin(
        points.to_crs(regions.crs), regions[[region_col, "geometry"]],
        how="left", predicate="within",
    )
    joined["region_matches"] = joined[f"{region_col}_left"] == joined[f"{region_col}_right"]

    # Anything landing on the same coordinate more than a handful of times is
    # almost always a centroid fallback rather than a real coincidence.
    counts = joined.geometry.apply(lambda g: (round(g.x, 5), round(g.y, 5))).value_counts()
    stacked = set(counts[counts > 5].index)
    joined["stacked_point"] = joined.geometry.apply(
        lambda g: (round(g.x, 5), round(g.y, 5)) in stacked
    )
    return joined

Run on a real published file โ€” the FSA's own coordinates for every Herefordshire food business, each of which the record says is in Herefordshire:

fsa = pd.DataFrame(json.load(open("herefordshire.json"))["establishments"])
lat = pd.to_numeric(fsa.geocode.str["latitude"])
lon = pd.to_numeric(fsa.geocode.str["longitude"])
has = lat.notna() & lon.notna()
points = gpd.GeoDataFrame({"region": ["Herefordshire, County of"] * int(has.sum())},
                          geometry=gpd.points_from_xy(lon[has], lat[has]), crs=4326)
regions = gpd.read_file("lad24.geojson").rename(columns={"LAD24NM": "region"})

checked = flag_suspicious(points, regions)
print(f"{len(fsa):,} records, {int((~has).sum()):,} with no coordinate at all")
print(f"outside Herefordshire: {int((~checked.region_matches).sum())}, "
      f"landing in {checked.loc[~checked.region_matches, 'region_right'].fillna('no authority (sea or off the map)').value_counts().to_dict()}")
xy = checked.geometry.apply(lambda g: (round(g.x, 5), round(g.y, 5)))
print(f"on a stacked coordinate (more than 5 records): {int(checked.stacked_point.sum())}; "
      f"largest stack {xy.value_counts().iloc[0]} records")
1,784 records, 418 with no coordinate at all
outside Herefordshire: 3, landing in {'Powys': 2, 'Monmouthshire': 1}
on a stacked coordinate (more than 5 records): 77; largest stack 9 records

Two checks, both cheap. On the FSA's own published file they found three businesses the record places in Herefordshire whose coordinates land in Wales, and 77 records sharing a coordinate with more than five others โ€” while 418 of the 1,784 records carried no coordinate at all, which no spatial check can see. Between them, a wrong region and a stacked point catch most geocoding disasters; the missing ones have to be counted separately.

Herefordshire food business records from the FSA, through three geocoding checks. the FSA's published coordinates for Herefordshirerecords1,784418 have nonewith a coordinate1,3663 in Walesinside Herefordshire1,36377 stackednot on a stacked point1,286The biggest loss is the one a spatial check cannot see: no coordinate at all spatialworkflow.io
Measured in Example 3 on a real published file.

Example 4 โ€” the five answers at the top of this page

import time

from pyproj import Geod

geod = Geod(ellps="WGS84")
door = None
for query in ("10 Downing Street, London, UK", "Downing Street, London, UK", "SW1A 2AA, UK",
              "London, UK", "United Kingdom"):
    time.sleep(1.1)
    top = requests.get("https://nominatim.openstreetmap.org/search", headers=UA, timeout=30,
                       params={"q": query, "format": "jsonv2", "limit": 1}).json()[0]
    south, north, west, east = map(float, top["boundingbox"])
    span = geod.inv(west, south, east, north)[2]
    point = (float(top["lat"]), float(top["lon"]))
    door = door or point
    print(f"{query:32} {top['addresstype']:10} {span:10,.0f} m {geod.inv(door[1], door[0], point[1], point[0])[2]:10,.0f} m"
          f"   ({point[0]:.7f}, {point[1]:.7f})")

time.sleep(1.1)
top3 = requests.get("https://nominatim.openstreetmap.org/search", headers=UA, timeout=30,
                    params={"q": "Springfield, USA", "format": "jsonv2", "limit": 3}).json()
for hit in top3:
    print(f"  {hit['importance']:.3f}  {hit['display_name'][:70]}")
10 Downing Street, London, UK    office             55 m          0 m   (51.5034878, -0.1276965)
Downing Street, London, UK       road               98 m         50 m   (51.5032088, -0.1271283)
SW1A 2AA, UK                     postcode        1,978 m          0 m   (51.5034878, -0.1276965)
London, UK                       city           73,966 m        440 m   (51.5074456, -0.1277653)
United Kingdom                   country     1,618,789 m    413,682 m   (54.7023545, -3.2765753)
  0.613  Springfield, Sangamon County, Illinois, United States
  0.611  Springfield, Hampden County, Massachusetts, United States
  0.596  Springfield, Greene County, Missouri, United States

Example 5 โ€” how often place names repeat

import unicodedata

places = pd.read_parquet("places.parquet")
per_name = places.groupby("name").country.nunique()
print(f"{len(places):,} populated places, {places.name.nunique():,} distinct names, "
      f"{int((per_name > 1).sum()):,} of them in more than one country")
sa = places[places.name == "San Antonio"]
print(f"San Antonio: {len(sa):,} places in {sa.country.nunique()} countries; "
      f"Springfield in the US: {int(((places.name == 'Springfield') & (places.country == 'US')).sum())}")
print(f"names with a non-ASCII character: {(~places.name.map(str.isascii)).mean():.1%}")

plain = places.drop_duplicates(["country", "name"]).assign(
    plain=lambda d: d.name.map(lambda n: unicodedata.normalize("NFKD", n).encode("ascii", "ignore").decode()))
merged = plain.groupby(["country", "plain"]).name.nunique()
print(f"stripping accents merges distinct names within one country in {int((merged > 1).sum()):,} cases")
5,231,941 populated places, 3,027,905 distinct names, 132,549 of them in more than one country
San Antonio: 2,384 places in 25 countries; Springfield in the US: 68
names with a non-ASCII character: 22.5%
stripping accents merges distinct names within one country in 51,298 cases

Why the same address geocodes differently each year

Why the same address geocodes differently every year

Reference data changes. Streets are renamed, buildings are demolished, postcodes are re-cut, and OpenStreetMap gains an address point where it previously interpolated one. A geocode is a measurement taken on a particular day against a particular database version.

If your results have to be reproducible, cache the responses and store the date and provider version alongside them. Re-geocoding a year later and getting different coordinates is not a bug you can fix; caching is the only defence.

Why forward and reverse geocoding are not inverses

Forward geocoding turns text into a point. Reverse geocoding turns a point into the nearest or containing feature. Round-tripping loses information both ways: geocode an address, reverse geocode the result, and you frequently get a different address string โ€” the one the reference database uses, which may be the neighbouring unit, the building rather than the flat, or a different transliteration of the same street.

A geocode stored as three values: coordinate, precision level and match confidence. store three things, not onecoordinatelat, lonlooks the same however it was foundprecisionwhat was matchedthe filterable fieldconfidencehow sure the text match wasranking, not accuracyraw responseprovider JSONthe only way to re-interpret laterDrop the middle two and no downstream check can tell a rooftop from a country centroid spatialworkflow.io
Two extra columns are the difference between a dataset you can audit and one you must re-geocode.

Missing and stacked coordinates

  • A missing coordinate is invisible to a spatial check. 418 of the FSA's 1,784 Herefordshire records had none; count them before you join.
  • A stacked coordinate is the cheapest fallback detector. Five or more records on one point found 77 suspects in Example 3 without any boundary layer.
  • Normalisation runs in your code too. If you strip accents before sending a query, you merge the same 51,298 within-country name pairs the geocoder would have kept apart.

FAQ

What is geocoding?

Turning a description of a place โ€” an address, a postcode, a place name โ€” into a coordinate, by searching a reference database of known locations and ranking the candidates.

Why does my geocoder return a point in the middle of nowhere?

Almost always a fallback: it could not match the street or the town, so it returned the centroid of the largest thing it could match. Check the precision field in the response; it will say country or state rather than house.

Is geocoding accurate?

It is as accurate as the reference data and as specific as the match. A rooftop match against an address register is within a few metres. A postcode match is within a few hundred metres to a few kilometres. A country match is meaningless as a location.

How many addresses can I geocode for free?

On the public Nominatim service, roughly one per second with a valid User-Agent, and bulk geocoding is explicitly discouraged. For tens of thousands of addresses, run your own instance or use a commercial API with a paid tier.

Should I store the geocoded coordinates?

Yes โ€” and the precision, the score, the provider and the date. Some licences restrict storage, so check the terms; the cache is what makes the pipeline reproducible and cheap.

What is the difference between geocoding and reverse geocoding?

Forward geocoding goes from text to a point. Reverse geocoding goes from a point to the feature that contains or is nearest to it. They are not inverses, and round-tripping an address rarely returns the string you started with.