Geocoding in Python takes one call once you install Geopy: point it at Nominatim, the free OpenStreetMap geocoder, then call .geocode() to turn an address into latitude and longitude, or .reverse() to turn coordinates back into an address. No API key, no signup. The harder parts are deciding what to do when an address returns nothing, and running the same thing across ten thousand rows.
This guide starts with working Geopy code for both directions, plus the error handling most tutorials skip. Then it covers volume: Nominatim's public instance caps you at one request per second and puts strict conditions on bulk jobs, so a script that handles fifty addresses will stall on five thousand. Finally, it moves the same work to a dedicated geocoding API, in batches and in parallel.
Key Takeaways
- Geocoding in Python takes one function call. Geopy plus Nominatim needs no API key and no signup, and gives you both directions:
.geocode()for address to coordinates,.reverse()for coordinates to address. - Geopy is a client, not a geocoder. It holds no map data of its own. Nominatim does the work in the examples here, which means the rate limits you hit belong to Nominatim rather than to Geopy.
- Failures are silent by default. A no-match returns
Noneinstead of raising, and Geopy's default timeout is one second, so unguarded code breaks on the first bad row. - Nominatim's public instance allows one request per second. Smaller one-time bulk jobs are permitted if they run single-threaded on one machine and cache results, while scheduled or day-long scripts drop to 4 requests per minute, which turns 10,000 addresses into a 41-hour job.
- Self-hosting removes the cap but not the cost. A full planet import wants 128GB of RAM and 1TB of disk, plus ongoing updates.
- A dedicated API is the move once geocoding becomes a product feature. No per-second policy wall, no concurrency limit on our geocoding endpoints, and no infrastructure to maintain.
What Geocoding Means in Practice
Geocoding runs in two directions, and both matter when you're writing code against them.
Forward geocoding takes an address or place name and returns coordinates. You send "1600 Pennsylvania Ave NW, Washington, DC" and get back a latitude and longitude you can store, compare, or plot on a map. This is the direction most people mean when they say geocoding without qualifying it. It's what you need when addresses arrive as text from a form, a CSV export, or a database column, and your application has to do something spatial with them.
Reverse geocoding goes the other way. You send a coordinate pair and get back a readable address. This is what runs behind a mobile app that captures a GPS fix and shows the user a street name, or a delivery dashboard turning driver pings into locations a dispatcher can read. The input comes from a device rather than a person, which is why the output needs translating back into something human.
In Geopy both directions live on the same object, one method each, so the code below covers them together.
Geocoding in Python With Geopy and Nominatim
Before the first line of code, one fact explains almost everything that follows.
Geopy holds no addresses, no coordinates, no map data at all. It's a Python geocoding library in the client sense: a wrapper that formats your request, sends it to whichever service you configured, and parses the response into a normal Python object. Its documentation says this outright in a section called Geopy Is Not a Service, which notes that the geocoding services are not affiliated with geopy and that each one has its own terms of use, quotas, pricing, and geodatabases. The service doing the work below is Nominatim, the open-source geocoder built on OpenStreetMap data.
That split is why we start here. Nominatim's public instance needs no signup and no API key, which makes it the fastest way to get a working geocode in front of you. It also means the limits you hit later belong to Nominatim rather than to Geopy.
You'll also come across the geocoder package, another Python library for this job. It's a client in the same sense: geocoder.osm() sends your query to the same Nominatim server, under the same usage policy. This guide sticks with Geopy. Its latest release, 2.5.0, came out in July 2026, while the geocoder version pip installs, 1.38.1, dates from April 2018. geocoder.osm() also identifies itself with the requests library's stock User-Agent unless you set your own, and Nominatim's usage policy doesn't accept stock user agents.
Install and run your first forward geocode
pip install geopyNo key, no account, nothing else to configure.
# Forward geocode a street address into latitude and longitude
from geopy.geocoders import Nominatim
geolocator = Nominatim(user_agent="geocoding-demo/1.0")
location = geolocator.geocode("1600 Pennsylvania Ave NW, Washington, DC")
print(location.address)
print(location.latitude, location.longitude)
White House, 1600, Pennsylvania Avenue Northwest, Ward 2, Washington, District of Columbia, 20500, United States
38.8976387 -77.0365525
Four lines of real work. geocode() returns a Location object carrying the matched address, the coordinates, and the provider's raw payload. Notice the returned address is richer than what went in: Nominatim matched the query to a named place and filled in the ward, postcode, and country.
Now the user_agent argument, which is where most first attempts fail.
Nominatim's usage policy requires every request to carry either a valid HTTP Referer or a User-Agent identifying the application, and it states plainly that stock user agents set by HTTP libraries are not acceptable. Geopy enforces this on its own side rather than letting you find out from a 403. Open the Nominatim class in geopy's source and you'll find a hardcoded list of rejected strings: my-application, my_app/1, my_user_agent/1.0, specify_your_app_name_here, and geopy's own default. Those are the sample values that circulate in tutorials and docs, which is exactly why they're blocked. Pass one and geopy raises ConfigurationError before the request leaves your machine.
So pick something that identifies your application. A name and a version is the minimum, and adding a contact address is better. The point of the header is that the Nominatim operators can reach you or block you specifically, instead of blocking everyone who copied the same string.
Reverse geocoding with the same object
Reverse geocoding in Python uses the geolocator you already built. One method instead of another.
Reverse geocoding in Python uses the geolocator you already built. One method instead of another.
# Reverse geocode a coordinate pair back into a readable address
from geopy.geocoders import Nominatim
geolocator = Nominatim(user_agent="geocoding-demo/1.0")
location = geolocator.reverse("38.8977, -77.0365")
print(location.address)
White House, 1600, Pennsylvania Avenue Northwest, Ward 2, Washington, District of Columbia, 20500, United States
The query argument is flexible. A string works, and so does a plain tuple of floats, which is usually cleaner when the values are already in variables:
# Pass coordinates as a (latitude, longitude) tuple instead of a string
location = geolocator.reverse((38.8977, -77.0365))
print(location.address)
White House, 1600, Pennsylvania Avenue Northwest, Ward 2, Washington, District of Columbia, 20500, United States
A geopy.point.Point object works too. Whichever form you use, latitude comes first.The parsed .address is one formatted string, which is often not what you need. For the house number, postcode, or country code as separate fields, go to .raw, which holds the provider's untouched JSON:
# Inspect the full provider payload instead of the formatted address string
import json
print(json.dumps(location.raw, indent=2))
print(location.raw["address"]["postcode"])
{
"place_id": 347426922,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "relation",
"osm_id": 19761182,
"lat": "38.8976387",
"lon": "-77.0365525",
"class": "office",
"type": "government",
"place_rank": 30,
"importance": 0.6958339719610435,
"addresstype": "office",
"name": "White House",
"display_name": "White House, 1600, Pennsylvania Avenue Northwest, Ward 2, Washington, District of Columbia, 20500, United States",
"address": {
"office": "White House",
"house_number": "1600",
"road": "Pennsylvania Avenue Northwest",
"city_district": "Ward 2",
"city": "Washington",
"state": "District of Columbia",
"ISO3166-2-lvl4": "US-DC",
"postcode": "20500",
"country": "United States",
"country_code": "us"
},
"boundingbox": [
"38.8974904",
"38.8977959",
"-77.0368541",
"-77.0362509"
]
}
20500Reaching into raw["address"]["postcode"] works fine in a script. If the postcode is the only field you were after, the ZIP Code Finder by Address gets it in one step.
That payload shows how reverse geocoding works under the hood. Nominatim's documentation on how reverse geocoding works explains that the API does not compute an address for the coordinate you send. It finds the closest suitable OSM object and returns that object's address, which the response makes visible: "osm_type": "relation" with "addresstype": "office" and a name of White House. You asked about a point, and you got back the feature that point falls inside, along with its own lat and lon rather than yours.
Here that works out well. The coordinates are the White House, and the address block came back with house_number: 1600 and road: Pennsylvania Avenue Northwest, so the match is street-level and correct. It does not always land that cleanly. Nominatim's docs name two cases: objects unsuitable for searching, small unnamed paths for instance, are missing from the database and can never be returned by a reverse lookup, and in dense city areas the closest object may belong to a completely different street. That behavior comes from Nominatim, and Geopy can't change it. Check what you get for the regions you care about before assuming building-level precision.
Handle failed matches and timeouts
Every snippet so far assumes the request succeeded and the address matched. Neither is guaranteed, and both failures are quiet.
A failed match returns None instead of raising an error. This is documented behavior: when no results are found, geocode() and reverse() both return None. The next line reaching for .latitude then raises AttributeError: 'NoneType' object has no attribute 'latitude'. In a loop over a spreadsheet, that's one unmatched row killing a job that was otherwise fine
Requests time out, and sooner than you'd expect. Geopy's default_timeout is one second. The public Nominatim instance runs on donated servers with limited capacity, so a one second ceiling is optimistic. When a request exceeds the timeout you get GeocoderTimedOut, and when the service can't be reached at all you get GeocoderUnavailable. Both live in geopy.exc, alongside GeocoderRateLimited and the rest of geopy's documented exception list. Read it once so you know what each failure mode means.
Here's a version that survives both:
# Geocode an address with guards for no-match and network failure
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut, GeocoderUnavailable
geolocator = Nominatim(user_agent="geocoding-demo/1.0", timeout=10)
def geocode_address(address):
try:
location = geolocator.geocode(address)
except (GeocoderTimedOut, GeocoderUnavailable) as error:
print(f"Request failed for {address}: {error}")
return None
if location is None:
print(f"No match found for {address}")
return None
return location.latitude, location.longitude
print(geocode_address("1600 Pennsylvania Ave NW, Washington, DC"))
print(geocode_address("asdfghjkl nowhere street"))
(38.8976387, -77.0365525)
No match found for asdfghjkl nowhere street
None
The second call is the one that matters. A nonsense address produced a logged message and a None return instead of a traceback, and the script kept going
The timeout is raised well above the default, which is short for a shared public service. Transport failures are caught separately from match failures, since those mean different things and need different handling. And both paths return None rather than crashing, so the caller decides what an unresolved address means.
Notice what's still missing. This handles one address at a time and knows nothing about how fast it is allowed to run, which becomes the entire problem the moment you have more than a handful of rows. It also has no retries. Retries with backoff come later in this post, and the same patterns apply to any API you call; our guide API Error Handling: How to Build Integrations That Don't Break covers them in depth.
Batch Geocoding and Where the Free Path Breaks
One address at a time is fine. Past a few hundred, the bottleneck stops being your code and becomes the rules of the server you're calling.
The rules for the public Nominatim instance
Nominatim's usage policy sets an absolute maximum of one request per second on the public instance at nominatim.openstreetmap.org. Requests triggered directly by an end user, like someone submitting a search, are acceptable provided your number of users is moderate. The limit applies to your whole application, so the combined traffic from all your users has to stay under it. The policy also requires attribution wherever you display the results: credit OpenStreetMap, for example with "© OpenStreetMap contributors" linked to openstreetmap.org/copyright, the same credit you saw in the licence field of the raw payload earlier.
Bulk geocoding isn't banned outright, but the policy says plainly that bulk geocoding of larger amounts of data is not encouraged. Smaller one-time bulk tasks may be permitted if you follow four conditions: a single thread, one machine, no distributed scripts, and results cached on your side. Scripts that run longer than a day, or that run at regular intervals, are restricted to 4 requests per minute, and the policy counts periodic requests from apps as bulk geocoding too. What it forbids outright is systematic querying: reverse lookups across a grid of coordinates, or pulling complete lists of postcodes or towns
Do the arithmetic before you start a job. Ten thousand addresses at one request per second is 10,000 seconds, which is 2 hours and 47 minutes, assuming every request succeeds on the first try and your loop adds no overhead of its own. Whether a job that size counts as "smaller" is the operators' call, not yours. Run the same 10,000 addresses as a nightly sync and the 4 per minute cap applies: 2,500 minutes, or 41 hours and 40 minutes. A nightly job that takes almost two days to finish doesn't work.
These limits belong to the shared server, not to the software. Nominatim is open source, and anyone running their own instance sets their own terms, which is why commercial providers offer Nominatim-based geocoding on paid plans and Geopy supports a couple of them directly. On the public instance, the cap is enforced on the server side, and no client setting raises it.
Batch geocoding with RateLimiter
Geopy ships a tool for pacing requests. RateLimiter wraps a geocoding function and holds a minimum delay between calls, so your loop stays inside a provider's rate limit without you writing sleep logic.
# Geocode a list of addresses with a one second delay between requests
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter
geolocator = Nominatim(user_agent="geocoding-demo/1.0", timeout=10)
# min_delay_seconds=1 keeps the loop inside Nominatim's one request per second limit
geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1)
addresses = [
"1600 Pennsylvania Ave NW, Washington, DC",
"221B Baker Street, London",
"Eiffel Tower, Paris",
]
for address in addresses:
location = geocode(address)
if location:
print(address, "->", location.latitude, location.longitude)
else:
print(address, "-> no match")
It wraps reverse the same way, so the same pacing applies to reverse geocoding a list of coordinates.
min_delay_seconds is the inverse of a request rate. One second gives one request per second. For a provider that allows twenty per second, min_delay_seconds=1/20 is the equivalent.
Before you trust this in a job you care about, check how its defaults behave.
It adds no delay unless you ask. min_delay_seconds defaults to 0.0. Wrapping your geocoder and forgetting that argument changes nothing about your request rate.
It retries, but narrowly. max_retries defaults to 2 with error_wait_seconds=5.0 between attempts. Only GeocoderServiceError and its subclasses are retried. Anything else re-raises straight away.
It hides failures by default. swallow_exceptions defaults to True, so once the retries run out the error is absorbed and return_value_on_exception is returned instead, which is None. An address that failed and an address that has no match both come back as None, and you cannot tell them apart. If that distinction matters, set swallow_exceptions=False and catch the exceptions yourself.
There is also no durability here. RateLimiter paces and retries one call at a time. It saves nothing, so an interrupted run two hours into a three-hour job starts over from the first address.
Geopy's docs also tell you to check a service's terms before using the rate limiter, since some providers treat bulk requests as a violation even when throttled.
Geocoding a pandas DataFrame with Geopy
Most real address lists arrive as a spreadsheet or CSV export, so the natural home for them in Python is a pandas DataFrame. Geopy's docs show RateLimiter paired with df.apply(), and the version below adds one thing the usage policy requires for bulk work: each unique address is requested once and the result is cached.
# Geocode a DataFrame column with Nominatim: one thread, one request per second, one call per unique address
import pandas as pd
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter
geolocator = Nominatim(user_agent="geocoding-demo/1.0", timeout=10)
geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1)
df = pd.read_csv("addresses.csv").dropna(subset=["address"])
df["address"] = df["address"].str.strip()
# Cache: each unique address is requested once, as the usage policy requires for bulk jobs
cache = {address: geocode(address) for address in df["address"].unique()}
df["lat"] = df["address"].map(lambda a: cache[a].latitude if cache[a] else None)
df["lon"] = df["address"].map(lambda a: cache[a].longitude if cache[a] else None)
df.to_csv("addresses_geocoded.csv", index=False)
addresses.csv only needs a header row with an address column. Deduplicating first matters more than it looks: a customer export where the same office building appears hundreds of times shrinks to one request for that building. If you'll rerun the job, save the cache to disk so the second run doesn't repeat the first.
What to do when you hit the limit
Geopy's guidance on Too Many Requests errors lists four ways forward
Add a non-zero min_delay_seconds. This is the first fix, and the RateLimiter code above already does it. It keeps you inside the per-second cap, but it can't make a scheduled job any faster than 4 requests per minute.
Try a different geocoding service. Each one brings its own terms, quota, and response shape, so you rewrite your parsing when you switch, and geopy's docs warn that some services prohibit bulk geocoding, so read their terms first. If you got here searching for a free geocoding API for Python, this is the trade-off: free usually means a quota with a ceiling.
Take a paid plan on the service you've chosen. A paid plan buys a higher quota. That is where a dedicated geocoding API fits, covered in the next section.
Provision your own local copy of the service. Self-hosting Nominatim removes the public instance's limits, because those limits come from sharing a free server. Then you own the server. Nominatim's installation docs put a full planet import at 128GB of RAM or more strongly recommended, at least 1TB of disk, and NVME drives recommended, with a note not to report out of memory problems below 64GB. The import takes around 2.5 days on a well configured machine, or 4 to 5 days on traditional SSDs. After that, the OSM database keeps growing and your copy needs updates to stay current. Reasonable for a team that geocodes constantly and has someone to run it. Heavy for a feature inside a web app.
Geocoding in Python With a Dedicated API
At some point the constraint stops being your code. You have handled the None returns and paced the loop, and the limit has not moved, because it was never yours to raise. A dedicated geocoding API is where that changes. Our geocoding APIs cover both directions with one key and one response format.
Forward geocoding: address to coordinates
Our Forward Geocoding API takes free-form text and returns coordinates. One GET request, no SDK to install.
# Forward geocode a single address and return the best match
import json
import requests
url = "https://api.apifreaks.com/v1.0/geocoder/search"
headers = {"X-apiKey": "YOUR_API_KEY"}
params = {
"query": "1600 Amphitheatre Parkway, Mountain View, CA",
"limit": 1,
}
response = requests.get(url, headers=headers, params=params)
print(json.dumps(response.json(), indent=2))
Response:
[
{
"lat": 37.42248575,
"lon": -122.08558456613565,
"name": "Google Building 41",
"category": "building",
"type": "commercial",
"poi": [
{
"name": "Google Building 41",
"category": "building",
"type": "commercial"
},
{
"name": "1600",
"category": "place",
"type": "house_number"
}
],
"street": "Amphitheatre Parkway",
"postcode": "94043",
"city": "Mountain View",
"county": "Santa Clara County",
"state_code": "US-CA",
"state": "California",
"country_code": "US",
"country": "United States",
"full_address": "Google Building 41, 1600, Amphitheatre Parkway, Mountain View, Santa Clara County, California, 94043, United States",
"bounding_box": [
"37.4221124",
"37.4228508",
"-122.0859868",
"-122.0851511"
]
}
]
The response is an array because a text query can match more than one place. limit=1 keeps it to the best match, which is what you want for address validation. Raise it (up to 40) and you get candidates, useful for a search box where the user picks.
The fields you will use most: lat and lon in WGS84, so they drop into any mapping library without a projection step. full_address when you need display text. The individual components when you need to store or filter. state_code follows ISO 3166-2, so US-CA normalizes cleanly instead of you deciding between "California" and "CA". And bounding_box gives you the extent of the match, which beats guessing a map zoom radius.
If you want to see what a single address resolves to before wiring anything up, paste it into the Address to Coordinates Converter instead. And once an address has coordinates, you can use them as the center of a radius search; How to Find All ZIP Codes Within a Radius walks through that next step.
Reverse geocoding: coordinates to address
The Reverse Geocoding API goes the other way, with the same authentication and a closely matching response.
# Reverse geocode a coordinate pair into the nearest street address
import json
import requests
url = "https://api.apifreaks.com/v1.0/geocoder/reverse"
headers = {"X-apiKey": "YOUR_API_KEY"}
params = {"lat": 40.748817, "lon": -73.985428}
response = requests.get(url, headers=headers, params=params)
print(json.dumps(response.json(), indent=2))
Response:
{
"lat": 40.7489669,
"lon": -73.985399,
"name": "LensCrafters",
"category": "shop",
"type": "optician",
"poi": [
{
"name": "LensCrafters",
"category": "shop",
"type": "optician"
},
{
"name": "7",
"category": "place",
"type": "house_number"
}
],
"street": "West 34th Street",
"area": "Manhattan, New York County",
"postcode": "10001",
"city": "New York",
"state_code": "US-NY",
"state": "New York",
"country_code": "US",
"country": "United States",
"full_address": "LensCrafters, 7, West 34th Street, Midtown South, Manhattan Community Board 5, Manhattan, New York County, New York, 10001, United States",
"bounding_box": [
"40.7489169",
"40.7490169",
"-73.9854490",
"-73.9853490"
]
}
One thing to watch: this returns a single object instead of an array. A coordinate pair has one nearest address, so there is nothing to page through. Forward gives you a list, reverse gives you an object, and your parser needs to know which one it called.
The poi array is what makes reverse geocoding useful for more than an address. Alongside street and postcode, these coordinates also resolved to a named shop, which is how you label a check-in with a venue rather than a house number. For spot-checking a single pair by hand, the Coordinates to Address Converter does it without a key.
Batch geocoding a CSV of addresses
This is the counterpart to the RateLimiter loop earlier. It reads addresses from a CSV, geocodes each unique one, and writes results to a second CSV as it goes. The input file needs a header row with an address column:
address
1600 Amphitheatre Parkway, Mountain View, CA
221B Baker Street, London
Eiffel Tower, Paris
asdfqwerty123nonexistent
221B Baker Street, London
Save the script below as batch_geocode.py. The next two examples import from it.
# Batch geocode addresses from a CSV file with retries, backoff, and resumable progress
import csv
import os
import sys
import threading
import time
import requests
API_KEY = os.environ["APIFREAKS_KEY"]
URL = "https://api.apifreaks.com/v1.0/geocoder/search"
INPUT_FILE = "addresses.csv"
OUTPUT_FILE = "geocoded.csv"
FIELDS = ["address", "lat", "lon", "full_address", "status"]
FINAL_STATUSES = {"ok", "no_match"}
_local = threading.local()
class OutOfCredits(Exception):
"""Raised on a 402, since every remaining request would fail the same way."""
def get_session():
"""Give each thread its own requests.Session."""
if not hasattr(_local, "session"):
session = requests.Session()
session.headers.update({"X-apiKey": API_KEY})
_local.session = session
return _local.session
def load_addresses(path):
"""Read the address column, trimmed, with blanks and duplicates removed."""
with open(path, newline="", encoding="utf-8") as f:
rows = ((row.get("address") or "").strip() for row in csv.DictReader(f))
return list(dict.fromkeys(address for address in rows if address))
def geocode(address, max_retries=3):
"""Return (lat, lon, full_address, status) for one address."""
for attempt in range(max_retries):
last_attempt = attempt == max_retries - 1
try:
response = get_session().get(
URL, params={"query": address, "limit": 1}, timeout=15
)
except requests.RequestException:
if last_attempt:
break
time.sleep(2 ** attempt) # backoff doubles each time
continue
status = response.status_code
if status == 200:
try:
results = response.json()
except ValueError:
return None, None, None, "bad_json"
if not results:
return None, None, None, "empty_response"
result = results[0]
return result.get("lat"), result.get("lon"), result.get("full_address"), "ok"
if status == 404:
return None, None, None, "no_match" # bad data, retrying won't help
if status == 402:
raise OutOfCredits("Credits exhausted, stopping the run.")
if status in (400, 401, 403):
return None, None, None, f"client_error_{status}"
if status == 429 or status >= 500:
if last_attempt:
break
time.sleep(2 ** attempt) # temporary failure, back off and retry
continue
return None, None, None, f"http_{status}"
return None, None, None, "failed_after_retries"
def prepare_output(path):
"""Keep rows that already have a final answer, drop the rest, and return their addresses.
Dropping failed rows before a retry means each address ends up with one row.
"""
if not os.path.exists(path) or os.path.getsize(path) == 0:
return set()
with open(path, newline="", encoding="utf-8") as f:
kept = {
row["address"]: row
for row in csv.DictReader(f)
if row["status"] in FINAL_STATUSES
}
temp_path = path + ".tmp"
with open(temp_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(kept.values())
os.replace(temp_path, path) # swap in the cleaned file in one step
return set(kept)
def main():
addresses = load_addresses(INPUT_FILE)
finished = prepare_output(OUTPUT_FILE)
write_header = not os.path.exists(OUTPUT_FILE) or os.path.getsize(OUTPUT_FILE) == 0
with open(OUTPUT_FILE, "a", newline="", encoding="utf-8") as outfile:
writer = csv.DictWriter(outfile, fieldnames=FIELDS)
if write_header:
writer.writeheader()
for address in addresses:
if address in finished:
continue
try:
lat, lon, full_address, status = geocode(address)
except OutOfCredits as error:
print(error)
sys.exit(1)
writer.writerow({
"address": address,
"lat": lat,
"lon": lon,
"full_address": full_address,
"status": status,
})
outfile.flush()
print(f"{status:22} {address}")
if __name__ == "__main__":
main()
Here's what this script handles that the RateLimiter loop couldn't.
The retry logic reads the status code and decides. A 404 returns immediately, because our Forward Geocoding API uses 404 for "no results found", and an address that doesn't exist won't start existing on the second attempt. A 400, 401, or 403 is your request or your key, so retrying repeats the mistake. A 402 means credits are exhausted, so the run stops instead of writing thousands of identical failures. Only 429s, 5xx responses, and connection failures get retried, and the backoff doubles each time rather than waiting a fixed interval
Failures stay distinguishable. Every row carries a status, so no_match and failed_after_retries never collapse into the same empty cell. The junk entry in the sample CSV is there to show it: the bad address is recorded as no_match instead of a row that is mysteriously blank
Progress survives a killed process. Rows are written and flushed as they complete. On restart, the script keeps the rows that already have a final answer (ok or no_match) and removes the rest before retrying those addresses, so each address ends up with one row instead of an old failure plus a new result
Nothing is geocoded twice. Duplicates are removed when the CSV is loaded, so the repeated Baker Street address in the sample costs one call. The same idea scales up: cache results across runs and the repeats stop costing anything at all.
Running requests in parallel
The loop above sends one request at a time, which keeps it simple. Our geocoding endpoints carry no concurrency limit, so you can keep several requests in flight at once. This is the step the public Nominatim instance rules out entirely, since its bulk conditions require a single thread.
# Geocode the same CSV with eight requests in flight at once
import csv
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from batch_geocode import FIELDS, OutOfCredits, geocode, load_addresses, prepare_output
OUTPUT_FILE = "geocoded_parallel.csv"
credits_ran_out = threading.Event()
def geocode_or_skip(address):
"""Geocode one address, or mark it out_of_credits once a 402 has come back."""
if credits_ran_out.is_set():
return None, None, None, "out_of_credits"
try:
return geocode(address)
except OutOfCredits:
credits_ran_out.set()
return None, None, None, "out_of_credits"
finished = prepare_output(OUTPUT_FILE)
todo = [address for address in load_addresses("addresses.csv") if address not in finished]
write_header = not os.path.exists(OUTPUT_FILE) or os.path.getsize(OUTPUT_FILE) == 0
with open(OUTPUT_FILE, "a", newline="", encoding="utf-8") as outfile:
writer = csv.writer(outfile)
if write_header:
writer.writerow(FIELDS)
with ThreadPoolExecutor(max_workers=8) as pool:
for address, result in zip(todo, pool.map(geocode_or_skip, todo)):
writer.writerow([address, *result])
outfile.flush()
if credits_ran_out.is_set():
print("Credits ran out. Top up and run the script again to finish the out_of_credits rows.")
geocode() gives each thread its own requests.Session, so the function from the batch script works unchanged inside the pool. Rows are written and flushed in input order as pool.map hands results back, so answers you've paid for reach the file as the run goes rather than at the end. The wrapper handles a 402: instead of raising an exception that crashes the run while the pool keeps sending requests that can only fail, geocode_or_skip() records that address as out_of_credits, and every address that hasn't started yet gets the same status without another call to the API. The script also reuses prepare_output() from the batch script, so after topping up you run it again and it picks up only the addresses without a final answer. For files with hundreds of thousands of rows, feed it the input in chunks, since pool.map queues every address up front. Parallelism changes how fast you spend credits, not how many: every successful call still costs the same.
Geocoding a pandas DataFrame with the API
If your addresses already live in a DataFrame, the same geocode() function plugs straight in:
# Add coordinates to a DataFrame with the API, one call per unique address
import pandas as pd
from batch_geocode import OutOfCredits, geocode
df = pd.read_csv("addresses.csv").dropna(subset=["address"])
df["address"] = df["address"].str.strip()
results = {}
for address in df["address"].unique():
try:
results[address] = geocode(address)
except OutOfCredits as error:
print(error) # stop calling the API, but keep every answer already paid for
break
not_geocoded = (None, None, None, "out_of_credits")
geo = pd.DataFrame(
[results.get(address, not_geocoded) for address in df["address"]],
index=df.index,
columns=["lat", "lon", "full_address", "status"],
)
df = df.join(geo)
df.to_csv("addresses_geocoded.csv", index=False)
If credits run out partway, the loop stops at the first 402 and keeps every answer it already has, and the rows it never reached are marked out_of_credits. This version doesn't resume, so a rerun geocodes every row again; for a list you can't afford to redo, run one of the scripts above and load its CSV into pandas. The status column comes along either way, so you can filter df[df["status"] != "ok"] afterward and review only the rows that need another look.
What the API fixes
Each of these maps back to something earlier in this post.
Volume comes down to credits. Nominatim's public instance caps you at one request per second and puts tight conditions on bulk and scheduled jobs, and no amount of code changes that. With our API, what you can process depends on the credits on your plan. Each successful geocoding call costs 10 credits, so the 10,000 free credits you get on signup cover 1,000 lookups; the pricing page lists the larger plans. Concurrency limits apply only to a short list of our endpoints, and neither geocoding endpoint is on it.
Failures don't drain your balance. Credits are charged only on successful queries, meaning a 2xx status. If a request returns a 4xx or 5xx, the credits for it are restored. That is what lets the script above retry a server error without watching a budget drain.One documented response format. Both endpoints use the same ISO code standards and the same bounding_box layout, and the component names line up where the data overlaps. Moving between forward and reverse means handling an array versus an object, not relearning a schema the way switching providers does.
Nothing to host. No planet file, no 1TB volume, no 128GB of RAM, no multi-day import, and no ongoing job keeping a local OSM copy current.
Which Approach Fits Your Volume
The real choice between these three is less about capability than about what you're willing to run yourself.
| Approach | Geopy + Nominatim public instance | Self-hosted Nominatim | Dedicated geocoding API |
|---|---|---|---|
| Setup effort | pip install geopy, no key, no account | Server provisioning plus a full planet import, 2.5 days on a well configured machine and 4 to 5 on traditional SSDs | Sign up, get a key, one GET request |
| Realistic volume ceiling | One request per second; smaller one-time bulk jobs only, single-threaded and cached; scheduled or day-long scripts limited to 4 per minute | Bounded by your own hardware rather than by policy | Bounded by credits rather than a per-second cap; no concurrency limit on either geocoding endpoint |
| Maintenance burden | None, because you run nothing | Yours entirely: 128GB RAM recommended, 1TB disk minimum, plus keeping a growing OSM copy current | None |
| Cost shape | Free | Server and someone's time, indefinitely | 10 credits per successful call, credits restored on 4xx or 5xx |
| Best fit | Learning, prototypes, occasional scripts, and features triggered one request at a time by a moderate number of users | Teams geocoding constantly with infrastructure people already on staff | Production features and dataset jobs where volume and reliability matter more than a zero invoice |
Volume decides this. A script that geocodes fifty addresses has no reason to leave Geopy and Nominatim, and at that size the one per second limit is invisible.
Things change once geocoding becomes part of how your product behaves: a signup form validating every address, a nightly job attaching coordinates to yesterday's orders, a search box responding as the user types. The usage policy treats periodic jobs as bulk geocoding and forbids autocomplete outright, so those features need a different service no matter how quickly the public instance responds.
Self-hosting is the right call for some teams. Just know that you're choosing to operate a geocoding service, and the server keeps needing attention long after the import finishes.
Best Practices for Geocoding in Python
A few habits save you from the problems that show up months later.
Check what was matched, not just that something came back. A response means the geocoder found its closest match, which is not always the place you meant. In a Nominatim reverse result, addresstype and class tell you whether you hit a building, a road, or a boundary. A query for a street address that returns an administrative area technically succeeded and is useless to you. Decide which match types you accept before writing to a database.
Cache aggressively. The same address resolves to the same coordinates every time, so geocoding it twice is wasted work. A lookup table keyed on the normalized address is usually enough, and it pays off fastest where inputs repeat: signup forms where the same office building appears hundreds of times, or a nightly job reprocessing rows it already handled. On the public Nominatim instance, caching is a requirement for bulk work.
Normalize before you send. Inconsistent input is the most common cause of a failed match, and it is yours to fix. Trim whitespace, collapse double spaces, strip apartment and suite numbers, and settle on one casing. Ste 400 and Suite 400 will also break your cache, since they hash differently despite being the same place
Log the input with the output. Store the exact query you sent next to what came back, failures included. Months later, when a set of coordinates looks wrong, that log is the only way to tell a bad geocode from bad source data. The status column in the batch script is the minimum version of this.
Conclusion
Geocoding in Python stays simple until volume enters the picture. Geopy and Nominatim will carry you through prototypes, one-off scripts, and features that fire a single request at a time for a moderate user base, and at that scale there is no reason to reach for anything else. When the one request per second ceiling and the bulk restrictions start dictating how your application is built, that is the signal to move rather than to optimize.
Our Forward Geocoding API and Reverse Geocoding API take it from there. Sign up for free and test them against your own addresses before committing to anything.
Frequently Asked Questions
What is the best way to do geocoding in Python?
It depends on volume. For learning, prototypes, or a script you run occasionally, Geopy pointed at Nominatim needs no key and no account, and four lines get you coordinates. For anything running in production or processing addresses in bulk, a dedicated geocoding API removes the per-second ceiling and the bulk-job conditions that come with the free public instance. Start with Geopy, and move when the limits start shaping your code rather than the other way around.
What is the best geocoding tool?
The best tool depends on how many lookups you need and where they run. For checking a single address in a browser, a free converter like our Address to Coordinates Converter is fastest. For Python scripts and prototypes, Geopy with Nominatim costs nothing. For production features and large datasets, a geocoding API with documented limits and a consistent response format is the safer choice. Match quality varies by region with every provider, so test a sample of your own addresses before committing.
Is Geopy free to use?
Yes. Geopy is an open source client library, so the library itself never costs anything. The geocoding service behind it is separate: Geopy's own documentation notes that these services are not affiliated with it and each has its own terms, quotas, and pricing. Configure Nominatim and you pay nothing; configure a commercial provider through the same interface and you pay that provider.
Is a geocoding API free?
Most have a free entry point rather than being free outright. Nominatim's public instance costs nothing and needs no signup, in exchange for a one request per second cap and tight limits on bulk jobs. Commercial APIs typically give you a credit or request allowance to start, then bill beyond it. Ours includes 10,000 free credits on signup, enough to test against your own address data before deciding anything.
How do I geocode thousands of addresses in Python?
Price the job before you write any code. Deduplicate the list first, since repeated addresses are common in exports and each duplicate is a wasted request. Then estimate both ways: on the public Nominatim instance, a one-time run of 10,000 unique addresses takes about 2 hours 47 minutes at one request per second, if the operators accept it as a smaller job, and a scheduled run drops to 4 per minute. With our API, the same 10,000 addresses cost 100,000 credits on success, and you can run requests in parallel to finish faster.
