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.latitudeto 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.
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.
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_rankis granularity β how specific the matched object is. Deterministic, comparable, and exactly what you want.importanceis 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.
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_agentmust identify you. Nominatim blocks the default and generic values. Use your project name and a contact address.swallow_exceptionsdefaults toTrueinRateLimiter, turning network errors intoNone. Set it toFalseso 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.
Internal links
- Geocoding returns wrong or missing coordinates β diagnosing the failures this article guards against
- GIS data sources explained β licence implications of derived coordinates
- How to cache downloaded GIS data so you fetch it once β the general form of the geocode cache
- How to read a CSV with coordinates as a GeoDataFrame β turning the output into geometry
- How to handle missing and null values in spatial datasets β what to do with the rejects
- A GIS data cleaning checklist for Python β where address cleaning sits in the wider process
- How to retry a flaky GIS step in Python β backoff when a geocoder rate-limits you
- Points plot in the ocean off Africa β the lat/lon swap that follows geocoding
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.