How to Validate Geocoding Results Before You Trust Them
On this page
Problem statement
A geocoded file arrives with 100% coverage: every row has a latitude and a longitude. That is the least informative possible summary, because a geocoder produces a coordinate for almost any input, including inputs it did not understand.
The failures that reach production are the ones that look normal:
- hundreds of rows sharing one coordinate โ a country centroid, about 414 km from a London address in the United Kingdom's case
- rows in the right country and the wrong region, because the town name is duplicated
- latitude and longitude swapped, which for European data lands the points in the Middle East or the ocean
- coordinates rounded to two decimal places somewhere upstream, quantising every address to a 1 km grid
Validation is the step that converts these from invisible to counted. None of the checks are expensive; the reason they are skipped is that the file looks finished.
Quick answer
Five checks, in the order they catch the most for the least effort:
def validate(gdf, regions=None, region_col=None):
"""Cheap checks first; each one returns a count you can act on."""
report = {}
# 1. did it match at all, and at what precision
report["no_match"] = int(gdf["lat"].isna().sum())
report["too_coarse"] = int(gdf["precision"].isin(
["locality", "city", "region", "state", "country"]).sum())
# 2. is the coordinate even possible
report["out_of_range"] = int((~gdf["lat"].between(-90, 90)
| ~gdf["lon"].between(-180, 180)).sum())
report["null_island"] = int(((gdf["lat"].abs() < 1e-6)
& (gdf["lon"].abs() < 1e-6)).sum())
# 3. are many rows sharing one point (the fallback fingerprint)
stacked = gdf.groupby([gdf["lat"].round(5), gdf["lon"].round(5)]).size()
report["stacked_points"] = int(stacked[stacked > 5].sum())
# 4. is it inside the region the record claims
if regions is not None:
joined = gdf.sjoin(regions[[region_col, "geometry"]], how="left", predicate="within")
report["wrong_region"] = int((joined[f"{region_col}_left"]
!= joined[f"{region_col}_right"]).sum())
# 5. is the precision plausible for the decimal places present
report["over_precise_text"] = int((gdf["lat"].astype(str).str.split(".").str[-1]
.str.len() > 8).sum())
return report
What you get โ from 2. Look for stacked coordinates
lat lon
51.51490 -0.12362 202
53.47159 -2.21739 52
51.51459 -0.15281 48
51.54234 -0.14774 43
51.52045 -0.10974 38
53.47155 -2.21887 36
53.48315 -2.24138 33
51.53143 -0.12612 32
53.47466 -2.21934 31
51.51751 -0.17741 31
dtype: int64
Step-by-step solution
1. Check the range and the impossible values first
Latitude outside ยฑ90 is a swapped pair or a corrupted parse. Exactly (0, 0) โ Null Island, in the Gulf of Guinea โ is the signature of a missing value that was coerced to zero somewhere upstream.
Both checks cost microseconds and both catch failures that no later check can distinguish from real data.
2. Look for stacked coordinates
When a geocoder falls back, many different addresses receive the same coordinate. Counting exact duplicates at five decimal places is therefore the most efficient single test for fallbacks:
stacked = gdf.groupby([gdf.lat.round(5), gdf.lon.round(5)]).size()
print(stacked[stacked > 5].sort_values(ascending=False).head(10))
lat lon
51.51490 -0.12362 202
53.47159 -2.21739 52
51.51459 -0.15281 48
51.54234 -0.14774 43
51.52045 -0.10974 38
53.47155 -2.21887 36
53.48315 -2.24138 33
51.53143 -0.12612 32
53.47466 -2.21934 31
51.51751 -0.17741 31
dtype: int64
On the food hygiene register used here, the top of that list is not a fallback at all: 202 businesses registered at one office address in Covent Garden, then dark kitchens and department-store concessions. A stack is a question; reverse geocoding the point and counting the distinct addresses in it answers it, and the centroid guide does both.
3. Join to a boundary you already trust
This is the strongest check available, because it is independent of the geocoder's opinion of itself. Most address files already carry a region, county, state or country column; the geocoded point should fall inside that region's polygon.
Disagreements are not all errors โ boundary data is generalised, and a point genuinely on a boundary can fall on either side โ but a disagreement rate above a percent or two is a finding, and the specific rows are a work queue.
4. Test for a swapped pair explicitly
Swapped coordinates are common enough to deserve their own check rather than being left to the boundary join. The test is simple: does swapping the pair move the point into the expected region?
def looks_swapped(row, region_geom):
from shapely.geometry import Point
here = Point(row.lon, row.lat)
there = Point(row.lat, row.lon)
return (not region_geom.contains(here)) and region_geom.contains(there)
manchester = regions.set_index("LAD24NM").geometry["Manchester"]
sample = gdf[(gdf.district == "Manchester") & gdf.geometry.notna()].head(3)
swapped = sample.assign(lat=sample.lon, lon=sample.lat) # what a column mix-up does
print([looks_swapped(row, manchester) for row in swapped.itertuples()], swapped[["lat", "lon"]].round(3).values.tolist())
[True, True, True] [[-2.245, 53.454], [-2.245, 53.48], [-2.243, 53.461]]
For data in the UK or Ireland, a swap puts the point near the equator east of Africa, in the Indian Ocean; for most of Europe it lands in the Horn of Africa or Arabia. Visible on a map, and easy to miss in a table of tens of thousands of rows.
5. Check the distribution against something known
Two aggregate checks catch subtler damage:
- Compare the geocoded count per region with an expected distribution โ population, previous months, the source system's own counts. A region with three times its usual share has absorbed a fallback.
- Check the spatial spread within each region. Addresses within a town should scatter; if the standard distance of a town's points is near zero, they are all on one centroid.
6. Sample and look
Automated checks find the failures you predicted. Plot two hundred random points on a basemap and look at them โ five minutes finds the ones you did not.
Code examples
The data used on this page
The Food Standards Agency's food hygiene rating open data for Camden, Westminster, Manchester and Leeds โ a geocoded file that somebody else produced, with the council each premises is registered with as its own claim of region โ and the four districts' boundaries from the ONS Open Geography Portal (May 2024, full resolution, clipped to the coastline). Reverse geocoding goes to the public Nominatim server; USER_AGENT identifies the application, as its usage policy requires โ replace it with your own before you run anything. Responses are cached in nominatim_cache.json.
import json
import os
import time
import geopandas as gpd
import pandas as pd
import requests
USER_AGENT = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
AREAS = {"Camden": "506", "Westminster": "533", "Manchester": "415", "Leeds": "413"} # FSA authority codes
if not os.path.exists("premises.csv"):
rows = []
for area, code in AREAS.items():
r = requests.get(f"https://ratings.food.gov.uk/api/open-data-files/FHRS{code}en-GB.json", timeout=300)
r.raise_for_status()
found = r.json()["FHRSEstablishment"]["EstablishmentCollection"]
for e in (found if isinstance(found, list) else found["EstablishmentDetail"]):
lines = [(e.get(f"AddressLine{i}") or "").strip() for i in (1, 2, 3, 4)]
geo = e.get("Geocode") or {}
rows.append({"area": area, "name": e.get("BusinessName"), "postcode": e.get("PostCode") or "",
"street_line": next((l.upper() for l in lines if l[:1].isdigit()), ""),
"address": ", ".join(l for l in lines if l),
"lat": geo.get("Latitude"), "lon": geo.get("Longitude")})
pd.DataFrame(rows).to_csv("premises.csv", index=False)
premises = pd.read_csv("premises.csv", dtype={"postcode": str, "street_line": str}).fillna({"street_line": ""})
premises = premises.rename(columns={"area": "district"}) # gdf.area is the geometry's area
gdf = gpd.GeoDataFrame(premises, geometry=gpd.points_from_xy(premises.lon, premises.lat), crs=4326)
gdf.loc[premises.lat.isna(), "geometry"] = None # a missing point, not POINT (NaN NaN)
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_May_2024_Boundaries_UK_BFC/FeatureServer/0/query")
if not os.path.exists("districts.geojson"):
r = requests.get(ONS, timeout=300, params={"where": "LAD24NM IN ('Camden','Westminster','Manchester','Leeds')",
"outFields": "LAD24CD,LAD24NM", "outSR": 4326, "f": "geojson"})
r.raise_for_status()
open("districts.geojson", "w").write(r.text)
regions = gpd.read_file("districts.geojson")
print(f"{len(premises):,} premises, {gdf.geometry.notna().sum():,} with a coordinate; {len(regions)} district boundaries")
class Nominatim:
"""The polite client from the Nominatim guide, with search, reverse and a JSON cache."""
def __init__(self, user_agent, cache="nominatim_cache.json", min_interval=1.1):
self.session, self.min_interval, self._last, self.path = requests.Session(), min_interval, 0.0, cache
self.session.headers["User-Agent"] = user_agent
self.cache = json.load(open(cache)) if os.path.exists(cache) else {}
def _get(self, endpoint, params):
key = json.dumps([endpoint, params], sort_keys=True)
if key not in self.cache:
time.sleep(max(0.0, self.min_interval - (time.monotonic() - self._last)))
self._last = time.monotonic()
r = self.session.get(f"https://nominatim.openstreetmap.org/{endpoint}", params=params, timeout=30)
r.raise_for_status()
self.cache[key] = r.json()
json.dump(self.cache, open(self.path, "w"))
return self.cache[key]
def search(self, query, **kw):
return self._get("search", {"q": query, "format": "jsonv2", "limit": 1, **kw})
def reverse(self, lat, lon, zoom=18):
found = self._get("reverse", {"lat": lat, "lon": lon, "zoom": zoom, "format": "jsonv2"})
return None if "error" in found else found
nom = Nominatim(USER_AGENT)
23,856 premises, 19,833 with a coordinate; 4 district boundaries
Example 1 โ the full validation report
import geopandas as gpd
import pandas as pd
COARSE = {"locality", "city", "town", "suburb", "region", "state", "county", "country"}
def validation_report(gdf: gpd.GeoDataFrame, regions: gpd.GeoDataFrame,
claim_col: str, region_col: str) -> pd.DataFrame:
checks = []
n = len(gdf)
def add(name, mask, severity):
checks.append({"check": name, "rows": int(mask.sum()),
"pct": round(100 * mask.sum() / n, 2), "severity": severity})
add("missing coordinate", gdf.geometry.isna() | gdf.geometry.is_empty, "fatal")
valid = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty]
lat, lon = valid.geometry.y, valid.geometry.x
add("latitude out of range", ~lat.between(-90, 90), "fatal")
add("null island (0,0)", (lat.abs() < 1e-6) & (lon.abs() < 1e-6), "fatal")
if "precision" in gdf: # this register stores none
add("coarse precision", gdf["precision"].isin(COARSE), "warning")
coords = pd.Series(list(zip(lat.round(5), lon.round(5))), index=valid.index)
counts = coords.value_counts()
add("stacked on a shared point", coords.map(counts) > 5, "warning")
joined = gpd.sjoin(valid.to_crs(regions.crs), regions[[region_col, "geometry"]],
how="left", predicate="within")
outside = joined[region_col].isna()
mismatch = (~outside) & (joined[region_col] != joined[claim_col])
add("outside every region", outside, "warning")
add("in the wrong region", mismatch, "error")
report = pd.DataFrame(checks).sort_values(
["severity", "rows"], ascending=[True, False])
return report.reset_index(drop=True)
report = validation_report(gdf, regions, claim_col="district", region_col="LAD24NM")
print(report.to_string())
check rows pct severity
0 in the wrong region 7 0.03 error
1 missing coordinate 4023 16.86 fatal
2 latitude out of range 0 0.00 fatal
3 null island (0,0) 0 0.00 fatal
4 stacked on a shared point 2083 8.73 warning
5 outside every region 42 0.18 warning
The register leaves 4,023 premises without a coordinate โ mobile traders and home caterers with no public address โ rather than inventing one, which is the honest choice and the reason the missing check comes first. The seven wrong-district rows all sit on the CamdenโWestminster border, around Covent Garden and New Oxford Street, and most of the 42 outside every district are at King's Cross, where Camden meets Islington: boundary cases rather than a systematic fault.
Example 2 โ naming the stacked points
def explain_stacks(gdf, client, min_rows=5, top=10):
"""Reverse geocode the shared coordinates so the fallback names itself."""
coords = gdf.groupby([gdf.geometry.y.round(5), gdf.geometry.x.round(5)]).size()
stacks = coords[coords >= min_rows].sort_values(ascending=False).head(top)
for (lat, lon), n in stacks.items():
place = client.reverse(lat, lon)
label = place.get("display_name", "?") if place else "?"
kind = place.get("addresstype", "?") if place else "?"
print(f"{n:6,} rows at ({lat:.5f}, {lon:.5f}) [{kind}] {label[:70]}")
explain_stacks(gdf[gdf.geometry.notna()], nom, top=6)
202 rows at (51.51490, -0.12362) [amenity] NewWoman Health, 71-75, Shelton Street, St Clement Danes, Covent Garde
52 rows at (53.47159, -2.21739) [amenity] Ardwick Kitchens Communal Kitchen, 42, Higher Ardwick, Brunswick, Ardw
48 rows at (51.51459, -0.15281) [amenity] Alto by San Carlo, Oxford Street, East Marylebone, Mayfair, City of We
43 rows at (51.54234, -0.14774) [tourism] Amy Winehouse Statue, 407, Chalk Farm Road, Primrose Hill, Chalk Farm,
38 rows at (51.52045, -0.10974) [amenity] Gebeta Ethiopian Kitchen, Leather Lane, Hatton Garden, Holborn, London
36 rows at (53.47155, -2.21887) [amenity] Higher Ardwick, Brunswick, Ardwick, Manchester, Greater Manchester, En
One line per stack, and each line explains itself: a registered-office address, a communal kitchen, a department store, a market. This is the report to put in front of whoever owns the address data.
Example 3 โ a hard gate for a pipeline
class ValidationFailure(Exception):
pass
def gate(report: pd.DataFrame, thresholds=None):
"""Stop the pipeline on the checks that make the output meaningless."""
thresholds = thresholds or {
"latitude out of range": 0, # any is fatal
"null island (0,0)": 0,
"missing coordinate": 5.0, # percent: a gate without this passes a file of blanks
"in the wrong region": 1.0, # percent
"coarse precision": 10.0,
}
failures = []
for _, row in report.iterrows():
limit = thresholds.get(row["check"])
if limit is None:
continue
actual = row["rows"] if limit == 0 else row["pct"]
if actual > limit:
failures.append(f"{row['check']}: {actual} > {limit}")
if failures:
raise ValidationFailure("geocoding validation failed:\n " + "\n ".join(failures))
return True
try:
gate(report)
except ValidationFailure as failure:
print(failure)
geocoding validation failed:
missing coordinate: 16.86 > 5.0
Thresholds are a policy decision and belong in config. The important part is that they exist: a pipeline with no gate publishes whatever it produced.
Explanation
Why the boundary join is the strongest check
Every other check tests the coordinate against itself or against the geocoder's own metadata. The boundary join tests it against an independent claim that came with the record โ the region the customer said they were in.
Two independent sources agreeing is evidence. This is why the check catches things nothing else does: a locality centroid in the right country passes the range check, passes the stacking check if the locality is unique, and fails the boundary check the moment the record claims a different county.
Why coarse precision is a warning and the wrong region is an error
A coarse match is a known limitation. It is labelled, it is countable, and some analyses can live with it โ a national choropleth built on locality centroids is fine.
A point in the wrong region is a different category: the geocoder matched confidently against the wrong place, so there is no label to filter on. Nothing downstream can detect it, which is why it earns a higher severity and a tighter threshold.
Why stacking beats precision fields for detecting fallbacks
Precision fields are reliable when they exist. They do not always exist: some providers omit them, some pipelines drop them, and files arrive from third parties without them.
Stacking needs nothing but the coordinates. Several hundred distinct addresses cannot legitimately share a point to five decimal places โ about one metre โ so the count is close to a pure fallback detector, and it works on any file no matter where it came from.
Why validation belongs in the pipeline rather than in a notebook
The checks are cheap and the failure modes are recurrent. Running them once by hand tells you about today's file; running them in the pipeline with thresholds tells you about every future file, including the one that arrives while you are on holiday.
The gate is what makes it real. A report that nobody reads is a slower version of no validation at all.
Edge cases or notes
- Islands and exclaves legitimately fall outside a simplified national boundary. Use a small negative tolerance, or the coastal buffer, before calling them errors.
- Boundary data is generalised. A point 20 m from a boundary can fall on either side; do not chase those rows.
- Blocks of flats legitimately stack. Set the stacking threshold above the largest plausible building.
- Marine and offshore addresses exist โ rigs, ports, ferry terminals. Do not hard-fail on "in the sea".
- Rounded coordinates quantise addresses onto a grid: two decimal places is about 1.1 km at the equator.
- Validate before the CRS change, not after โ a reprojection error looks identical to a geocoding error once the numbers change.
- Keep the failing rows, not just the counts. A report without examples cannot be acted on.
- Re-validate after every re-geocode. New provider, new failure modes.
Internal links
- Rooftop, interpolated or centroid: geocoding precision levels โ the field the precision check reads
- Fixing geocodes that all land in the country centroid โ the biggest stack, diagnosed
- Fixing a geocoder that matches the wrong town โ the wrong-region rows
- My points plot in the ocean: latitude and longitude swapped โ the swap check in detail
- How to validate a GeoDataFrame schema โ the same gate idea for columns
- How to validate pipeline inputs and outputs โ where the gate belongs
- How to batch geocode thousands of addresses in Python โ the step that produced this file
- Spatial data quality dimensions โ positional accuracy in context
FAQ
What is the single most useful geocoding check?
Joining the points to a boundary layer and comparing with the region the record already claims. It is the only check that uses information independent of the geocoder.
How do I detect country-centroid fallbacks without a precision field?
Count coordinates shared by many rows. Hundreds of distinct addresses on one point to five decimal places is a fallback; reverse geocoding that point usually names the country or city responsible.
How many rows in the wrong region are acceptable?
Under about 1%, allowing for generalised boundaries and genuinely borderline addresses. Above that, something systematic is wrong โ usually an ambiguous town name or a missing country filter.
Should validation stop the pipeline?
Yes, on the fatal checks: impossible coordinates, Null Island, and a wrong-region rate above your threshold. Warnings should be reported and counted rather than blocking.
How do I find swapped latitude and longitude?
Test whether swapping the pair moves the point into the expected region. For European data an unswapped point is in Europe and a swapped one is in Somalia or Kazakhstan.
Do I need ground truth to validate?
No. Range checks, stacking, boundary joins and distribution comparisons all work without it, and between them they catch the great majority of real geocoding failures.