How to Validate Geocoding Results Before You Trust Them

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:

  • a few hundred rows sharing one coordinate โ€” the country centroid, 413 km from where they claim to be
  • 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
Five validation checks in order: impossible values, precision, stacking, region, distribution.
The first three cost microseconds; the fourth costs one spatial join.

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))

A legitimate duplicate is a block of flats. Four hundred rows on one point is a centroid, and reverse geocoding that point usually names it directly โ€” "United Kingdom", "Greater London", "รŽle-de-France".

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)

For data anywhere in the UK, Ireland or western Europe, a swap moves the point into Somalia, Kazakhstan or the Indian Ocean depending on the sign โ€” visible on a map, and easy to miss in a table of 40,000 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.

Two map panels: scattered geocoded points beside the same rows collapsed onto one centroid.
The stack test needs nothing but the geometry, so it works on files that arrived without metadata.

Code examples

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")
    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)
                      check   rows   pct severity
0        missing coordinate    416  1.04    fatal
1          null island (0,0)     12  0.03    fatal
2         in the wrong region    287  0.72    error
3  stacked on a shared point  1,004  2.51  warning
4           coarse precision  2,174  5.44  warning
5      outside every region      93  0.23  warning

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]}")
 1,240 rows at (54.70235, -3.27658)  [country]  United Kingdom
   318 rows at (51.50745, -0.12777)  [city]     Greater London, England, United Kingdom
    47 rows at (52.48624, -1.89040)  [city]     Birmingham, West Midlands, England

One line per stack, and each line explains itself. 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,
        "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

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.

Triage table mapping four validation findings to fatal, error and warning actions.
Warnings are counted and reported; only the top two rows block the run.

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.

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.