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.

Distance from 10 Downing Street to the coordinate Nominatim returns for each coarser query. how far the fallback moved the pointthe street50 mthe postcode0 mthe city440 mthe country413,682 m0450k mThe error is systematic, not random โ€” every fallen-back row gets the same coordinate spatialworkflow.io
Aggregating fallback rows does not average the error away; it concentrates it.

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:

  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.
Three stacks from the food hygiene register, named by reverse geocoding and sorted by how many addresses each holds. let the stack name itselfrowsaddressesreverse geocodes to202171-75 Shelton Street521Ardwick Kitchens, 42 Higher Ardwick4322407 Chalk Farm Road, by the marketone address: a shared building; many: a fallbackThis is the report to put in front of whoever owns the address data spatialworkflow.io
A source-system default โ€” a depot, a head office โ€” looks identical and is fixed elsewhere.

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.

Three honest policies for coarse geocodes, and the one that is not a policy. three options, and a fourth that is not onewhat happens to a coarse geocode?strict analysisdrop and reportcount the exclusionsarea-level usekeep, labelledusable at its own levelfixable inputreview queueusually a bulk fixdefaultkeep unlabellednot a policyThe fourth is what happens automatically when the precision column is not stored spatialworkflow.io
Every option except the last one is defensible; the last one is the default.

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.

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.