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.

Bar chart of distance from a real address to street, postcode, city and country coordinates.
Aggregating fallback rows does not average the error away; it concentrates it.

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:

  1. Store the precision level on every geocode. Without it, this diagnosis needs archaeology.
  2. Reject below a floor at write time. A geocoder wrapper that refuses to return a country-level match โ€” returning None and a reason instead โ€” makes the failure loud at the point it happens.
Table of stacked coordinates with row counts, precision level and the place each reverse geocodes to.
A source-system default โ€” a depot, a head office โ€” looks identical and is fixed elsewhere.

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.

Decision diagram with three valid fallback policies and one invalid default.
Every option except the last one is defensible; the last one is the default.

Edge cases or notes

  • Blocks of flats stack legitimately. Set min_rows above 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.

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.