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" |
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.
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.
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_xytakes(lon, lat). Reversing it is the commonest single geocoding bug after the precision problem. Always printtotal_boundsafterwards.(0, 0)is a real coordinate in the Gulf of Guinea. Anything thatfillna(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.
Internal links
- How to geocode addresses in Python β the pipeline that prevents most of this
- Points plot in the ocean off Africa β the lat/lon swap and the (0, 0) sentinel
- How to handle missing and null values in spatial datasets β what to do with rows that will not geocode
- A GIS data cleaning checklist for Python β where address cleaning belongs
- How to cache downloaded GIS data so you fetch it once β so a rerun only touches the failures
- How to retry a flaky GIS step in Python β separating rate limits from bad data
- GIS data sources explained β whether you may keep the coordinates at all
- How to read a CSV with coordinates as a GeoDataFrame β the step after geocoding
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.