How to Geocode Addresses in Python

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.
  • 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="my-project/1.0 ([email protected])")
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, …
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 mapped to the granularity of the match, from country through city and street to individual building.
Every match returns a coordinate. Only `place_rank` says what the coordinate is the centre of.

Step-by-step solution

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
)

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(flat|apt|apartment|unit|suite)\s*\S+,?\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, Main Bus Terminal, East End-Danforth, Toronto, Ontario, Canada

An unqualified street name matched a bus terminal in Toronto. Use structured queries rather than free text where you can β€” they constrain the search instead of ranking it:

location = geocode({"street": "Oldham Street", "city": "Manchester", "country": "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 house number 9999 on a street returning the street centroid, indistinguishable in latitude and longitude from a real building match.
The fallback is the danger. Both results are a valid coordinate pair; only one is the address you asked for.

Code examples

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="spatialworkflow-example/1.0 ([email protected])")
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="United Kingdom", min_rank=MIN_RANK):
    records = []
    for i, address in enumerate(df[column], 1):
        query = {"street": address, "country": country}
        try:
            loc = geocode(query, 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                   1
imprecise (rank 26)  1
no match             2
                          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  semidetached_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.

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)
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  semidetached_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="United Kingdom"):
    key = f"{address}|{country}"
    hit = cache.get(key)
    if hit is not None:
        return hit
    loc = geocode({"street": address, "country": country}, 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.11s

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.

Explanation

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 zero importance and rank 30. Filtering on importance therefore rejects precisely the matches you wanted:

'1 High Street, Manchester, UK'  rank=30  importance=0.000  semidetached_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.

A geocoding pipeline with four gates: normalise the text, rate-limit the request, check precision, and route failures to a reject file.
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.

Edge cases or notes

  • 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.