To geocode a list of addresses, you send every row through a geocoding service in a loop, keep the latitude and longitude it returns along with the match quality, and write the results back into your file. Doing it by hand dies at about thirty rows. Doing it properly takes an afternoon of setup and then runs unattended on 100,000 rows.
That is batch geocoding, and it is the step most newsroom data projects skip. A flat address column is useless for spatial analysis until it has coordinates attached to it. Once it does, you can plot locations, join census and district data, sort by distance, and spot whether a pattern is real or just a reporting artefact.
Table of Contents
- What You Need
- How to Geocode a List of Addresses Step by Step
- Common Mistakes
- Frequently Asked Questions
- What is the difference between geocoding and reverse geocoding?
- What is the best address format for accurate geocoding results?
- How do I geocode addresses from different countries?
- Why do my latitude and longitude coordinates appear in the wrong place?
- Should I use a hosted geocoding API or a self-hosted service?
- Conclusion
What You Need
Four things. A Python 3.10 or newer environment, the pandas library for reading and writing your file, a geocoding provider with an API key, and your input as CSV or GeoJSON. Everything else is optional.
Free versus paid is the first decision, and the answer depends on list size rather than enthusiasm. Nominatim, the OpenStreetMap-backed service, allows one request per second with no key, which is free forever but slow for bulk work. Google, HERE, Mapbox, Bing and Esri all hand out a small free credit tier and bill per request after that, so a one-off job of a few thousand addresses usually stays inside the free allowance.
Forward geocoding turns text into coordinates. Reverse geocoding turns a pair of coordinates back into text, which you need when a source file has been mangled or when you are confirming that a pin landed on the building you expected.
One caution before you start. A coordinate is a statement about what the provider’s database contains, not proof that a place exists, is occupied, or is still there. Geocoders happily return a rooftop match for a building demolished two years ago, and they will confidently place a small village in the wrong county. Treat coordinates as a strong hint you should audit, not a fact you can publish unchecked.
How to Geocode a List of Addresses Step by Step
The same seven-stage workflow works with Nominatim, Google Maps Platform, Mapbox, HERE or Esri’s World Geocoder. What changes between providers is the endpoint, the parameter names, the response fields, the rate limit and the commercial-use terms, so read step three properly before you write anything.
1. Prepare and Inspect the Address List

Load the file into a DataFrame and give every row a stable identifier before you touch anything else. That identifier is how you keep the geocoded result attached to the right record three steps later.
import pandas as pd
df = pd.read_csv("addresses.csv", dtype=str, keep_default_na=False)
df.columns = [c.strip().lower() for c in df.columns]
df["source_id"] = df.index.astype(str)
print(df["address"].isna().sum(), "blank addresses")
print(len(df) - df["full_address"].nunique(), "duplicate strings")
Where your file already has separate street, city, postal code and country columns, keep them separate. Structured fields geocode far better than one concatenated string because the provider can weight them properly.
df["full_address"] = (
df["address"].str.strip()
+ ", " + df["city"].str.strip()
+ ", " + df["state"].str.strip()
+ " " + df["postal"].str.strip()
+ ", " + df["country"].str.strip()
)
Read postal codes as text, not numbers. That one setting stops Excel turning 02108 into 2108 and sends the whole file to the wrong part of the country.
2. Validate and Standardize Each Address
Validate before you spend a single request. Trim whitespace, collapse double spaces, standardize country to a two-letter code, and flag rows missing a street or a city so you can deal with them separately instead of watching them fail one by one.
df["full_address"] = df["full_address"].str.replace(r"s+", " ", regex=True)
df["country"] = df["country"].str.strip().str.upper()
bad = df[df["address"].str.strip() == ""]
print(len(bad), "rows need a street before geocoding")
Normalize formatting but do not silently correct spelling. Geocoders do a better job of fuzzy matching than any regular expression you will write, and quietly editing a street name gives you a coordinate that no longer matches the address you published.
Local formatting rules differ so much across countries that assuming them is a reliable way to produce bad data. Japanese addresses run block and house numbers before the street, many European postal codes carry letters, and a US-style comma-separated string will do nothing useful in a file that mixes regions. Split by country, or geocode each region in its own pass.
3. Choose a Geocoding Provider and Test a Sample
Judge a provider on coverage in your actual study area, not on its marketing. Then look at match quality, cost per request, requests per second, commercial-use terms, privacy policy and bulk-processing rules. Nominatim is free and open but asks for strict rate limiting and a real user agent string. Google, HERE, Mapbox, Bing and Esri are faster and better documented, each with its own credit model.
Test ten addresses from your file before the full run. Spread them across different cities, rural areas and edge cases rather than picking ten from one postcode.
from geopy.geocoders import Nominatim
geo = Nominatim(user_agent="newsroom-geocoder", timeout=10)
for addr in sample_addresses:
result = geo.geocode(addr)
if result is None:
print(addr, "no result")
else:
print(result.latitude, result.longitude, result.raw.get("type"), result.raw.get("class"))
Read the match type, not just the coordinates. A result typed as a city when you sent a street address is not a match, however plausible the pin looks on the map. Country bias is the classic failure: send a South African address without a country and the first candidate often sits in Arizona.
4. Send Requests in Small Batches
Pick a batch size on purpose and put a delay between requests. A tight loop against a shared endpoint is rude at best and throttled at worst, and the resulting failures look exactly like genuine no-results.
import os, time, json
import pandas as pd
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter
geolocator = Nominatim(user_agent="newsroom-geocoder", timeout=10)
geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1, max_retries=3)
results = []
for chunk_start in range(0, len(df), 200):
chunk = df.iloc[chunk_start:chunk_start + 200]
for row in chunk.itertuples():
hit = geocode(row.full_address)
results.append({
"source_id": row.source_id,
"full_address": row.full_address,
"lat": getattr(hit, "latitude", None),
"lon": getattr(hit, "longitude", None),
"match_type": (hit.raw or {}).get("type") if hit else None,
"retrieved_at": pd.Timestamp.utcnow().isoformat(),
})
time.sleep(2)
pd.DataFrame(results).to_csv("geocoded_partial.csv", index=False)
Read the key and any endpoint configuration from the environment rather than pasting a secret into the script. If you use a hosted provider instead of Nominatim, swap the endpoint and parameters, and expect a different response shape: Google returns results with geometry and a location type, Esri’s findAddressCandidates returns candidates with score and address components, Mapbox returns a GeoJSON feature collection.
The checkpoint write at the end of each chunk is not decoration. On a long run something will fail at 2am, and without checkpoints you restart the whole job and pay for it twice.
5. Handle Errors, Ambiguity, and No Results
Not every failure means the same thing, and treating them the same wastes your quota. A transport error is worth retrying. A rate limit means slow down with exponential backoff and a cap on attempts. An authentication failure means stop entirely, because retrying a bad key just burns time. A partial match, an ambiguous result with several candidates, and a genuine zero result are data problems, not network problems.
MAX_ATTEMPTS = 5
def lookup(address, geocoder):
for attempt in range(MAX_ATTEMPTS):
try:
return geocoder.geocode(address)
except Exception as exc:
if attempt == MAX_ATTEMPTS - 1:
print(address, "gave up:", type(exc).__name__)
return None
time.sleep(2 ** attempt)
Send low-confidence rows to a manual review list rather than retrying them. If a city-level match is your second attempt at the same string, a third attempt usually returns the same city-level match and costs you another request. Reviewing twenty uncertain rows by hand is cheaper than geocoding them ten times.
Match accuracy comes back as a tier, and the tiers mean different things. A rooftop match sits on a specific building, which is what you want for a store or a clinic. Range interpolation places the point somewhere along a street segment. Street and city-level matches are useful for coverage analysis but will not survive a reader pointing at a specific building.
| Match tier | What it means | Safe to use for |
|---|---|---|
| Rooftop | A specific building or parcel | Point-level mapping and site visits |
| Range interpolation | Somewhere along a street segment | Neighbourhood and corridor analysis |
| Street | The street segment itself | Rough density counts |
| City or postal code | The locality centroid | Coverage gaps and service-area questions |
6. Cache Results and Avoid Paying Twice
A cache is a lookup table keyed by the normalized address and the provider. Before every API call, check the cache. If the key is there, reuse the coordinates. Most real address lists are full of duplicates, and deduplicating before you start often removes 10 to 30 percent of the billable requests before a single call goes out.
import json, os
CACHE_PATH = "geocode_cache.json"
cache = json.load(open(CACHE_PATH)) if os.path.exists(CACHE_PATH) else {}
def key_for(row):
return row.full_address.strip().lower()
pending = [r for r in df.itertuples() if key_for(r) not in cache]
print(len(pending), "of", len(df), "rows need a live lookup")
Cache successful results and definitive no-results alike. A documented no-result is worth keeping so the next run does not re-request it, but keep it separate from successes so your final output does not fill with zeros you mistake for real coordinates.
Store the match quality, latitude, longitude and retrieval date alongside each cached row. Without the provider name and date, a cached result is a number nobody can audit six months later.
7. Export Coordinates for Maps and Data Analysis

Export both formats. CSV for spreadsheets and joins, GeoJSON for any mapping tool or web map.
out = pd.DataFrame(results)
out.to_csv("geocoded.csv", index=False)
features = [
{"type": "Feature",
"geometry": {"type": "Point", "coordinates": [r.lon, r.lat]},
"properties": {"source_id": r.source_id,
"address": r.full_address,
"match_type": r.match_type}}
for r in out.itertuples() if pd.notna(r.lat) and pd.notna(r.lon)
]
with open("geocoded.geojson", "w", encoding="utf-8") as fh:
json.dump({"type": "FeatureCollection", "features": features}, fh, ensure_ascii=False)
Note the coordinate order above. GeoJSON requires longitude first, then latitude. Swap them and your map loads without an error, which is why this mistake survives so long: every point lands somewhere real, just in the wrong place, and in the western hemisphere it scatters across an ocean.
Before publishing, check that latitude sits between -90 and 90, longitude between -180 and 180, that source IDs survived the join, and that the original address text is still in the file. Load the GeoJSON into any map and confirm the pins fall where you expect rather than in an ocean or on another continent.
Common Mistakes
Sending the whole list in a tight loop. You will hit throttling that looks like failed geocoding. Add a delay, shrink your batch size, and read the retry guidance your provider documents.
Mixing address formats in one pass. One file with US, UK and Japanese addresses geocoded by a single unfiltered call produces confidently wrong results in at least one region. Geocode by country, or pass the country filter the provider offers.
Accepting the first match. Geocoders return candidates ordered by their own confidence, and result one is not always the building you meant. Check the match type, and pass a region or country bias where the API allows it.
Ignoring coordinate order. Latitude first works in a CSV column and breaks GeoJSON. Longitude first is required by the GeoJSON specification, and the failure is silent.
Overwriting your source file. Write to a new file every time, always carrying source_id and the original address string. You cannot audit a coordinate you cannot trace back to a row.
Retrying permanent failures. A malformed address or a missing API key does not fix itself. Cap retries at a handful of attempts with exponential backoff, then park the row for manual review.
Ignoring the usage rules. Free tiers come with conditions. Nominatim’s public instance expects strict rate limiting and a descriptive user agent. Google requires geocoded results to be displayed on a Google map rather than stored and reused on your own, which matters enormously if you plan to publish a map. Check the terms before you build a workflow that depends on storing results.
Publishing imprecise locations without saying so. If half your rows are city-level matches, say that in the caption. Readers can forgive imprecision that is labelled and lose trust in imprecision that is hidden.
Two habits settle most of this. Always test a ten-address sample first, and always record which provider and which retrieval date produced each row. If a story needs to be re-run next year, those two fields are the difference between a reproducible update and a week of archaeology.
Frequently Asked Questions
What is the difference between geocoding and reverse geocoding?
Geocoding converts text into coordinates, so you go from an address to latitude and longitude. Reverse geocoding goes the other way, turning a latitude and longitude pair back into the nearest address or place name. You need forward geocoding to map an address list. You need reverse geocoding when the source data has no address at all, or when you want to confirm that a pin landed on the place you expected.
What is the best address format for accurate geocoding results?
Separate structured fields beat one concatenated string wherever your file allows it. Street, city, region, postal code and country sent as distinct parameters let the provider weight each part and apply the right formatting rules for that country. If you must use a single line, put it in the order your country actually uses, include the postal code, and end with the country name so the geocoder does not guess between the United States, the United Kingdom and Canada.
How do I geocode addresses from different countries?
Geocode each country separately rather than sending one mixed batch. Group the rows by country code, run a pass per group with that country passed as a region or country bias, and join the results back on your source ID. This avoids the first-match bias that puts a South African street in the United States and the formatting mismatches that quietly fail on postal codes with letters. Keep the country code column in your output so you can audit it later.
Why do my latitude and longitude coordinates appear in the wrong place?
Almost always coordinate order. GeoJSON requires longitude first and latitude second, so swapping them produces a valid-looking file where every point is mirrored across an axis and lands in the wrong ocean. The other common cause is a missing spatial reference: WGS84, which is EPSG:4326, is what nearly every web geocoder returns. Check the order in your export code, confirm the reference system, and plot three known points as a test before trusting the batch.
Should I use a hosted geocoding API or a self-hosted service?
Use a hosted API for anything you need done this week. You get broad coverage, current data, documented rate limits and a support path, and the free tiers cover most one-off lists of a few thousand addresses. A self-hosted or open instance such as Nominatim suits bulk work you control, gives you no per-request cost, and demands that you respect a one-request-per-second limit and run your own infrastructure. If results must never leave your organisation, that settles it before anything else does.
Conclusion
Start by profiling and validating the address list: count duplicates, check which rows are missing a street or a city, and confirm the postal codes survived as text. Then geocode a small sample spanning several cities and regions and read the match types before you commit to the full batch.
How to geocode a list of addresses properly means keeping the original row, the provider name, the match quality and the retrieval date together with every coordinate. Write the output to a new file each time, cache what you have already paid for, and export both CSV and GeoJSON so the next person can check your work instead of taking it on faith.


