My Geocoder Returns No Match for Addresses That Exist

On this page

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.

for query in ["10 Downng Stret, Londn", "1 Nonexistent Road, Nowheresville, UK"]:
    print(query, "->", nom.search(query))
10 Downng Stret, Londn -> []
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}


print(diagnose(nom, ["Flat 2", "14 High St", "Camden", "London", "NW1 8QP", "UK"])["failing_component"])
(dropped 0)    Flat 2, 14 High St, Camden, London, NW1 8QP, UK      no match
(dropped 1)    14 High St, Camden, London, NW1 8QP, UK              no match
(dropped 2)    Camden, London, NW1 8QP, UK                          1 hit(s)  <- first match
14 High St

The unit was not the problem; 14 High St was. Camden's high street is Camden High Street in OpenStreetMap, and there is no plain High St in NW1 8QP to find.

Bisecting an address by dropping components until something matches. drop components until it matches1Flat 2, 14 High St, Camden, NW1 8QPno match214 High St, Camden, NW1 8QPno match3Camden, London, NW1 8QP1 hitThe component removed last is the one that failed: here the street, which is Camden High Street spatialworkflow.io
Bisection turns "the geocoder is broken" into "this street is not in the reference data".

Confirm a no-match, check input, switch to structured queries

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 Camden High Street", city="London",
                     postalcode="NW1 8QP", country="United Kingdom")
print(hits[0]["display_name"] if hits else "no match")
Jinli, 14-16, Camden High Street, Fitzrovia, London Borough of Camden, Greater London, England, NW1 0JH, 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. Read what comes back, though: this one found 14โ€“16 Camden High Street in NW1 0JH, at the far end of the street from the NW1 8QP it was given, so the postcode narrowed nothing.

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: number + street + town + postcode   -> no match
attempt 2:          street + town + postcode   -> road      (accept, flag)
attempt 3:                   town + postcode   -> postcode
attempt 4:                          postcode   -> postcode  (last resort)

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.

Five causes of an empty geocoding result and the fix for each. five causes, five different fixeswhy it returned nothingwhat to doempty or two-token addressfilter before sendingno country, name exists worldwideadd countrycodesfields in the wrong columnsfix the mapping โ€” a bulk fixmojibake in the textrepair the encoding firsthouse number absent from the datafall back to street, record thelevelFailures cluster: sort the no-matches by town and source and the cause usually appears at once spatialworkflow.io
Fixing a cluster is one rule; fixing rows one at a time is a week.

Classifying failures and a fallback ladder

The data used on this page

The live calls go to the public Nominatim and Photon servers, so they return whatever OpenStreetMap held when the page was last run; USER_AGENT identifies the application, as both services ask โ€” replace it with your own before you run anything, and keep to one request a second. The addresses are a fixed hundred โ€” chosen by a hash of each premises' id, so the register's weekly changes barely move them โ€” of the 4,293 food premises on the Food Standards Agency's register for Camden. Responses are cached in nominatim_cache.json, so a second run sends no requests.

import json
import os
import time

import pandas as pd
import requests

USER_AGENT = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"

if not os.path.exists("camden_premises.csv"):
    # the FSA register file as saved on 9 October 2026 (updated daily); live:
    # ratings.food.gov.uk/api/open-data-files/FHRS506en-GB.json
    r = requests.get("https://data.spatialworkflow.io/fhrs-506-2026-10-09.json", timeout=300)
    r.raise_for_status()
    found = r.json()["FHRSEstablishment"]["EstablishmentCollection"]
    rows = []
    for e in (found if isinstance(found, list) else found["EstablishmentDetail"]):
        lines = [(e.get(f"AddressLine{i}") or "").strip() for i in (1, 2, 3, 4)]
        numbered = next((l for l in lines if l[:1].isdigit()), "")
        rows.append({"id": e["FHRSID"], "postcode": e.get("PostCode") or "",
                     "street_line": numbered,
                     "address": ", ".join([l for l in lines if l] + [e.get("PostCode") or ""]).strip(", ")})
    pd.DataFrame(rows).to_csv("camden_premises.csv", index=False)


class Nominatim:
    """The polite client from the Nominatim guide, with a JSON cache so re-runs cost nothing."""

    def __init__(self, user_agent, cache="nominatim_cache.json", min_interval=1.1):
        self.session, self.min_interval, self._last, self.path = requests.Session(), min_interval, 0.0, cache
        self.session.headers["User-Agent"] = user_agent
        self.cache = json.load(open(cache)) if os.path.exists(cache) else {}

    def search(self, query=None, **structured):
        params = {"format": "jsonv2", "limit": 1, **({"q": query} if query else structured)}
        key = json.dumps(params, sort_keys=True)
        if key not in self.cache:
            time.sleep(max(0.0, self.min_interval - (time.monotonic() - self._last)))
            self._last = time.monotonic()
            r = self.session.get("https://nominatim.openstreetmap.org/search", params=params, timeout=30)
            r.raise_for_status()
            self.cache[key] = r.json()
            json.dump(self.cache, open(self.path, "w"))
        return self.cache[key]


nom = client = Nominatim(USER_AGENT)
premises = pd.read_csv("camden_premises.csv", dtype=str).fillna("")
print(f"{len(premises):,} Camden food premises")
4,293 Camden food premises

Example 1 โ€” classifying the failures

import hashlib
from collections import Counter

import pandas as pd


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

# a fixed 100: the premises whose ids hash lowest, so the register's weekly changes barely move the sample
sample = (premises.assign(key=premises["id"].map(lambda i: hashlib.sha256(str(i).encode()).hexdigest()))
          .sort_values("key").head(100).drop(columns="key").reset_index(drop=True))
hits = [client.search(a + ", UK") if a.strip() else [] for a in sample["address"]]
sample["lat"] = [float(h[0]["lat"]) if h else None for h in hits]
sample["precision"] = [h[0].get("addresstype") if h else None for h in hits]
failures = classify_failures(sample)
print("\nmatched at:", sample["precision"].value_counts().to_dict())
33 failures of 100 rows (33.0%)
  plausible โ€” reference data gap or typo            29
  empty                                              4

matched at: {'road': 25, 'amenity': 20, 'office': 7, 'building': 7, 'place': 3, 'shop': 2, 'tourism': 2, 'retail': 1}

The last category is the one that needs a person; everything above it is a data problem with a bulk fix. On a random hundred of Camden's food premises, almost every failure is in that last category: real addresses, typed by a council officer, that the free-text search did not resolve.

Where a fixed sample of 100 Camden food premises ended up when geocoded with Nominatim. 100 Camden addresses, one polite request eachmatched as free text67matched once structured14street and town only2postcode only13empty address4050100Sent as structured parameters, 14 of the 33 failures found an address-level hit spatialworkflow.io
Structuring the same fields turned fourteen failures into address-level hits, which still need checking.

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

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


def build_ladder(parsed: dict) -> list[tuple[str, dict]]:
    """Progressively coarser structured queries, each labelled with what it will return."""
    number, street = parsed.get("house_number"), parsed.get("street")
    place = {k: parsed[k] for k in ("city", "postalcode", "country") if parsed.get(k)}
    rungs = []
    if number and street:
        rungs.append(("full", {"street": f"{number} {street}", **place}))
    if street:
        rungs.append(("street_town", {"street": street, **place}))
    rungs.append(("town_postcode", place))
    if parsed.get("postalcode"):
        rungs.append(("postcode", {"postalcode": parsed["postalcode"], "country": parsed.get("country", "")}))
    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}


widened = []
for row in failures[failures.reason.str.startswith("plausible")].itertuples():
    number, _, street = row.street_line.partition(" ")
    parsed = {"house_number": number, "street": street, "city": "London",
              "postalcode": row.postcode, "country": "United Kingdom"}
    result = geocode_with_ladder(client, parsed)
    widened.append((result["widened_to"], result["accepted"]))
print(pd.Series(widened).value_counts().to_string())
(full, True)           14
(postcode, False)      13
(street_town, True)     2

accepted is the field that keeps the ladder honest. Eleven of the free-text failures matched at full address as soon as the same fields were sent structured โ€” the parsing stage was the whole problem. Most of the rest only reached the postcode, and a row that reached 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

class Photon:
    """Komoot's public Photon geocoder: the same OpenStreetMap data behind a different search engine."""

    def search(self, q):
        time.sleep(1.0)
        r = requests.get("https://photon.komoot.io/api/", params={"q": q, "limit": 1},
                         headers={"User-Agent": USER_AGENT}, timeout=30)
        r.raise_for_status()
        return [{"addresstype": {"house": "house", "street": "road"}.get(f["properties"].get("type"), "coarse"),
                 "display_name": ", ".join(str(f["properties"][k]) for k in ("housenumber", "street", "postcode")
                                           if f["properties"].get(k))}
                for f in r.json()["features"]]


import random

random.seed(3)
opinion = second_opinion(failures[failures.reason.str.startswith("plausible")], Photon())
print("Photon for 14 High St, Camden:", Photon().search("14 High St, Camden, London, NW1 8QP")[0]["display_name"])
of 29 sampled failures:
   19 (  66%) found at address level elsewhere
    0 (   0%) found only at a coarse level
   10 (  34%) not found anywhere
Photon for 14 High St, Camden: 178-182, Camden High Street, NW1 8QP

Photon searches the same OpenStreetMap data with a different engine, so this measures how much of the failure was the query rather than the data. It returned an address-level answer for most of the rows โ€” but an answer is not a match: for 14 High St, Camden it returns 178โ€“182 Camden High Street, the right postcode and the wrong building. A second provider settles whether the data exists; whether its answer is right still needs a check.

An empty result is a successful request

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: "10 of these 100 Camden premises were found by neither geocoder" is a finding.

A deliberate fallback ladder that records the level it stopped at, with where 29 Camden failures landed. widen deliberately, and write down how farnumber + street + town + postcodeattempt 1address: 14 of 29street + town + postcodeattempt 2street: 2 โ€” accept, flagtown + postcodeattempt 3locality: 0postcode onlyattempt 4postcode: 13, not for distancesA ladder is only honest if the rung you landed on is stored with the coordinate spatialworkflow.io
A widened match that looks like a full match is the failure the precision field exists to prevent.

Result lengths and single-result limits

  • 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 "10 in 100 have no findable address" 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.