Fixing Geocodes That All Land in the Country Centroid
On this page
Problem statement
The map has one enormous dot. Zoom in and it is hundreds of customers stacked on a single coordinate somewhere in the middle of the country. Ask the public Nominatim geocoder for United Kingdom and it returns (54.70235, โ3.27658), a hillside in Cumbria.
Nothing failed. Every row stacked there has a valid coordinate with six decimal places, and it is about 414 km from a London address it might claim to represent โ the measured distance from 10 Downing Street to that point.
This is the fallback failure: the geocoder could not match the address, matched the largest thing it could, and returned that. The coordinate is correct for the question it answered and useless for the question you asked.
Quick answer
Find the stacks, name them, and then decide what to do with the rows behind them:
def find_stacks(gdf, min_rows=5, decimals=5):
"""Distinct addresses cannot share a point to 1 m. Stacks are fallbacks."""
coords = list(zip(gdf.geometry.y.round(decimals), gdf.geometry.x.round(decimals)))
counts = pd.Series(coords).value_counts()
stacked = counts[counts >= min_rows]
print(f"{stacked.sum():,} rows sit on {len(stacked):,} shared coordinates")
for (lat, lon), n in stacked.head(10).items():
print(f" {n:6,} rows at ({lat}, {lon})")
return stacked
stacked = find_stacks(gdf)
2,598 rows sit on 284 shared coordinates
202 rows at (51.5149, -0.12362)
52 rows at (53.47159, -2.21739)
48 rows at (51.51459, -0.15281)
43 rows at (51.54234, -0.14774)
38 rows at (51.52045, -0.10974)
36 rows at (53.47155, -2.21887)
33 rows at (53.48315, -2.24138)
32 rows at (51.53143, -0.12612)
31 rows at (51.52867, -0.13327)
31 rows at (51.51751, -0.17741)
Then look at what each stack is. On the food hygiene register for four councils, the largest stack is 202 businesses at 71โ75 Shelton Street in Covent Garden, a well-known registered-office address; others are dark kitchens sharing one Manchester unit and the concessions inside a department store. A stack is a question, not a verdict: it is a fallback when it puts many different addresses on one point, and a shared building when it is one address many times.
Confirm with precision, name the stacks, guard against them
1. Confirm the diagnosis with the precision field
If the precision level was stored, this takes one line:
print(df["precision"].value_counts())
If it was not stored โ and the register used on this page stores none โ the stack count is the diagnosis, read together with how many different addresses each stack holds. Many distinct addresses cannot legitimately share a coordinate to five decimal places, which is about one metre; many businesses at one address can.
2. Reverse geocode the stack points to name the cause
Each stacked coordinate has a name, and the name tells you how far the geocoder fell:
- A country name โ the address failed entirely; usually only the country was parseable.
- A city or region name โ the street failed; the town matched.
- A postcode district โ the house number failed.
- A depot or head office address โ not a geocoder fallback at all, but a default value from the source system.
That last one is common and is diagnosed the same way. It is fixed in the source system, not in the geocoder.
3. Find what the failing rows have in common
Fallback rows cluster. Group them and the cause usually appears in one query:
suspects = df[df.precision.isin(["country", "region", "locality"])]
print(suspects.groupby("source_system").size())
print(suspects["address"].str.split().str.len().describe())
print(suspects["postcode"].isna().mean())
Typical findings, in rough order of frequency:
- the address column is empty or holds a placeholder (
n/a,-,TBC) - the country is the only populated field
- the address is in a column the geocoder never saw, because of a mapping error
- one source system exports addresses in a different column order
- the rows are foreign addresses sent without a country code
4. Fix the input where it is fixable, and re-geocode only those rows
Most fallback clusters are input problems, and input problems have bulk fixes: correct the column mapping, add the missing country, filter the placeholders, split the two addresses crammed into one cell.
Re-geocode only the affected rows. The cache makes that cheap and the rest of the file untouched.
5. Label what remains, and exclude it from analysis
Some rows genuinely have no usable address. Once they are labelled, they can be handled honestly:
df["geocode_usable"] = df["precision"].map(PRECISION_RANK).fillna(9) <= PRECISION_RANK["street"]
analysis_input = df[df.geocode_usable]
print(f"analysing {len(analysis_input):,} of {len(df):,} rows "
f"({100 * len(analysis_input) / len(df):.1f}%)")
Printing the coverage line every time is what stops the exclusion becoming invisible.
6. Stop it happening again
Two changes prevent the next occurrence:
- Store the precision level on every geocode. Without it, this diagnosis needs archaeology.
- Reject below a floor at write time. A geocoder wrapper that refuses to return a country-level match โ returning
Noneand a reason instead โ makes the failure loud at the point it happens.
A full stack report
The data used on this page
The Food Standards Agency's food hygiene rating open data for Camden, Westminster, Manchester and Leeds, which carries a coordinate for most premises โ a real geocoded file that somebody else produced. Reverse geocoding and the fallback demonstrations go 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": ""})
located = premises.dropna(subset=["lat", "lon"])
gdf = gpd.GeoDataFrame(located, geometry=gpd.points_from_xy(located.lon, located.lat), crs=4326)
print(f"{len(premises):,} premises, {len(gdf):,} with a coordinate from the register")
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 from the register
Example 1 โ a full stack report
import geopandas as gpd
import pandas as pd
def stack_report(gdf: gpd.GeoDataFrame, client=None, min_rows=5, decimals=5, top=15):
coords = pd.Series(
list(zip(gdf.geometry.y.round(decimals), gdf.geometry.x.round(decimals))),
index=gdf.index)
counts = coords.value_counts()
stacked = counts[counts >= min_rows]
rows_affected = int(stacked.sum())
print(f"{rows_affected:,} of {len(gdf):,} rows "
f"({100 * rows_affected / len(gdf):.1f}%) sit on {len(stacked):,} shared points\n")
for (lat, lon), n in stacked.head(top).items():
label, kind = "", ""
if client is not None:
place = client.reverse(lat, lon, zoom=18)
if place:
label = place.get("display_name", "")[:60]
kind = place.get("addresstype", "")
print(f"{n:7,} rows ({lat:9.5f}, {lon:10.5f}) {kind:10} {label}")
members = coords[coords.isin(stacked.index)]
return gdf.loc[members.index].assign(stack_size=members.map(counts))
report = stack_report(gdf, client=nom, top=8)
by_point = report.assign(point=list(zip(report.lat.round(5), report.lon.round(5)))).groupby("point")
addresses = by_point.street_line.agg(lambda s: s[s != ""].nunique())
print(f"\n{(addresses >= 5).sum()} stacks put five or more different street addresses on one point "
f"({by_point.size()[addresses >= 5].sum():,} rows): area-level geocodes, not buildings")
print(f"{(addresses <= 1).sum()} stacks are a single address shared by many businesses")
2,598 of 19,833 rows (13.1%) sit on 284 shared points
202 rows ( 51.51490, -0.12362) amenity NewWoman Health, 71-75, Shelton Street, St Clement Danes, Co
52 rows ( 53.47159, -2.21739) amenity Ardwick Kitchens Communal Kitchen, 42, Higher Ardwick, Bruns
48 rows ( 51.51459, -0.15281) amenity Alto by San Carlo, Oxford Street, East Marylebone, Mayfair,
43 rows ( 51.54234, -0.14774) tourism Amy Winehouse Statue, 407, Chalk Farm Road, Primrose Hill, C
38 rows ( 51.52045, -0.10974) amenity Gebeta Ethiopian Kitchen, Leather Lane, Hatton Garden, Holbo
36 rows ( 53.47155, -2.21887) amenity Higher Ardwick, Brunswick, Ardwick, Manchester, Greater Manc
33 rows ( 53.48315, -2.24138) amenity Doner Shack, Corporation Street, Central Retail District, Ci
32 rows ( 51.53143, -0.12612) highway Euston Road, King's Cross, London Borough of Camden, Greater
115 stacks put five or more different street addresses on one point (869 rows): area-level geocodes, not buildings
105 stacks are a single address shared by many businesses
Reverse geocoding at street zoom names each point, and the address count says which kind of stack it is. The 115 stacks with five or more different street addresses are the register's own fallbacks: premises geocoded to their postcode rather than their door, so every number in the postcode shares a point. The 105 single-address stacks are real โ one building, many businesses.
Example 2 โ a guard that refuses to return a fallback
LEVELS = {"house": "rooftop", "building": "rooftop", "amenity": "rooftop", "office": "rooftop", "shop": "rooftop",
"road": "street", "postcode": "postcode", "suburb": "locality", "city_district": "locality",
"borough": "locality", "city": "city",
"county": "region", "state": "region", "country": "country"}
PRECISION_RANK = {"rooftop": 0, "street": 1, "postcode": 2, "locality": 3, "city": 4, "region": 5, "country": 6}
class PrecisionFloor:
"""Wraps any geocoder so a coarse match is a labelled failure, not a coordinate."""
def __init__(self, geocoder, floor="street"):
self.geocoder, self.floor = geocoder, floor
def geocode(self, query, **kw):
hits = self.geocoder.search(query, **kw)
if not hits:
return {"status": "no_match", "lat": None, "lon": None}
level = LEVELS.get(hits[0].get("addresstype"), "unknown")
if PRECISION_RANK.get(level, 9) > PRECISION_RANK[self.floor]:
return {"status": f"below_floor:{level}", "lat": None, "lon": None,
"would_have_returned": (float(hits[0]["lat"]), float(hits[0]["lon"])),
"matched_text": hits[0].get("display_name")}
return {"status": "ok", "lat": float(hits[0]["lat"]),
"lon": float(hits[0]["lon"]), "precision": level}
from pyproj import Geod
guarded = PrecisionFloor(nom)
for query in ["103 Parkway, London NW1 7PP", "Camden, London", "United Kingdom"]:
print(f"{query:30} {guarded.geocode(query)}")
uk = guarded.geocode("United Kingdom")["would_have_returned"]
downing = nom.search("10 Downing Street, London")[0]
metres = Geod(ellps="WGS84").inv(uk[1], uk[0], float(downing["lon"]), float(downing["lat"]))[2]
for coarser in ["Downing Street, London", "SW1A 2AA", "London, UK", "United Kingdom"]:
hit = nom.search(coarser)[0]
metres = Geod(ellps="WGS84").inv(float(hit["lon"]), float(hit["lat"]),
float(downing["lon"]), float(downing["lat"]))[2]
print(f"{coarser!r:26} lands {metres:>9,.0f} m from 10 Downing Street")
103 Parkway, London NW1 7PP {'status': 'ok', 'lat': 51.5368769, 'lon': -0.1457945, 'precision': 'rooftop'}
Camden, London {'status': 'below_floor:locality', 'lat': None, 'lon': None, 'would_have_returned': (51.5427966, -0.1624803), 'matched_text': 'London Borough of Camden, Greater London, England, United Kingdom'}
United Kingdom {'status': 'below_floor:country', 'lat': None, 'lon': None, 'would_have_returned': (54.7023545, -3.2765753), 'matched_text': 'United Kingdom'}
'Downing Street, London' lands 50 m from 10 Downing Street
'SW1A 2AA' lands 0 m from 10 Downing Street
'London, UK' lands 440 m from 10 Downing Street
'United Kingdom' lands 413,682 m from 10 Downing Street
would_have_returned keeps the fallback visible without letting it into the coordinate columns. It is exactly the information you want when somebody asks why a row has no location.
Example 3 โ deciding what to do with the excluded rows
def fallback_policy(df, policy="exclude", area_layer=None, area_col="code"):
"""Three honest options, and the fourth is not one of them."""
coarse = df["precision"].isin(["locality", "city", "region", "country"])
if policy == "exclude":
df.loc[coarse, ["lat", "lon", "geometry"]] = None
print(f"excluded {coarse.sum():,} coarse geocodes")
elif policy == "aggregate":
# keep them, but only ever use them at the level they were matched at
df.loc[coarse, "usable_for"] = df.loc[coarse, "precision"]
df.loc[~coarse, "usable_for"] = "point"
print(f"{coarse.sum():,} rows usable only at area level")
elif policy == "review":
queue = df[coarse].copy()
queue.to_csv("geocode_review_queue.csv", index=False)
print(f"wrote {len(queue):,} rows for manual review")
else:
raise ValueError("keeping coarse geocodes unlabelled is not a policy")
return df
Why the geocoder falls back instead of failing
Why the geocoder does this rather than failing
A geocoder ranks candidates and returns the best. "Best" is defined over whatever it could match, so a query it cannot resolve to a street still resolves to a town, and one it cannot resolve to a town still resolves to a country.
This is correct behaviour for an interactive search box, where a human sees United Kingdom in the result and tries again. It is dangerous behaviour in a batch, where only the coordinates are kept.
Why the error is so large and so specific
A country centroid is a single point standing for an entire country, so the error for any given address is roughly the distance from that address to the country's centre. Measured in Example 2 for 10 Downing Street against the coordinate Nominatim returns for "United Kingdom": 413,682 m.
The error is also systematic, not random. Every fallback row gets the same coordinate, so aggregating them does not average the error away โ it concentrates it. A choropleth built on such a file shows a spike in whichever region contains the centroid.
Why stacked coordinates are the reliable fingerprint
Distinct businesses can share a coordinate legitimately โ 202 of them at one registered-office address on this register, dozens in one dark kitchen or one department store. Distinct addresses mostly cannot: five different house numbers on one point to a metre means the geocoder stopped at the postcode or above.
The stack test needs nothing but the geometry, which means it works on files that arrived from somebody else with no metadata at all. That makes it the check to run first on any geocoded file you did not produce.
Why the fix is upstream
By the time the fallback is in the output, the information needed to do better has been discarded. The rows that fell back did so because their address text was empty, mis-mapped or unmatched โ and fixing that is a change to the input, not to the geocoder.
This is why the diagnosis step groups the failures by source system and by field completeness. One mapping error in one export usually explains most of a stack, and one fix clears it for every future run.
Legitimate stacks and source defaults
- Buildings stack legitimately. A registered-office address, a market or a department store can hold hundreds of records; count distinct addresses in a stack, not just rows.
- A source-system default โ the head office, a depot โ looks identical to a geocoder fallback and is fixed in a different place.
(0, 0)is not a centroid, it is a missing value coerced to zero. Check for it separately.- Some countries' centroids are in the sea or in another country; Chile and Croatia are the usual examples.
- Re-geocoding without fixing the input reproduces the stack exactly.
- Do not "fix" the coordinates by jittering them. Random offsets around a centroid look like data and are not.
- Aggregating coarse rows to the level they were matched at is legitimate and worth offering โ a country-level row can still count towards a national total.
- Report the excluded count in every downstream output. Silent exclusion is a different kind of wrong answer.
Internal links
- Rooftop, interpolated or centroid: geocoding precision levels โ the ladder that was fallen down
- How to validate geocoding results before you trust them โ the stack test in a full validation
- Geocoding returns wrong or missing coordinates โ the wider set of failures
- My geocoder returns no match for addresses that exist โ the honest version of this failure
- Geocoding explained: from an address string to a coordinate โ why fallbacks happen at all
- How to reverse geocode points to addresses and areas โ naming the stack points
- Repair, reject or flag โ choosing a policy for the affected rows
- How to remove spatial outliers โ the general version of "these points are wrong"
FAQ
Why are all my geocoded points in one place?
The geocoder fell back to a country or city centroid for rows whose addresses it could not match. Every one of those rows gets the same coordinate โ the United Kingdom's is about 414 km from 10 Downing Street.
How do I detect this without a precision field?
Count coordinates shared by many rows, then count the distinct addresses in each stack. Many different addresses on one point to five decimal places โ about a metre โ is a fallback; one address many times is usually a shared building.
Can I fix the coordinates after the fact?
No. The fallback discarded the information needed to do better. Fix the input โ usually an empty, mis-mapped or country-only address field โ and re-geocode just those rows.
Should I delete the affected rows?
Label them rather than delete them. They can still be used at the level they were matched at, and deleting them hides how much of the file was unusable.
Is jittering the points a reasonable workaround?
No. Random offsets around a centroid produce something that looks like data and carries no information, which is worse than an obvious single stack.
How do I stop it happening again?
Store the precision level on every geocode, and wrap the geocoder so that a match below your floor returns a labelled failure instead of a coordinate.