My Geocoder Returns No Match for Addresses That Exist

Problem statement

A batch of addresses comes back empty. Not wrong โ€” empty. And the addresses are real: you can find them on a map, post letters to them, and walk to the front door.

>>> nom.search("10 Downng Stret, Londn")
[]
>>> nom.search("1 Nonexistent Road, Nowheresville, UK")
[]

Both requests returned HTTP 200 with an empty list. Nothing raised, nothing logged, and a pipeline that only checks raise_for_status() records these as successful lookups with None coordinates.

An empty result has half a dozen distinct causes, and they need different fixes. Guessing between them โ€” usually by trying a different geocoder โ€” wastes days.

Quick answer

Bisect the address. Remove components from the most specific end until something matches; the component you removed to get a hit is the one that failed.

def diagnose(client, address_parts):
    """address_parts, most specific first: ['Flat 2', '14 High St', 'Camden',
    'London', 'NW1 8QP', 'UK']. Prints where matching starts to work."""
    for i in range(len(address_parts)):
        query = ", ".join(address_parts[i:])
        hits = client.search(query)
        status = f"{len(hits)} hit(s)" if hits else "no match"
        marker = "  <- first match" if hits and i > 0 else ""
        print(f"{'(dropped ' + str(i) + ')':14} {query[:50]:52} {status}{marker}")
        if hits:
            return {"failing_component": address_parts[i - 1] if i else None,
                    "matched_at": query, "hit": hits[0]}
    return {"failing_component": "everything", "matched_at": None, "hit": None}
(dropped 0)    Flat 2, 14 High St, Camden, London, NW1 8QP        no match
(dropped 1)    14 High St, Camden, London, NW1 8QP                no match
(dropped 2)    Camden, London, NW1 8QP                            1 hit(s)  <- first match

The unit and the house number were fine; 14 High St was not โ€” that street is not in the reference data under that name.

Four progressively shorter queries, the last of which finally matches.
Bisection turns "the geocoder is broken" into "this street is not in the reference data".

Step-by-step solution

1. Confirm it really is a no-match, not an error

Three different things look similar in a log:

Symptom Meaning Fix
HTTP 200, [] no match this article
HTTP 429 / 403 rate limited or blocked slow down, or self-host
HTTP 200, {"error": ...} invalid request check the parameters
Timeout / 502 transient retry with backoff

Log the status code and the response length separately. A pipeline that records "failed" for all four cannot tell you which one dominates.

2. Check the obvious input problems first

In order of frequency:

  • The address is empty or nearly so after cleaning. A query of two tokens matches a country and a query of zero tokens matches nothing.
  • The country is missing and the geocoder is searching the world for a street name that exists in thirty countries.
  • The fields are in the wrong columns โ€” the town in the street field, the whole address in the postcode field. A single mis-mapped column produces a wall of no-matches.
  • Encoding damage. Mรƒยผnchen is not a place; it is Mรผnchen decoded twice. See the encoding fix guide.

The first four checks take a minute with value_counts() on the token count and a look at ten failing rows.

3. Switch to a structured query

Free-text search has to guess which token is the street and which is the town. If your data is already parsed, tell the geocoder directly:

hits = client.search(street="14 High Street", city="London",
                     postalcode="NW1 8QP", country="United Kingdom")

You cannot mix q with the structured parameters. Structured queries frequently succeed where the same text as one string fails, because the parsing stage is skipped entirely.

4. Widen deliberately, and record how far you widened

If a full address does not match, drop the most specific component and try again โ€” but keep the resulting precision level in the output:

attempt 1: unit + number + street + town + postcode   -> no match
attempt 2:        number + street + town + postcode   -> no match
attempt 3:                 street + town + postcode   -> road   (accept, flag)
attempt 4:                          town + postcode   -> postcode

This is a fallback ladder, and it is only honest if the level you landed on is stored. A widened match that looks like a full match in the output is the failure mode the whole precision field exists to prevent.

5. Distinguish "not in this reference data" from "not anywhere"

Try a second provider on a sample of the failures. Three outcomes:

  • Provider B finds them โ€” your reference data has a gap. Consider a second provider for the residue, or a different one entirely.
  • Neither finds them โ€” the addresses are new, damaged, or not addresses (PO boxes, Unit 4, Industrial Estate, see attached).
  • Provider B finds them at locality level only โ€” they are real places with no address-level record anywhere open.

A hundred-row sample settles which of the three you are dealing with, and that determines whether the fix is technical or editorial.

6. Fix the recoverable ones in bulk

Failures cluster. Sort the no-match rows by town, by postcode district, by source system, and the pattern usually appears immediately: one branch's exports have the address in a different column order, one country's rows are missing their country code, one new estate is absent from a reference file that is eighteen months old.

Fixing a cluster is one rule. Fixing rows one at a time is a week.

Triage table of five causes of an empty geocoding result and their fixes.
Fixing a cluster is one rule; fixing rows one at a time is a week.

Code examples

Example 1 โ€” classifying the failures

import pandas as pd
from collections import Counter


def classify_failures(df, address_col="address", country_col=None):
    fails = df[df["lat"].isna()].copy()
    reasons = []
    for _, row in fails.iterrows():
        text = str(row[address_col] or "")
        tokens = text.split()
        if not text.strip():
            reasons.append("empty")
        elif len(tokens) < 3:
            reasons.append("too short")
        elif not any(ch.isdigit() for ch in text):
            reasons.append("no house number")
        elif country_col and not row.get(country_col):
            reasons.append("no country")
        elif any(ord(ch) > 0x2500 for ch in text):
            reasons.append("mojibake / bad encoding")
        elif len(text) > 120:
            reasons.append("suspiciously long โ€” two addresses in one cell?")
        else:
            reasons.append("plausible โ€” reference data gap or typo")
    fails["reason"] = reasons

    print(f"{len(fails):,} failures of {len(df):,} rows "
          f"({100 * len(fails) / len(df):.1f}%)")
    for reason, n in Counter(reasons).most_common():
        print(f"  {reason:45} {n:6,}")
    return fails

The last category is the one that needs a person. Everything above it is a data problem with a bulk fix.

Example 2 โ€” the fallback ladder, with the level recorded

LEVELS = ["full", "no_unit", "no_number", "street_town", "town_postcode", "postcode"]


def build_ladder(parsed: dict) -> list[tuple[str, dict]]:
    """Progressively coarser queries, each labelled with what it will return."""
    base = {k: v for k, v in parsed.items() if v}
    rungs = []
    rungs.append(("full", base))
    if "unit" in base:
        rungs.append(("no_unit", {k: v for k, v in base.items() if k != "unit"}))
    if "house_number" in base:
        rungs.append(("no_number", {k: v for k, v in base.items()
                                    if k not in ("unit", "house_number")}))
    if "street" in base:
        rungs.append(("street_town", {k: base[k] for k in ("street", "city", "postalcode")
                                      if k in base}))
    rungs.append(("town_postcode", {k: base[k] for k in ("city", "postalcode", "country")
                                    if k in base}))
    return rungs


def geocode_with_ladder(client, parsed, accept_below="street_town"):
    for level, params in build_ladder(parsed):
        hits = client.search(**params)
        if hits:
            return {"hit": hits[0], "widened_to": level,
                    "accepted": LEVELS.index(level) <= LEVELS.index(accept_below)}
    return {"hit": None, "widened_to": None, "accepted": False}

accepted is the field that keeps the ladder honest. A row that reached town_postcode is still a result; it is just not a result that belongs in a walking-distance analysis.

Example 3 โ€” sampling a second provider on the residue

def second_opinion(failures, provider_b, sample=100):
    """How many of our no-matches are absent from this reference data only?"""
    import random
    subset = random.sample(list(failures.itertuples()), min(sample, len(failures)))

    found, coarse, still_missing = [], [], []
    for row in subset:
        hits = provider_b.search(row.address)
        if not hits:
            still_missing.append(row.address)
        elif hits[0].get("addresstype") in ("house", "building", "road"):
            found.append((row.address, hits[0]["display_name"]))
        else:
            coarse.append((row.address, hits[0].get("addresstype")))

    n = len(subset)
    print(f"of {n} sampled failures:")
    print(f"  {len(found):3d} ({100 * len(found) / n:4.0f}%) found at address level elsewhere")
    print(f"  {len(coarse):3d} ({100 * len(coarse) / n:4.0f}%) found only at a coarse level")
    print(f"  {len(still_missing):3d} ({100 * len(still_missing) / n:4.0f}%) not found anywhere")
    return found, coarse, still_missing

Explanation

Why an empty result is a success as far as HTTP is concerned

The request was well formed, the server understood it, the search ran, and the answer is "nothing". That is a 200 with an empty body by any reasonable REST design.

The consequence is that no-match handling has to be explicit in your code. r.raise_for_status() passes, r.json() returns [], and hits[0] raises an IndexError several lines later in a place that makes the cause hard to see. Check the length immediately after parsing.

Why the house number is so often the failing component

Reference data is built from streets, and house numbers are attached to them either as individual address points or as interpolation ranges. Both are incomplete: a new build has no point yet, a converted property has an a suffix nobody recorded, and interpolation ranges cover the numbers that existed when the range was captured.

That is why the bisection so often finds the street matching and the number not. The fix is a deliberate fallback to street level, not a different geocoder โ€” the other geocoder has the same gap in a different place.

Why searching the world is worse than searching a country

A street name with no country is a search across every country's reference data. That produces more candidates, more chances to score a foreign match above the right one, and โ€” for genuinely ambiguous names โ€” more no-matches, because the ranking gets crowded.

Adding a country code is one parameter and it is usually the single highest-yield change to a failing batch: fewer no-matches, fewer wrong-country matches, and a faster query.

Why some addresses cannot be geocoded at all

Not everything with a postal address has a location in the sense a map needs:

  • PO boxes resolve to a sorting office, not to the recipient.
  • Care-of addresses name a different building.
  • Large sites โ€” Unit 4, Riverside Industrial Estate โ€” often have no per-unit record anywhere.
  • New developments predate the reference data by months to years.
  • Informal addressing, common in much of the world, has no street or number to match.

These belong in a labelled bucket, not in a retry loop. Counting them is a legitimate outcome: "3.7% of this file has no geocodable address" is a finding.

Five rungs of a fallback ladder from full address to country, with the precision each returns.
A widened match that looks like a full match is the failure the precision field exists to prevent.

Edge cases or notes

  • Check the length of the result, not just the status code. Every no-match is an HTTP 200.
  • limit=1 and a no-match look the same in a log. Record the candidate count.
  • A trailing country name in the string can fight the countrycodes parameter. Use one or the other.
  • Postcode-only queries almost always match and are a legitimate last rung, at postcode precision.
  • Cache the no-matches with a short TTL โ€” they will fail again, and they are the slowest rows to fail.
  • Rows that fail in bulk share a cause. Group the failures before investigating any of them.
  • Very long strings often contain two addresses; split on the second postcode.
  • Do not retry a no-match unchanged. It is deterministic; only a changed query or changed reference data can change the answer.

FAQ

Why does the geocoder return nothing for an address I can see on a map?

Most often the house number is absent from the reference data even though the street is present. Bisect the address: drop components until something matches, and the last one you removed is the culprit.

Is an empty response an error?

No. It is HTTP 200 with an empty list, which is why code that only calls raise_for_status() records it as a success. Check the result length explicitly.

Should I retry a no-match?

Not unchanged โ€” the answer is deterministic. Retry with a different query shape: structured parameters, a country code, or a coarser rung of the fallback ladder.

Will a different geocoder find them?

Sometimes. Sample a hundred failures against a second provider: if it finds them, you have a reference-data gap; if it does not, the addresses are new, damaged, or not addressable.

How do I handle addresses that cannot be geocoded at all?

Label and count them. PO boxes, care-of addresses and informal addresses have no location to find, and reporting "3.7% is not geocodable" is a legitimate result.

Does adding the country code help?

Substantially. It removes cross-border candidates, speeds the query, and reduces both no-matches and wrong-country matches. It is usually the highest-yield single change.