Geocoding Returns Wrong or Missing Coordinates

Problem statement

The geocoder ran. It returned coordinates. And the map is wrong:

  • Half the points sit on top of each other at the centre of the city.
  • One customer in Manchester has landed in Wigan, twenty miles away.
  • A quarter of the rows are None, and the addresses look perfectly reasonable.
  • Everything is in the Gulf of Guinea, off the coast of Africa.

None of these raised an exception. Geocoding is the step in a GIS pipeline most likely to produce confidently wrong output, because a geocoder is a search engine: asked for something it cannot find, it returns the best thing it can, at whatever precision it managed.

Here is the failure in a single pair of calls:

print(geocode("Oldham Street, Manchester").raw["place_rank"])
print(geocode("9999 Oldham Street, Manchester").raw["place_rank"])
26
26

Same rank, same coordinate. House number 9999 does not exist, and instead of failing, the geocoder returned the street.

Quick answer

Diagnose by looking at the raw response, not the coordinates:

loc = geocode("1 High Street, Manchester, UK", addressdetails=True)
raw = loc.raw
print(f"rank {raw['place_rank']} | type {raw['type']} | {raw['address'].get('city')}")
print(loc.address)
rank 30 | type semidetached_house | Wigan
1, High Street, Gin Pit, Astley, Wigan, Greater Manchester, England, …

Rank 30 β€” a building. Precise, confident, and in the wrong town.

Symptom Cause Fix
points stacked on one spot fell back to city/street centroid filter on place_rank >= 28
right rank, wrong town ambiguous name won on ranking structured query + country + extent check
everything returns None rate-limited, or exceptions swallowed swallow_exceptions=False, slow down
points in the Gulf of Guinea lat/lon swapped, or (0, 0) kept build points as (lon, lat); drop nulls first
a few percent fail sub-building details in the text strip "Flat 3", "Apt 2B", "Unit 5"
Five geocoding symptoms mapped to their cause and the specific check that identifies each.
Each symptom has one diagnostic. None of them is visible in the coordinate pair alone.

Step-by-step solution

1. Look at what the geocoder actually returned

location.latitude is the least informative part of the response. Everything you need to judge a match is in .raw:

import json

loc = geocode("Main Street", addressdetails=True)
print(json.dumps({k: loc.raw[k] for k in ("place_rank", "type", "class", "importance")}, indent=2))
print(loc.address)
{
  "place_rank": 30,
  "type": "station",
  "class": "railway",
  "importance": 0.406
}
Main Street, Main Bus Terminal, East End-Danforth, Toronto, Ontario, Canada

Rank 30 says "precise". type: station and class: railway say it is a transit stop, not an address. And the country says Canada. All three are needed β€” rank alone would have passed this.

2. Test for the centroid-stacking symptom

If many points share a coordinate, they all fell back to the same parent object:

import pandas as pd

dupes = (points.groupby([points.geometry.x.round(5), points.geometry.y.round(5)])
               .size().sort_values(ascending=False))
print(dupes.head(3))
-2.24428  53.47929    147
-2.29380  53.41234      6
-2.23011  53.48120      3

147 addresses at one coordinate is not 147 buildings. It is the city centroid, and every one of those rows failed to match at address level. Check the rank distribution to confirm:

print(result["place_rank"].value_counts().sort_index().to_string())
16      147
26       88
30      765

147 city-level, 88 street-level, 765 building-level. Only the last group answers an address-level question.

3. Separate "no match" from "the request failed"

geopy's RateLimiter swallows exceptions by default, which means a timeout, a 429 and a genuinely unmatched address all arrive as None:

geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1.1,
                      swallow_exceptions=False)      # the default is True

With that flag off, a rate limit raises GeocoderTimedOut or GeocoderUnavailable and you can retry it β€” whereas a bad address returns None and should not be retried. Conflating them means either retrying addresses that will never match, or discarding rows that were only ever a network blip.

A sudden run of None starting partway through a file is almost always rate limiting, not data quality. Check whether the failures cluster by position:

print(result.assign(row=range(len(result)))
            .query("status == 'no match'")["row"].describe()[["count", "min", "max"]])

4. Check the country and the extent

def out_of_area(result, expect_cc="gb", bounds=(-8.6, 49.9, 1.8, 60.9)):
    wrong_country = result["country_code"].ne(expect_cc) & result["country_code"].notna()
    outside = ~result["lon"].between(bounds[0], bounds[2]) | \
              ~result["lat"].between(bounds[1], bounds[3])
    return result[wrong_country | outside]


print(out_of_area(result)[["query", "matched", "country_code"]].head().to_string(index=False))
        query                                          matched country_code
  Main Street  Main Street, Main Bus Terminal, Toronto, Canada           ca
 Union Street  Union Street, Aberdeen City, Scotland, UK                 gb

The second row passes the country test and fails nothing β€” which is the point. An extent check catches the wrong-continent case; only a rank-and-town check catches the wrong-town case.

5. Find the addresses that are unmatchable as written

import re

failed = result[result["status"] == "no match"]["query"]
patterns = {
    "sub-building": r"(?i)\b(flat|apt|apartment|unit|suite|room)\b",
    "po box": r"(?i)\bp\.?o\.? box\b",
    "care of": r"(?i)\bc/o\b",
    "no digits": r"^\D*$",
    "very short": r"^.{0,6}$",
}
for label, pattern in patterns.items():
    hits = failed.str.contains(pattern, regex=True).sum()
    print(f"{label:14} {hits:4} of {len(failed)}")
sub-building     41 of 118
po box            7 of 118
care of           3 of 118
no digits        29 of 118
very short        2 of 118

Sixty percent of the failures are explained by five patterns, and four of them are fixable with text cleaning rather than a better geocoder.

Many addresses failing to match at building level and collapsing onto a single city-centre coordinate.
147 points at one coordinate is a diagnostic, not a data pattern. Check `place_rank` before believing any of them.

Code examples

Example 1 β€” a full audit of a geocoded result set

import geopandas as gpd
import pandas as pd


def audit(result, *, min_rank=28, expect_cc="gb", bounds=(-8.6, 49.9, 1.8, 60.9)):
    """Classify every geocoded row by why it is or is not usable."""
    r = result.copy()
    r["place_rank"] = pd.to_numeric(r.get("place_rank"), errors="coerce")

    conditions = [
        (r["lat"].isna(), "no match"),
        (r["place_rank"] < 26, "settlement centroid"),
        (r["place_rank"].between(26, 27), "street centroid"),
        (r["country_code"].ne(expect_cc) & r["country_code"].notna(), "wrong country"),
        (~r["lon"].between(bounds[0], bounds[2]) |
         ~r["lat"].between(bounds[1], bounds[3]), "outside extent"),
        (r["place_rank"] >= min_rank, "ok"),
    ]
    r["verdict"] = "unclassified"
    for mask, label in conditions:
        r.loc[(r["verdict"] == "unclassified") & mask.fillna(False), "verdict"] = label

    summary = r["verdict"].value_counts()
    print(summary.to_string())
    print(f"\nusable: {summary.get('ok', 0) / len(r):.1%}")
    return r


audited = audit(result)
print(audited[audited["verdict"] != "ok"][["query", "matched", "verdict"]]
      .head(4).to_string(index=False))
verdict
ok                     765
settlement centroid    147
street centroid         88
no match               118
wrong country            6

usable: 68.2%

                          query                                 matched              verdict
 9999 Oldham Street, Manchester   Oldham Street, Northern Quarter, …       street centroid
                     Manchester   Manchester, Greater Manchester, …    settlement centroid
                    Main Street   Main Street, Main Bus Terminal, …          wrong country
   Nowhere Street 999, Atlantis                                 NaN               no match

68.2% usable is the number to report. A pipeline that quietly kept all 1,124 coordinates would have shown a map that looks complete and is one-third fiction.

Example 2 β€” retrying the fixable failures with cleaned text

import re

def clean(address):
    a = re.sub(r"(?i)\b(flat|apt|apartment|unit|suite|room)\s*[\w-]+,?\s*", "", str(address))
    a = re.sub(r"(?i)\bc/o\b[^,]*,?\s*", "", a)
    a = re.sub(r"(?i)\bp\.?o\.? box\s*\d+,?\s*", "", a)
    a = re.sub(r"(?i)\bst\b\.?(?=\s|,|$)", "Street", a)
    a = re.sub(r"(?i)\brd\b\.?(?=\s|,|$)", "Road", a)
    return re.sub(r"\s*,\s*", ", ", re.sub(r"\s+", " ", a)).strip(" ,")


retry = audited[audited["verdict"].isin(["no match", "street centroid"])].copy()
retry["cleaned"] = retry["query"].map(clean)
changed = retry[retry["cleaned"] != retry["query"]]
print(f"{len(changed)} of {len(retry)} rows changed by cleaning")
print(changed[["query", "cleaned"]].head(3).to_string(index=False))
88 of 206 rows changed by cleaning
                              query                    cleaned
     Flat 3, 12 Oldham st., Manchester  12 Oldham Street, Manchester
  Apt 2B, 44 Deansgate, Manchester      44 Deansgate, Manchester
  c/o Smith, 9 High Rd, Manchester      9 High Road, Manchester

Re-geocode only the changed rows. This is why the reject file matters: it turns a vague "improve the geocoding" into 88 specific rows and one measurable improvement.

Example 3 β€” building points without the (0, 0) trap

def to_points(audited, *, keep="ok", crs="EPSG:4326"):
    usable = audited[audited["verdict"] == keep].copy()

    # nulls first, then the sentinel: (0, 0) is in the Gulf of Guinea and is
    # what a "failed" row looks like once someone has fillna(0)'d it
    usable = usable.dropna(subset=["lat", "lon"])
    sentinel = (usable["lat"].eq(0) & usable["lon"].eq(0))
    if sentinel.any():
        print(f"dropping {sentinel.sum()} rows at (0, 0)")
        usable = usable[~sentinel]

    gdf = gpd.GeoDataFrame(
        usable,
        geometry=gpd.points_from_xy(usable["lon"], usable["lat"]),   # lon FIRST
        crs=crs,
    )
    print(f"{len(gdf)} points, bounds {gdf.total_bounds.round(3)}")
    return gdf


points = to_points(audited)
dropping 3 rows at (0, 0)
765 points, bounds [-3.201 51.447  0.128 55.012]

Two failure modes closed in one function. points_from_xy takes x then y β€” longitude then latitude β€” and reversing them puts UK addresses in Somalia. Printing total_bounds immediately afterwards is the check: those numbers are recognisably Britain, and a swap would have shown latitudes near -2 and longitudes near 53.

Explanation

Why a geocoder prefers a wrong answer to no answer

Geocoding is ranked retrieval. Your query is tokenised, candidates are scored on how much of it they explain plus how prominent they are, and the top candidate is returned. There is no threshold below which it declines.

"9999 Oldham Street, Manchester" contains a street that exists in a city that exists. Those tokens dominate the score; "9999" contributes nothing and costs nothing. The street wins.

This is reasonable search behaviour and terrible data-pipeline behaviour, which is why the threshold has to be yours. place_rank >= 28 is that threshold, expressed in the geocoder's own vocabulary.

Why the wrong-town case is so hard to spot

1 High Street, Manchester matched a house in Gin Pit, Astley, Wigan β€” inside the Greater Manchester county, so a country check passes, an extent check passes, and place_rank is 30. Every automated test says this is a good match.

It is wrong because "Manchester" in the query meant the city and the match used the county. The only checks that catch it are comparing the returned town against the requested one:

requested_town = "Manchester"
returned = loc.raw["address"]
town = returned.get("city") or returned.get("town") or returned.get("village")
if town and town.lower() != requested_town.lower():
    print(f"town mismatch: asked {requested_town}, got {town}")
town mismatch: asked Manchester, got Wigan

Use structured queries where you can, so the city is a constraint rather than a token.

Why importance is the wrong filter

It is tempting to filter on importance because it sounds like confidence. It is not β€” it is prominence, derived partly from Wikipedia links. An ordinary house has importance: 0.000 and is a perfect match; a city has importance: 0.740 and is a terrible one for an address query.

Filtering on importance therefore rejects exactly the rows you wanted and keeps the centroids. Use place_rank.

Two matches compared: a house with rank 30 and importance 0.0, and a city with rank 16 and importance 0.74.
Importance measures fame, not precision. The good match is the one with the low importance score.

Why rate limiting looks like bad data

Nominatim's policy is one request per second. Exceed it and requests start failing β€” as timeouts, as 429s, sometimes as empty results. With swallow_exceptions=True (the default in RateLimiter), all of those become None, which is the same value a genuinely unmatchable address produces.

The tell is position: rate-limit failures cluster after the point where you exceeded the limit, while data-quality failures are scattered throughout. If your None rows are mostly in the back half of the file, slow down and rerun rather than blaming the addresses. Combine that with a retry with backoff and a cache so the rerun only touches what failed.

Edge cases or notes

  • points_from_xy takes (lon, lat). Reversing it is the commonest single geocoding bug after the precision problem. Always print total_bounds afterwards.
  • (0, 0) is a real coordinate in the Gulf of Guinea. Anything that fillna(0)s a failed geocode creates points there. Drop nulls before building geometry.
  • Postcode centroids are legitimate but coarse. A rural postcode centroid can be over a kilometre from any building. Rank 21–25 identifies them.
  • The same address can geocode differently over time as OSM changes. Cache the result with a date if reproducibility matters.
  • Diacritics and transliteration matter. "ZΓΌrich" and "Zurich" may score differently. Normalising to ASCII sometimes helps and sometimes destroys the match β€” test on a sample.
  • Reverse geocoding returns the nearest thing, not the containing one. A point in a car park can reverse to the pub next door.
  • An official address gazetteer beats geocoding wherever one exists. Joining on a real address key has no precision ambiguity at all.

FAQ

Why are lots of my points stacked on one coordinate?

They fell back to a shared parent β€” usually the city or street centroid. Check place_rank: anything below 28 is not an address-level match.

Why did the geocoder return a coordinate for an address that does not exist?

Because the rest of the query matched something real. Geocoders return the best-scoring candidate, not only exact matches. Filter on precision yourself.

Should I filter on importance?

No. Importance measures prominence, not precision β€” an ordinary house scores 0.0. Use place_rank.

Everything returns None after a few hundred rows. What happened?

You are being rate-limited. Set swallow_exceptions=False to see the real error, keep to one request per second, and cache so the retry only covers the failures.

My points are in the Gulf of Guinea. Why?

Either (0, 0) from a filled-in null, or latitude and longitude swapped. points_from_xy takes longitude first; check total_bounds straight after building the geometry.

The match is precise but in the wrong town. How do I catch that?

Compare the returned address components against what you asked for. Rank and extent checks both pass for this case β€” only a town-level comparison catches it.

How good is good enough?

Report the percentage that matched at building level and hand the rest back. There is no universal threshold, but an unreported match rate is always the wrong answer.