Fixing a Geocoder That Matches the Wrong Town
Problem statement
Every coordinate looks reasonable. The points are on land, in populated places, at street-level precision. And a customer in Newport, Wales is plotted in Newport, Isle of Wight โ 155 km away, in the right country, at a real address on a real street with the same name.
This is the hardest geocoding failure to catch, because nothing about it is anomalous:
- the precision level says
roadorhouse, notcountry - the confidence score is high โ the match was good, against the wrong place
- the point is not stacked with any other
- the address is spelled correctly
The cause is ambiguity plus insufficient context. Place names repeat on a scale that surprises people: in a gazetteer of 5,226,942 populated places there are 3,026,188 distinct names, 132,528 of which occur in more than one country. San Antonio names 2,382 places across 25 countries. Springfield names 68 places in the United States alone. Ask a geocoder for "Springfield, USA" and it returns Springfield, Illinois with an importance of 0.6126 โ the most famous one, not necessarily yours.
Quick answer
Constrain the search, and check the answer against a region you already know:
# 1. Constrain: the single highest-yield change
hits = client.search(q=address, countrycodes="gb")
# 2. Verify: does the result agree with the region the record claims?
def region_matches(hit, expected_region):
address = hit.get("address", {})
candidates = {address.get(k, "").lower()
for k in ("state", "county", "region", "state_district")}
return expected_region.lower() in candidates
# 3. Detect: ask for three candidates and look at the gap
hits = client.search(q=address, countrycodes="gb", limit=3)
if len(hits) > 1:
gap = float(hits[0]["importance"]) - float(hits[1]["importance"])
if gap < 0.05:
print(f"ambiguous: {hits[0]['display_name'][:50]} "
f"vs {hits[1]['display_name'][:50]}")
Step-by-step solution
1. Confirm it is ambiguity and not something else
Three checks separate ambiguity from its lookalikes:
- Is the returned place a real place with the same name? Reverse the display name.
Newport, Isle of Wightis ambiguity.Newport Pagnellis a partial-string match, which is a different bug. - Is it in the right country? A wrong-country match usually means no country constraint was applied.
- Does the result change when you add the region? If adding
Gwentfixes it, ambiguity is confirmed and the fix is context.
2. Add every constraint you have
Constraints in order of power:
- Country code.
countrycodes=gbremoves the whole international candidate set โ 132,528 names are ambiguous across borders alone. - Region or state, as a query component rather than free text:
state="Gwent". - Postcode. The strongest of all where it exists, because postal codes are hierarchical: the outward part alone usually pins the town.
- A viewbox โ a bounding box the answer should fall inside, with
bounded=1so it is a hard constraint rather than a preference.
hits = client.search(street="High Street", city="Newport",
state="Gwent", postalcode="NP20 1AA",
countrycodes="gb")
3. Use a bounding box when the data has a natural extent
If every record is in one county, one delivery area or one country, say so:
hits = client.search(q=address, viewbox="-5.5,51.3,-2.6,53.5", bounded=1)
bounded=1 is the important half. Without it the viewbox is a ranking hint and the geocoder will still return something outside it.
4. Detect the ambiguity you cannot prevent
Some rows have no extra context. For those, ask for several candidates and measure the separation:
- Large gap โ a clear winner; accept.
- Small gap โ the geocoder chose between near-equals; flag for review.
- Several candidates in different regions โ flag regardless of the gap.
Requesting limit=3 costs nothing extra on most APIs and converts an invisible coin flip into a countable one.
5. Validate against the boundary layer
The strongest check does not involve the geocoder at all: join the geocoded points to an administrative boundary layer and compare with the region each record already claims.
This is the check that catches wrong-town matches specifically, because they are the failure that every geocoder-internal signal reports as a success. Expect a small baseline of legitimate disagreements โ generalised boundaries, addresses genuinely on a border โ and treat anything above about 1% as a systematic problem.
6. Fix the cluster, not the row
Wrong-town matches cluster by name. Group the mismatches by the town in the input:
bad = joined[joined.region_claimed != joined.region_found]
print(bad.groupby("town_input").size().sort_values(ascending=False).head(10))
A single ambiguous town name usually accounts for most of the errors, and one alias entry โ "Newport" in region "Gwent" -> geonameid 2641598 โ fixes every occurrence permanently.
Code examples
Example 1 โ a region-aware geocode with verification
def geocode_verified(client, row, country="gb"):
"""Geocode, then check the answer against the region the record claims."""
params = {"street": row.get("street"), "city": row.get("town"),
"postalcode": row.get("postcode"), "countrycodes": country}
params = {k: v for k, v in params.items() if v}
hits = client.search(limit=3, addressdetails=1, **params)
if not hits:
return {"status": "no_match"}
top = hits[0]
detail = top.get("address", {})
found_region = (detail.get("state") or detail.get("county")
or detail.get("state_district") or "")
claimed = (row.get("region") or "").strip()
result = {
"lat": float(top["lat"]), "lon": float(top["lon"]),
"precision": top.get("addresstype"),
"found_region": found_region, "claimed_region": claimed,
"candidates": len(hits),
}
if len(hits) > 1:
result["gap"] = round(float(top.get("importance", 0))
- float(hits[1].get("importance", 0)), 4)
if claimed and found_region and claimed.lower() not in found_region.lower():
result["status"] = "region_mismatch"
elif result.get("gap") is not None and result["gap"] < 0.05:
result["status"] = "ambiguous"
else:
result["status"] = "ok"
return result
Example 2 โ measuring how ambiguous your town names are, offline
import duckdb
def ambiguity_of_towns(parquet, towns, country=None):
"""Before geocoding: how many places share each of these names?"""
con = duckdb.connect()
where = "fclass = 'P'" + (f" and country = '{country}'" if country else "")
names = ",".join(f"'{t}'" for t in towns)
rows = con.execute(f"""
select name,
count(*) as places,
count(distinct country) as countries,
max(population) as biggest
from read_parquet('{parquet}')
where {where} and name in ({names})
group by 1 order by places desc
""").fetchall()
for name, places, countries, biggest in rows:
flag = " <- needs a region" if places > 1 else ""
print(f"{name:24} {places:5,} places in {countries:2} countries{flag}")
return rows
Springfield 121 places in 12 countries <- needs a region
Newport 75 places in 7 countries <- needs a region
Ambleside 7 places in 5 countries <- needs a region
Chipping Sodbury 1 places in 1 countries
Running this over the distinct town names in a file, before geocoding, tells you exactly which rows need extra context โ and it is a local query against a gazetteer, so it costs nothing.
Example 3 โ an alias table for the names your data uses
import csv
class AliasResolver:
"""Hand-written answers for the names that are ambiguous in your data.
aliases.csv:
town,region,country,lat,lon,note
Newport,Gwent,GB,51.58790,-2.99775,"the Welsh one"
Newport,Isle of Wight,GB,50.70050,-1.29260,
Springfield,Illinois,US,39.79901,-89.64395,"head office"
"""
def __init__(self, path="aliases.csv"):
self.by_key = {}
with open(path, newline="", encoding="utf-8") as fh:
for row in csv.DictReader(fh):
key = (row["town"].casefold(), row["region"].casefold(),
row["country"].upper())
self.by_key[key] = (float(row["lat"]), float(row["lon"]))
def resolve(self, town, region, country):
return self.by_key.get((str(town).casefold(), str(region).casefold(),
str(country).upper()))
Fifty lines of CSV, checked into the repository, reviewed by people who know which Newport the company means. It runs before the geocoder and it is the most reliable component in the pipeline, because a human decided each row.
Explanation
Why the confidence score cannot catch this
The score measures how well the returned text matched the query. When the query is "High Street, Newport" and the reference record is "High Street, Newport, Isle of Wight", the match is genuinely excellent โ every token you supplied is present.
The information that would have made it a bad match is the information you did not supply. No scoring function can penalise a candidate for failing a constraint that was never expressed, which is why the fix is constraints rather than thresholds.
Why famous places win
Geocoders rank by prominence as well as by text similarity, because that is the right behaviour for a search box: somebody typing "London" almost always means the large one.
In a batch of addresses the same ranking is a systematic bias towards big cities. "Springfield, USA" returns Illinois at importance 0.6126 not because it fits better but because it is better known. Every ambiguous row in your file is therefore resolved the same way โ towards fame โ which makes the errors correlated rather than random and concentrates them in the same wrong places.
Why the boundary check is the only reliable detector
Everything the geocoder tells you about this failure says "success": high score, fine precision, plausible coordinate, no stacking. The only signal that disagrees is an independent claim about where the record should be, and that comes from your own data.
That is why the region column in the source file is worth so much. Records that carry a county, a state, a sales territory or a branch code all give you a second opinion, and the join between the geocoded point and the corresponding boundary polygon converts it into a count.
Why postcodes beat every other constraint
A postal code is short, checkable, hierarchical and independently assigned. The outward part of a UK postcode identifies the town without needing the town name at all; a US ZIP identifies a delivery area; most national systems have the same property.
That makes the postcode both the best constraint to send and the best field to verify against. Its weakness is that it is typed from memory more often than any other field โ so use it as a constraint, and treat a postcode-versus-town disagreement as a flag on the record rather than as a decision about which one is right.
Edge cases or notes
- Adjacent towns with the same name exist within one county; a region constraint does not always resolve them.
- Historic counties โ Gwent, Middlesex โ appear in address data and not in current boundary layers. Keep a mapping.
- Cross-border addresses legitimately sit metres from a boundary; a small mismatch rate is normal.
bounded=1is required for a viewbox to be a constraint rather than a hint.- Do not over-constrain. A wrong region in the input plus a hard constraint produces a no-match, which at least is visible.
- The same name at different administrative levels โ a city and the county containing it โ is a different ambiguity; check the
addresstype. - Record which constraints you applied. A coordinate produced with a country filter is a different measurement from one without.
- Alias tables need an owner. They encode business knowledge and go stale silently.
Internal links
- Match quality explained: reading a geocoder's confidence score โ why the score does not catch this
- How to validate geocoding results before you trust them โ the boundary check in full
- Geocoding returns wrong or missing coordinates โ the wider family of failures
- Address matching explained: why exact string equality fails โ ambiguity in the matching layer
- How to fuzzy match place names in Python โ resolving names against a gazetteer
- How to build an offline geocoder from open address data โ measuring ambiguity before you geocode
- Geocoding explained: from an address string to a coordinate โ where ranking comes from
- How to join points to polygons with a spatial join โ the verification join
FAQ
Why did my geocoder pick the wrong town with the same name?
Because you did not give it enough context to choose, and it ranked by prominence. 132,528 place names occur in more than one country, and Springfield alone names 68 places in the United States.
What is the single most effective fix?
A country code on every request. After that, a region or postcode component, sent as a structured parameter rather than appended to the free-text string.
How do I detect wrong-town matches?
Join the results to a boundary layer and compare with the region the record already claims. No geocoder-internal signal catches this failure โ the score and precision both report success.
Does a high confidence score rule this out?
No. The match against the wrong town is genuinely good; every token supplied was present. The missing information was never in the query, so no score can penalise it.
What is a viewbox and when should I use it?
A bounding box the answer should fall within. Use it when all your data is in a known area, and pass bounded=1 so it constrains rather than merely ranks.
How do I handle a name that stays ambiguous?
An alias table: town, region, country and the chosen coordinate, hand-written and reviewed. Fifty lines usually cover an organisation's whole ambiguity problem permanently.