Fixing Geocodes That All Land in the Country Centroid
Problem statement
The map has one enormous dot. Zoom in and it is fourteen hundred customers stacked on a single coordinate somewhere in the middle of the country โ a field in Cumbria, a patch of desert, a point in the sea.
1,240 rows at (54.70235, -3.27658) [country] United Kingdom
318 rows at (51.50745, -0.12777) [city] Greater London, England
47 rows at (52.48624, -1.89040) [city] Birmingham, West Midlands
Nothing failed. Every one of those rows has a valid coordinate with six decimal places, and it is 413 km from the address it claims to represent โ that is the measured distance from a real London address to the coordinate returned for the query "United Kingdom".
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
Then reverse geocode each stacked coordinate. The answer names the failure: United Kingdom, Greater London, France โ the level the geocoder fell back to.
Step-by-step solution
1. Confirm the diagnosis with the precision field
If the precision level was stored, this takes one line:
print(df["precision"].value_counts())
rooftop 24,930
street 8,470
postcode 4,010
locality 2,048
country 126 <- these
If it was not stored, the stack count is the diagnosis. Several hundred distinct addresses cannot legitimately share a coordinate to five decimal places, which is about one metre.
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.
Code examples
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=10)
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))
Example 2 โ a guard that refuses to return a fallback
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}
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
Explanation
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 for one real London address against the coordinate returned for "United Kingdom": 413,077 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
Two distinct addresses can share a coordinate legitimately: flats in one building, units on one site. Several hundred cannot, and thousands certainly cannot.
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.
Edge cases or notes
- Blocks of flats stack legitimately. Set
min_rowsabove the largest plausible building โ 5 to 10 is usually safe. - 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 โ measured at 413 km from a real address in one case.
How do I detect this without a precision field?
Count coordinates shared by many rows. Hundreds of distinct addresses on one point to five decimal places โ about a metre โ is a fallback, and reverse geocoding that point names it.
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.