Geocoding Explained: From an Address String to a Coordinate

Problem statement

Geocoding looks like a lookup: you send "10 Downing Street, London" and you get back 51.5035, -0.1277. That framing is what causes most geocoding bugs, because a geocoder is not a dictionary. It is a search engine that ranks candidates and returns the best one it found, and "the best one it found" can be a building, a street, a postcode, a city or a country โ€” with nothing in the coordinate pair to tell you which.

Asked the same five questions about the same place, a public geocoder returned five different answers, all of them correct:

query                            type        bbox span   distance from the door
10 Downing Street, London, UK    office            55 m            0 m
Downing Street, London, UK       road              97 m           50 m
SW1A 2AA, UK                     postcode       1,975 m            0 m
London, UK                       city          73,804 m          440 m
United Kingdom                   country    1,615,489 m      413,077 m

The last row is the one that ruins analyses. A coordinate 413 km from the address is not an error the geocoder reports; it is the honest answer to a vaguer question than you meant to ask.

Quick answer

Treat every geocode as three values, not one: a coordinate, a precision level, and a match confidence. Reject or flag anything below the precision your analysis needs.

import requests

UA = {"User-Agent": "my-project/1.0 ([email protected])"}

def geocode(query, min_precision=("house", "building", "office", "road")):
    """One address in, one annotated result out โ€” or None."""
    r = requests.get(
        "https://nominatim.openstreetmap.org/search",
        params={"q": query, "format": "jsonv2", "addressdetails": 1, "limit": 1},
        headers=UA, timeout=30,
    )
    r.raise_for_status()
    hits = r.json()
    if not hits:                       # HTTP 200 with an empty list is the "no match" signal
        return None
    top = hits[0]
    return {
        "lat": float(top["lat"]),
        "lon": float(top["lon"]),
        "precision": top.get("addresstype"),
        "accepted": top.get("addresstype") in min_precision,
        "matched_text": top["display_name"],
    }
>>> geocode("10 Downing Street, London, UK")
{'lat': 51.5034878, 'lon': -0.1276965, 'precision': 'office',
 'accepted': True, 'matched_text': '10 Downing Street, 10, Downing Street, โ€ฆ'}

>>> geocode("10 Downng Stret, Londn")
None
Bar chart of matched feature size from 55 m for a building to 1,615 km for a country.
Every one of these is a valid coordinate pair with six decimal places.

Step-by-step solution

1. Understand what the geocoder is actually doing

Every geocoder, commercial or open, runs the same four stages:

  1. Parse the string into components โ€” house number, street, locality, region, postcode, country. This is guesswork on free text, and it is where most of the failure lives.
  2. Normalise each component โ€” case, accents, punctuation, abbreviations (St for both Street and Saint).
  3. Search an index of reference data for candidates that match some of the components.
  4. Rank the candidates and return the best few, usually with a score.

Nothing in that pipeline requires a full match. A geocoder that finds only the country is still successful by its own definition, and it will happily return a country centroid.

2. Know which reference data is underneath

The coordinate you get back is a property of the geocoder's reference database, not of the world:

  • Address point files (national address registers, OpenAddresses) give a coordinate per building. Best precision, patchy coverage.
  • Street centrelines with address ranges interpolate a position along the segment. Typically 10โ€“100 m out, and systematically wrong on long rural roads.
  • Parcels or building footprints give a centroid inside the property.
  • Administrative and postal areas give a representative point for an area, which is what a "postcode geocode" usually is.

Two geocoders disagreeing by 30 m is normal and means nothing. Two geocoders disagreeing by 30 km means one of them fell back a level.

3. Ask for the precision level, and act on it

Every geocoder exposes precision under a different name: addresstype in Nominatim, location_type in Google's API, match_codes and confidence elsewhere. Whatever it is called, it is the single most useful field in the response, and dropping it is how a table of coordinates loses the information that half of them are town centres.

Keep the raw response. It costs a text column and it is the only way to answer "why is this point in a field?" three months later.

4. Expect ambiguity, because place names are not unique

Names repeat, heavily. In the GeoNames gazetteer of 5,226,942 populated places there are 3,026,188 distinct names, and 132,528 of those names occur in more than one country. San Antonio names 2,382 separate places across 25 countries. Springfield names 68 places in the United States alone.

A single-result geocode of "Springfield, USA" returns Springfield, Illinois โ€” not because it is the right one, but because it scores highest on population and prominence. Ask for limit=3 and look at the gap between the top two scores: a small gap means the geocoder guessed.

5. Validate against something you already trust

The cheapest and strongest check is a spatial one: you usually know which region each record should fall in. Join the geocoded points to that boundary layer and count the ones that land outside it. That single check catches country centroids, transposed coordinates and matches in the wrong country in one pass.

Four-stage flow: parse, normalise, search, rank, ending in a single returned candidate.
The ranking step always has a top element, which is why "no match" has to be a deliberate choice.

Code examples

Example 1 โ€” a geocode result worth storing

from dataclasses import dataclass, asdict
import json


@dataclass
class Geocode:
    query: str
    lat: float | None
    lon: float | None
    precision: str | None       # house | road | postcode | city | country | None
    score: float | None
    matched_text: str | None
    source: str
    raw: str                    # the untouched response, as JSON text


PRECISION_RANK = {
    "house": 0, "building": 0, "office": 0, "amenity": 0,
    "road": 1, "postcode": 2, "suburb": 3, "city": 4, "state": 5, "country": 6,
}


def rank(g: Geocode) -> int:
    """Lower is better. Unknown precision is treated as the worst."""
    return PRECISION_RANK.get(g.precision, 9)


def usable(g: Geocode, worst_allowed="road") -> bool:
    return g.lat is not None and rank(g) <= PRECISION_RANK[worst_allowed]

Storing raw looks wasteful until the first time somebody asks why a shop is in the sea. It is a few hundred bytes per row and it turns an argument into a lookup.

Example 2 โ€” how far apart the precision levels actually are

import math


def haversine_m(a, b, r=6371008.8):
    lat1, lon1 = map(math.radians, a)
    lat2, lon2 = map(math.radians, b)
    h = (math.sin((lat2 - lat1) / 2) ** 2
         + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2)
    return 2 * r * math.asin(math.sqrt(h))


door     = (51.5034878, -0.1276965)     # the address itself
street   = (51.5032088, -0.1271283)     # the road it is on
city     = (51.5074456, -0.1277653)     # "London"
country  = (54.7023545, -3.2765753)     # "United Kingdom"

for label, point in [("street", street), ("city", city), ("country", country)]:
    print(f"{label:8s} {haversine_m(door, point):10,.0f} m")
street           50 m
city            440 m
country     413,077 m

A 440 m error is invisible on a national map and fatal in a catchment analysis. A 413 km error is visible from space and still arrives as a valid-looking coordinate pair.

Example 3 โ€” flag the fallbacks before they reach the map

import geopandas as gpd


def flag_suspicious(points: gpd.GeoDataFrame, regions: gpd.GeoDataFrame,
                    region_col="region"):
    """Mark geocodes that fall outside the region their record claims."""
    joined = gpd.sjoin(
        points.to_crs(regions.crs), regions[[region_col, "geometry"]],
        how="left", predicate="within",
    )
    joined["region_matches"] = joined[f"{region_col}_left"] == joined[f"{region_col}_right"]

    # Anything landing on the same coordinate more than a handful of times is
    # almost always a centroid fallback rather than a real coincidence.
    counts = joined.geometry.apply(lambda g: (round(g.x, 5), round(g.y, 5))).value_counts()
    stacked = set(counts[counts > 5].index)
    joined["stacked_point"] = joined.geometry.apply(
        lambda g: (round(g.x, 5), round(g.y, 5)) in stacked
    )
    return joined

Two checks, both cheap, and between them they catch the great majority of geocoding disasters: the point is in the wrong region, or 4,000 points share one coordinate.

Explanation

Why a geocoder never says "I do not know"

A search engine's job is to rank, and a ranking always has a top element. Returning nothing is a decision the geocoder has to be configured to make โ€” some APIs expose a minimum-confidence parameter, most do not โ€” so the default behaviour is to return the best available candidate at whatever level it could match.

That is reasonable behaviour for a search box on a map website, where the user sees the result and can tell that it is wrong. It is dangerous behaviour in a batch job, where 40,000 rows go straight into a spatial join.

Why precision and confidence are different things

Precision is about the geometry: what kind of thing was matched, and how big is it. Confidence is about the text: how sure the geocoder is that this is the thing you meant.

They fail independently. A high-confidence country match is precise nonsense โ€” the geocoder is certain, and the coordinate is still 400 km from the address. A low-confidence house match is a specific building that may be the wrong one. Filtering on one and not the other leaves half the failure modes in place.

Why the same address geocodes differently every year

Reference data changes. Streets are renamed, buildings are demolished, postcodes are re-cut, and OpenStreetMap gains an address point where it previously interpolated one. A geocode is a measurement taken on a particular day against a particular database version.

If your results have to be reproducible, cache the responses and store the date and provider version alongside them. Re-geocoding a year later and getting different coordinates is not a bug you can fix; caching is the only defence.

Why forward and reverse geocoding are not inverses

Forward geocoding turns text into a point. Reverse geocoding turns a point into the nearest or containing feature. Round-tripping loses information both ways: geocode an address, reverse geocode the result, and you frequently get a different address string โ€” the one the reference database uses, which may be the neighbouring unit, the building rather than the flat, or a different transliteration of the same street.

Four stacked layers: coordinate, precision, confidence and raw response.
Two extra columns are the difference between a dataset you can audit and one you must re-geocode.

Edge cases or notes

  • HTTP 200 with an empty array is the normal "not found". Do not treat a non-200 as the only failure case.
  • Coordinate order is a permanent hazard. Most APIs return lat and lon as named fields; GeoJSON and most Python geometry constructors take (x, y) โ€” that is (lon, lat).
  • Rate limits are policy, not just capacity. The public Nominatim instance allows roughly one request per second and requires a real User-Agent; bulk work belongs on your own instance.
  • Licensing follows the data. Some providers forbid storing coordinates, or require the results to be shown on their map. Check before you build a database of them.
  • Postcode geocodes are areas. A UK postcode covers about 15 addresses; a US ZIP covers thousands and is not even a polygon by definition.
  • PO boxes and rural addresses have no location in the sense your analysis wants. Neither does "Industrial Estate, Unit 4".
  • Accents matter and then they do not. 22.5% of populated-place names in GeoNames contain non-ASCII characters, and stripping accents creates 47,607 name collisions inside a single country.
  • Never geocode twice. Cache by normalised query string; a rerun should cost nothing.

FAQ

What is geocoding?

Turning a description of a place โ€” an address, a postcode, a place name โ€” into a coordinate, by searching a reference database of known locations and ranking the candidates.

Why does my geocoder return a point in the middle of nowhere?

Almost always a fallback: it could not match the street or the town, so it returned the centroid of the largest thing it could match. Check the precision field in the response; it will say country or state rather than house.

Is geocoding accurate?

It is as accurate as the reference data and as specific as the match. A rooftop match against an address register is within a few metres. A postcode match is within a few hundred metres to a few kilometres. A country match is meaningless as a location.

How many addresses can I geocode for free?

On the public Nominatim service, roughly one per second with a valid User-Agent, and bulk geocoding is explicitly discouraged. For tens of thousands of addresses, run your own instance or use a commercial API with a paid tier.

Should I store the geocoded coordinates?

Yes โ€” and the precision, the score, the provider and the date. Some licences restrict storage, so check the terms; the cache is what makes the pipeline reproducible and cheap.

What is the difference between geocoding and reverse geocoding?

Forward geocoding goes from text to a point. Reverse geocoding goes from a point to the feature that contains or is nearest to it. They are not inverses, and round-tripping an address rarely returns the string you started with.