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 road or house, not country
  • 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]}")
Checklist of four passing geocoder signals and one failing external check.
The missing information was never in the query, so no score could penalise it.

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 Wight is ambiguity. Newport Pagnell is 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 Gwent fixes it, ambiguity is confirmed and the fix is context.

2. Add every constraint you have

Constraints in order of power:

  1. Country code. countrycodes=gb removes the whole international candidate set โ€” 132,528 names are ambiguous across borders alone.
  2. Region or state, as a query component rather than free text: state="Gwent".
  3. Postcode. The strongest of all where it exists, because postal codes are hierarchical: the outward part alone usually pins the town.
  4. A viewbox โ€” a bounding box the answer should fall inside, with bounded=1 so 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.

Bar chart of how many places share the names San Antonio, Springfield, Newport and others.
A local gazetteer query tells you which rows need extra context, before you spend a request.

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.

Five geocoding constraints ordered by how much ambiguity each removes.
The alias table is the most reliable component in the pipeline, because a human decided each row.

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=1 is 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.

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.