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
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:
- 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.
- Normalise each component โ case, accents, punctuation, abbreviations (
Stfor 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 foldsMรถnchengladbachintoMonchengladbachhelps the typist and costs the geocoder a distinction. - Search an index of reference data for candidates that match some of the components.
- 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.
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.
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.
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.
Internal links
- How to geocode addresses in Python โ the working code for a single lookup
- The address data model: why an address is not a string โ what you are parsing before you send it
- Match quality explained: reading a geocoder's confidence score โ what the score actually measures
- Rooftop, interpolated or centroid: geocoding precision levels โ the ladder in detail
- Choosing a geocoder: coverage, licence and cost compared โ picking one for a project
- How to validate geocoding results before you trust them โ the checks in code
- Fixing geocodes that all land in the country centroid โ when the fallback has already happened
- GIS data sources explained โ where the reference data comes from
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.