How to Geocode Addresses in Python

On this page

Problem statement

You have a spreadsheet of addresses and you need points. The naive loop looks fine and is wrong in three ways at once:

from geopy.geocoders import Nominatim

geolocator = Nominatim(user_agent="my-app")
for address in addresses:
    location = geolocator.geocode(address)
    print(address, location.latitude, location.longitude)
  • It sends requests as fast as Python can produce them, which violates every public geocoder's usage policy and will get you blocked.
  • It raises AttributeError: 'NoneType' object has no attribute 'latitude' on the first address that does not match.
  • It uses geopy's default timeout of one second, so an ordinary slow response surfaces as GeocoderUnavailable — that happened on the first request from this site's test machine.
  • Worst of all, when it does return a coordinate, it may be the centre of the street, or the centre of the city, rather than the building — and there is nothing in location.latitude to tell you which.

That last one is the reason geocoded datasets are so often subtly wrong. A geocoder almost never says "I could not find it". It says "here is the best I could do", at whatever precision it managed.

Quick answer

Rate-limit, handle None, and check the precision of every match:

from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter

geolocator = Nominatim(user_agent=USER_AGENT, timeout=10)     # identify yourself; see the setup
geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1.1)

location = geocode("9999 Oldham Street, Manchester", addressdetails=True)
print(location.address)
print("place_rank:", location.raw["place_rank"], "| type:", location.raw["type"])
Oldham Street, Northern Quarter, City Centre, Manchester, Greater Manchester, England, M4 1LJ, United Kingdom
place_rank: 26 | type: tertiary

House number 9999 does not exist. Nominatim returned the street, silently, with a coordinate that looks exactly like a successful building match. place_rank is what exposes it:

place_rank Granularity
4–16 country, region, city
17–25 suburb, neighbourhood
26–27 street
28–30 building or address point

If you asked for an address and got rank 26, you got a street.

Nominatim place_rank values and the granularity each represents. every match returns a coordinate — rank says of whatcountryrank 4cityrank 16suburbrank 22streetrank 26buildingrank 30030Asked for an address and got rank 26? You got a street spatialworkflow.io
Every match returns a coordinate. Only place_rank says what the coordinate is the centre of.

A geocoder for your volume, proper rate limits

1. Pick a geocoder that matches your volume and licence

Geocoder Rate limit Licence of the results
Nominatim (OSM, free) 1 request/second, absolute ODbL
Pelias / Photon (self-hosted) your hardware depends on data loaded
Commercial (paid) thousands/second usually forbids storing coordinates

Nominatim is fine for a few thousand addresses over an evening. It is not fine for a million, and running it faster than one request per second gets your IP blocked. For bulk work, self-host — the software is open and a country extract fits on a laptop.

Read the terms on storage. Several commercial geocoders permit geocoding but forbid keeping the coordinates. That turns your derived point layer into something you may not redistribute.

2. Rate-limit properly, and never with time.sleep in the loop

from geopy.extra.rate_limiter import RateLimiter

geocode = RateLimiter(
    geolocator.geocode,
    min_delay_seconds=1.1,          # Nominatim's policy is 1/sec — leave headroom
    max_retries=2,
    error_wait_seconds=5.0,
    swallow_exceptions=False,       # see failures instead of silently getting None
)

The user agent matters as much as the rate. Nominatim's policy asks every application to identify itself, and it enforces it: the placeholder my-project/1.0 (you@example.com) that appears in many tutorials, this one's earlier drafts included, gets GeocoderInsufficientPrivileges: Non-successful status code 403. Use your project's name and a real contact.

RateLimiter measures from the end of one call to the start of the next, so it stays correct when a request takes two seconds. A hand-rolled time.sleep(1) after a slow call sleeps for no reason and still exceeds the limit after a fast one.

swallow_exceptions=False is important. The default turns a timeout into None, which is indistinguishable from "no match" — so a network blip looks like a bad address.

3. Clean the addresses before sending them

Geocoders match text. Text that is inconsistent matches inconsistently:

import re

def normalise(address):
    address = re.sub(r"\s+", " ", str(address)).strip()
    address = re.sub(r"(?i)\b(flats?|apt|apartments?|units?|suites?)\b\s*[\w-]+,?\s*", "", address)
    address = re.sub(r"(?i)\bst\b\.?", "Street", address)
    return address


print(normalise("  Flat 3, 12  Oldham  st. , Manchester "))
12 Oldham Street , Manchester

Sub-building details (flat, apartment, unit) are the biggest single cause of failed matches: no geocoder has a point for "Flat 3", and its presence often prevents matching the building that does exist.

4. Always add the country

location = geocode("Main Street")
print(location.address)
Main Street, 315, Main Street, East End-Danforth, Beaches—East York, Toronto, Golden Horseshoe, Ontario, M4C 1L3, Canada

An unqualified street name matched a Main Street in Toronto — whichever of the world's Main Streets the ranking preferred. Use structured queries rather than free text where you can — they constrain the search instead of ranking it:

location = geocode({"street": "Main Street", "city": "Garforth", "country": "United Kingdom"})
print(location.address)
Main Street, Garforth, Leeds, West Yorkshire, England, LS25 1AF, United Kingdom

5. Verify the result before you keep it

Three checks, all cheap:

def acceptable(location, min_rank=28, expect_country="gb", bounds=None):
    if location is None:
        return False, "no match"
    raw = location.raw
    if int(raw.get("place_rank", 0)) < min_rank:
        return False, f"only rank {raw['place_rank']} ({raw.get('type')})"
    if expect_country and raw.get("address", {}).get("country_code") != expect_country:
        return False, f"country {raw.get('address', {}).get('country_code')}"
    if bounds and not (bounds[0] <= location.longitude <= bounds[2]
                       and bounds[1] <= location.latitude <= bounds[3]):
        return False, "outside expected extent"
    return True, raw.get("type")

Precision, country, extent. Nearly every bad geocode fails at least one of them.

A request for a nonexistent house number falling back to the street centroid. "9999 Oldham Street" — no error anywhere9999 matches nohouse numberstreet + citystill score highlystreet returned,rank 26a valid coordinateof the wrong thingGood search behaviour, terrible pipeline behaviour spatialworkflow.io
The fallback is the danger. Both results are a valid coordinate pair; only one is the address you asked for.

A batch geocoder that records what it did

The data used on this page

Every call on this page goes to the public Nominatim server run by the OpenStreetMap Foundation, so the results are whatever OpenStreetMap held when the page was last run — the addresses are real Manchester and Leeds ones, and a couple are deliberately impossible. USER_AGENT identifies the application, as Nominatim's usage policy requires. Replace it with your own project's name and contact before you run anything, and keep to one request a second.

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

Example 1 — a batch geocoder that records what it did

import time
from pathlib import Path

import geopandas as gpd
import pandas as pd
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter

geolocator = Nominatim(user_agent=USER_AGENT, timeout=10)
geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1.1,
                      max_retries=2, error_wait_seconds=5.0, swallow_exceptions=False)

MIN_RANK = 28          # building-level; 26 would accept a street centroid


def geocode_frame(df, column, *, country_codes="gb", min_rank=MIN_RANK):
    records = []
    for i, address in enumerate(df[column], 1):
        try:
            loc = geocode(address, country_codes=country_codes, addressdetails=True)
        except Exception as exc:                       # network, timeout, 429
            records.append({"query": address, "status": f"error: {type(exc).__name__}"})
            continue

        if loc is None:
            records.append({"query": address, "status": "no match"})
            continue

        rank = int(loc.raw.get("place_rank", 0))
        records.append({
            "query": address,
            "matched": loc.address,
            "lat": loc.latitude,
            "lon": loc.longitude,
            "place_rank": rank,
            "match_type": loc.raw.get("type"),
            "status": "ok" if rank >= min_rank else f"imprecise (rank {rank})",
        })
        if i % 25 == 0:
            print(f"  {i}/{len(df)}")

    out = pd.DataFrame(records)
    print(out["status"].value_counts().to_string())
    return out


addresses = pd.DataFrame({"address": [
    "113 Deansgate, Manchester",
    "9999 Oldham Street, Manchester",
    "1 High Street, Manchester",
    "Nowhere Street 999, Atlantis",
]})
result = geocode_frame(addresses, "address")
print(result[["query", "place_rank", "match_type", "status"]].to_string(index=False))
status
ok                     2
imprecise (rank 26)    1
no match               1
                         query  place_rank match_type              status
     113 Deansgate, Manchester        30.0       cafe                  ok
9999 Oldham Street, Manchester        26.0   tertiary imprecise (rank 26)
     1 High Street, Manchester        30.0      house                  ok
  Nowhere Street 999, Atlantis         NaN        NaN            no match

Four addresses, four different outcomes, all visible. The status column is the deliverable — a geocoding run that reports only coordinates has thrown away the information you need to judge them.

The query is free text restricted with country_codes="gb", not a structured query, because the addresses arrive as one string. An earlier version of this function passed each whole address as the structured street field — {"street": "113 Deansgate, Manchester", "country": "United Kingdom"} — and three of the four came back as "no match", the real ones included. A structured query only helps when the parts really are separate.

Four addresses sent to Nominatim and what came back: two building matches, a street-level fallback and no match. four addresses, four outcomes worth keeping apartaddressplace_rankmatchedstatus113 Deansgate, Manchester30a cafeok9999 Oldham Street,Manchester26the streetimprecise1 High Street, Manchester30a houseokNowhere Street 999,Atlantis—nothingno matchthe second row has coordinates like any other — only place_rank says it is a streetKeep the status column; it is the part of a geocoding run you can audit spatialworkflow.io
What Example 1 got back from Nominatim when this page was last run.

Example 2 — turning results into a GeoDataFrame, with the rejects kept

def to_geodataframe(result, *, min_rank=MIN_RANK, crs="EPSG:4326"):
    good = result[result["status"] == "ok"].copy()
    bad = result[result["status"] != "ok"].copy()

    gdf = gpd.GeoDataFrame(
        good,
        geometry=gpd.points_from_xy(good["lon"], good["lat"]),
        crs=crs,
    )
    print(f"{len(gdf)} geocoded, {len(bad)} held back")
    return gdf, bad


points, rejects = to_geodataframe(result)
Path("data").mkdir(exist_ok=True)
rejects.to_csv("data/geocode_rejects.csv", index=False)
print(points[["query", "match_type"]].to_string(index=False))
2 geocoded, 2 held back
                    query match_type
113 Deansgate, Manchester       cafe
1 High Street, Manchester      house

Writing the rejects to their own file is what makes the run auditable. Silently dropping them produces a point layer whose count nobody can reconcile with the source spreadsheet.

match_type: cafe on the first row is worth noticing — the geocoder matched a café at that address rather than the building. It is the right coordinate, arrived at sideways, and it is the kind of thing you only see if you keep the field.

Example 3 — caching, so a rerun costs nothing

import json
import sqlite3


class GeocodeCache:
    """A tiny persistent cache — geocoding the same address twice is pure waste."""

    def __init__(self, path="data/geocode_cache.sqlite"):
        Path(path).parent.mkdir(parents=True, exist_ok=True)
        self.db = sqlite3.connect(path)
        self.db.execute("CREATE TABLE IF NOT EXISTS cache (q TEXT PRIMARY KEY, raw TEXT)")

    def get(self, query):
        row = self.db.execute("SELECT raw FROM cache WHERE q = ?", (query,)).fetchone()
        return json.loads(row[0]) if row else None

    def put(self, query, raw):
        self.db.execute("INSERT OR REPLACE INTO cache VALUES (?, ?)",
                        (query, json.dumps(raw)))
        self.db.commit()


cache = GeocodeCache()


def geocode_cached(address, country_codes="gb"):
    key = f"{address}|{country_codes}"
    hit = cache.get(key)
    if hit is not None:
        return hit
    loc = geocode(address, country_codes=country_codes, addressdetails=True)
    raw = loc.raw if loc else {}
    cache.put(key, raw)
    return raw


start = time.perf_counter()
for _ in range(3):
    geocode_cached("113 Deansgate, Manchester")
print(f"three lookups in {time.perf_counter() - start:.2f}s")
three lookups in 1.09s

One network call, two cache hits. At one request per second, caching is not an optimisation — it is what makes iterating on a 5,000-row file possible at all. Store the whole raw dict, not just the coordinates: place_rank and type are exactly what you will want when you revisit a questionable match.

Geocoders return something rather than nothing

Why geocoders return something rather than nothing

A geocoder is a search engine over addresses, not a lookup table. It tokenises your string, matches what it can, and ranks candidates. If "9999" matches no house number on Oldham Street, the street itself is still an excellent match for the rest of the query, so it wins.

From the geocoder's perspective this is correct behaviour — it found the best interpretation of your text. From yours it is a silent precision downgrade, and the only defence is to check the granularity of what came back rather than the fact that something did.

Why place_rank beats importance

Both fields are in the response and they measure different things:

  • place_rank is granularity — how specific the matched object is. Deterministic, comparable, and exactly what you want.
  • importance is prominence — how notable the place is, derived partly from Wikipedia. It is what breaks ties between candidates.

A famous city has high importance and low rank. An anonymous house has none and rank 30. Filtering on importance therefore rejects precisely the matches you wanted:

for q in ["1 High Street, Manchester, UK", "Manchester"]:
    r = geocode(q, addressdetails=True).raw
    print(f"{q!r:<34} rank={r['place_rank']:<3} importance={float(r.get('importance', 0)):.3f}  {r['type']}")
'1 High Street, Manchester, UK'    rank=30  importance=0.000  house
'Manchester'                       rank=16  importance=0.740  administrative

That second row is a city — a perfectly good match for the wrong question.

Why the country matters so much

Without a country, the search is global and ranking decides. "Main Street" resolves to Toronto because that candidate scored highest, not because Toronto is meant. Adding a country turns ranking into filtering, which is a categorically stronger constraint.

A structured query ({"street": …, "city": …, "country": …}) goes further: each component constrains a different field rather than contributing tokens to one blob of text. Where your source data has separate columns, keep them separate.

Four gates in a geocoding pipeline, ending with a reject file rather than a silent drop. the reject file is the deliverable1normalise — strip Flat, Apt, c/o, PO Boxtext first2rate-limit at 1 request per secondand cache3check place_rank, country and extentthree tests4write failures to their own filewith the reasonA run that outputs only successes cannot be reconciled spatialworkflow.io
The reject file is the point. A run that outputs only successes cannot be reconciled with its input.

Why to geocode once and store the result

Addresses do not move. Once an address has been geocoded and checked, that answer is good until the address itself changes.

Treat the geocoded coordinate as data with provenance — the query, the matched string, the rank, the date — and never re-derive it as part of a routine pipeline run. That is also the answer to the rate limit: the constraint stops mattering once you only geocode what is new. It is the same argument as caching any downloaded GIS data.

User agents and swallowed exceptions

  • user_agent must identify you. Nominatim blocks the default and generic values. Use your project name and a contact address.
  • swallow_exceptions defaults to True in RateLimiter, turning network errors into None. Set it to False so a blip is not mistaken for a bad address.
  • Sub-building details prevent matches. Strip "Flat", "Apt", "Unit" and similar before sending.
  • Postcodes geocode to a centroid, which for a rural postcode can be hundreds of metres from any building. Rank tells you (postcodes are typically rank 21–25).
  • Reverse geocoding has the same trap. reverse((53.4808, -2.2426)) returns whatever is nearest, which may be a bar rather than the building you meant.
  • Results are derived data. Nominatim results are ODbL; several commercial geocoders forbid storing coordinates at all. Check before building a point layer you intend to keep.
  • A geocoded point is not the same as an address point dataset. Where an official address gazetteer exists, joining to it beats geocoding, every time.

FAQ

Why does location.latitude raise AttributeError?

Because geocode() returned None — no match. Check for None before touching the attributes, and log the query that failed.

How fast can I geocode with Nominatim?

One request per second, as an absolute maximum, per their usage policy. Use RateLimiter(min_delay_seconds=1.1) and cache results. For bulk work, self-host.

How do I know if a coordinate is the building or the street?

Check place_rank in the raw response. 28–30 is building level, 26–27 is a street, below 26 is a settlement or larger.

Why did an address with a nonexistent house number still return a coordinate?

The geocoder fell back to the street, which matched the rest of your query well. This is normal behaviour and is why the precision check is not optional.

Should I use free text or a structured query?

Structured, whenever your data has separate columns. It constrains each field rather than ranking one blob of text, and it is far less likely to match another country.

Can I store geocoded coordinates?

Depends on the provider. Nominatim results are ODbL, so share-alike applies to redistributed data. Several commercial geocoders forbid storing coordinates entirely — check before you build a layer.

What should I do with addresses that fail?

Write them to their own file with the reason. They are the input to a manual pass, and their count is what makes the output reconcilable with the source.