Geocoding in Python: batch and reverse geocoding with geopy, Nominatim and postcodes.io

Batch geocode all 192 open schools in Sheffield in Python with geopy and OpenStreetMap Nominatim (1 request per second, a custom user agent and an on-disk cache) and with postcodes.io bulk postcode lookups, reverse geocode a sample, and measure every method honestly against the Department for Education's own coordinates. Covers free text against structured queries, failure modes, cross-checking two geocoders, and the licensing of geocoded results.

Level
Beginner
Tested with
Python 3.12
Source code
Download scripts

Key takeaways

  1. `geopy` with `Nominatim(user_agent=...)` wrapped in `RateLimiter(min_delay_seconds=1)` and an on-disk cache geocoded 576 queries for 192 Sheffield schools in 742 s within OpenStreetMap's usage policy; the rerun from cache took 3 s.
  2. Full postal addresses were the least accurate input: most matched only the street, with a median error of 102 m against the Department for Education's coordinates. Searching by school name gave a median of 43 m but missed 17 schools and put one in Doncaster, 27 km away.
  3. postcodes.io found all 192 schools' postcodes in two bulk requests, with a median error of 88 m and 95% within 250 m.
  4. Accepting a name match only when it lies within 250 m of the postcode centroid, and otherwise using the centroid, gave a median error of 46 m with 94% within 250 m, and needed no reference data to decide.
  5. Reverse geocoding the official coordinates of 40 schools returned the right full postcode for 35 with Nominatim and 28 with postcodes.io, but Nominatim named the school itself for only 28.

Before you start

  • Basic pandas and GeoPandas; the coordinate reference systems tutorial explains why the errors are measured in British National Grid (EPSG:27700)
  • Python 3.10+ with geopy, geopandas, shapely, pyproj, pandas, numpy, requests and matplotlib
  • An internet connection and about 15 minutes for the first run: Nominatim allows at most one request per second, so 600 requests take 10 minutes. Later runs read the cache and finish in seconds

Short answer: to geocode addresses in Python, install geopy and create Nominatim(user_agent="your-app-name"). Wrap its geocode method in RateLimiter(geolocator.geocode, min_delay_seconds=1) and call it for each address: location.latitude and location.longitude hold the result. For batch geocoding with the free OpenStreetMap server, keep to one request per second, use an identifying user agent, and cache every answer so you never send the same query twice. For reverse geocoding, use geolocator.reverse("53.38, -1.47"). For UK postcodes, the free postcodes.io API is faster and more reliable: one POST to /postcodes looks up 100 postcodes at once.

Geocoding is easy to run and hard to check. A geocoder almost always returns something, and nothing in the result tells you it is 300 m out, or in the next town. This tutorial geocodes a real list of UK addresses and then does what most tutorials skip: it measures the error against authoritative coordinates.

The addresses are all 192 open schools in Sheffield from the Department for Education’s Get Information about Schools register (GIAS). Schools make a good test because GIAS publishes an easting and northing for every school alongside its address, so each geocoded point can be compared with an official position. Lists of hospitals or libraries usually come without coordinates, or with coordinates that were themselves geocoded.

You will:

  1. load the addresses and the reference coordinates;
  2. build a polite, cached Nominatim client with geopy;
  3. geocode each school three ways: free-text address, structured address and name;
  4. look up every postcode in bulk with postcodes.io;
  5. measure the distance errors and see where and why they occur;
  6. cross-check the two geocoders to catch bad matches without reference data;
  7. reverse geocode a sample.

All the code is in geocode_schools.py, which you can download with its pinned requirements.

Set up

Terminal window
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt # geopy 2.5.0, geopandas 1.2.0, pandas 3.1.0, ...
python geocode_schools.py

The script downloads the latest daily GIAS extract (about 65 MB) once into data/, and writes every geocoder answer to cache/.

Step 1: the addresses and the reference coordinates

GIAS covers every school, academy, college and nursery school in England. The script keeps the open establishments in Sheffield, leaving out secure units:

geocode_schools.py
cols = ["URN", "EstablishmentName", "TypeOfEstablishment (name)", "Street", "Locality", "Address3", "Town",
"Postcode", "Easting", "Northing", "LA (name)", "EstablishmentStatus (name)"]
gias = pd.read_csv(gias_extract(), encoding="cp1252", usecols=cols, dtype=str)
schools = gias[(gias["LA (name)"] == "Sheffield") & (gias["EstablishmentStatus (name)"] == "Open")
& (gias["TypeOfEstablishment (name)"] != "Secure units")].copy()
schools = schools.rename(columns={"EstablishmentName": "name", "TypeOfEstablishment (name)": "type"})
schools[["Easting", "Northing"]] = schools[["Easting", "Northing"]].astype(float)
schools = schools.dropna(subset=["Easting", "Postcode"]).fillna("").reset_index(drop=True)
ref = gpd.GeoDataFrame(schools, geometry=gpd.points_from_xy(schools.Easting, schools.Northing), crs=27700)
schools["address"] = schools.apply(full_address, axis=1) # "Jenkin Road, Sheffield, S9 1AS"

Two details matter. The file is encoded in Windows-1252, not UTF-8, and dtype=str keeps the reference numbers and postcodes exactly as published.

Output — the input
open establishments in Sheffield in GIAS: 192 (secure units left out)
URN name Street Locality Town Postcode Easting Northing
106973 Broomhall Nursery School 4 Broomhall Road Sheffield S10 2DN 434273.0 386604.0
106975 Grace Owen Nursery School 1-7 the Pavement Duke Street Sheffield S2 5QD 436122.0 387343.0
106987 Brightside Nursery Infant School Jenkin Road Sheffield S9 1AS 438585.0 390675.0
106988 Carter Knowle Junior School Carter Knowle Road Sheffield S7 2DY 433952.0 384093.0

Notice that most addresses have no house number. A school’s postal address is often just a road name, which limits how precisely any address geocoder can place it.

Map of the City of Sheffield local authority boundary with 192 school locations: 120 academies in blue and 72 other school types in red, concentrated in the east of the city and absent from the Peak District moorland in the west.
Figure 1. The 192 open establishments in Sheffield and the coordinates the Department for Education publishes for them, which serve as the reference for every accuracy figure below.

Step 2: a polite, cached Nominatim client with geopy

geopy wraps many geocoding services behind the same interface. Its RateLimiter enforces the delay between calls and retries on errors. On top of that, a dictionary saved as JSON makes sure each distinct query is sent only once, ever:

geocode_schools.py
from geopy.extra.rate_limiter import RateLimiter
from geopy.geocoders import Nominatim
USER_AGENT = "TechnicalGIS-tutorial"
geolocator = Nominatim(user_agent=USER_AGENT, timeout=20)
geocode_live = RateLimiter(geolocator.geocode, min_delay_seconds=1, max_retries=2, error_wait_seconds=10)
reverse_live = RateLimiter(geolocator.reverse, min_delay_seconds=1, max_retries=2, error_wait_seconds=10)
cache_file = CACHE / "nominatim.json"
cache = json.loads(cache_file.read_text(encoding="utf-8")) if cache_file.exists() else {}
calls = []
def cached(kind, query, **kw):
key = f"{kind}|{json.dumps(query, sort_keys=True)}|{json.dumps(kw, sort_keys=True)}"
if key not in cache:
t0 = time.perf_counter()
fn = geocode_live if kind == "geocode" else reverse_live
loc = fn(query, **kw)
calls.append(time.perf_counter() - t0)
cache[key] = loc.raw if loc else None # store "no result" too, so misses are not retried
if len(calls) % 25 == 0:
cache_file.write_text(json.dumps(cache), encoding="utf-8")
return cache[key]

Set your own user agent: an application name, or a project URL. Do not put a personal email address in it. The cache stores Nominatim’s raw JSON, not just the coordinates, so you can later inspect what each query matched without asking again.

Step 3: three ways to ask Nominatim

The same school can be searched for in different ways, and they behave very differently. The script tries three for every school, always restricted to Great Britain with country_codes="gb":

geocode_schools.py
def nominatim(query):
raw = cached("geocode", query, country_codes="gb", addressdetails=True)
if raw is None:
return {}
return {"lat": float(raw["lat"]), "lon": float(raw["lon"]), "addresstype": raw.get("addresstype", ""),
"osm_class": f"{raw.get('class')}={raw.get('type')}", "display_name": raw.get("display_name", "")}
for r in schools.itertuples():
variants = {
"Nominatim free text": f"{r.address}, United Kingdom",
"Nominatim structured": {"street": r.Street, "city": r.Town or "Sheffield", "postalcode": r.Postcode,
"country": "United Kingdom"},
"Nominatim by name": f"{r.name}, Sheffield",
}
for method, q in variants.items():
rows.append({"URN": r.URN, "method": method, **nominatim(q)})

Passing a dictionary to geocode sends a structured query, in which each part of the address goes into its own field. The first run sent 559 requests (17 had been cached by an earlier test) and took 742 seconds:

Output — first run
Nominatim: 576 forward queries, 559 sent live, 17 from the cache, 742 s
live request wall time: median 1.00 s (the rate limiter keeps them at least 1 s apart)

A second run reads everything from the cache:

Output — rerun
Nominatim: 576 forward queries, 0 sent live, 576 from the cache, 0 s

Step 4: bulk postcode lookup with postcodes.io

postcodes.io is a free, open-source API over the ONS Postcode Directory. It needs no key, and its bulk endpoint takes up to 100 postcodes per request:

geocode_schools.py
todo = sorted({p for p in schools.Postcode if p not in pc_cache})
for i in range(0, len(todo), 100):
resp = requests.post("https://api.postcodes.io/postcodes", json={"postcodes": todo[i:i + 100]},
headers={"User-Agent": USER_AGENT}, timeout=60)
resp.raise_for_status()
for item in resp.json()["result"]:
pc_cache[item["query"]] = item["result"] # None for an unknown or terminated postcode

All 182 distinct postcodes for the 192 schools went in two requests, against more than ten minutes of rate-limited requests for Nominatim. Each result also carries the ward, local authority, LSOA and constituency codes, which saves a spatial join later.

Step 5: measure the accuracy honestly

Geocoders return WGS 84 latitude and longitude, while GIAS gives British National Grid eastings and northings. Project the results to EPSG:27700 and the error is simply the distance in metres. The coordinate reference systems tutorial explains why you should not measure distances in degrees:

geocode_schools.py
res = pd.DataFrame(rows).merge(schools[["URN", "name", "Postcode", "Easting", "Northing", "address"]], on="URN")
found = res.lat.notna()
pts = gpd.GeoSeries(gpd.points_from_xy(res.lon.fillna(0), res.lat.fillna(0)), crs=4326).to_crs(27700)
res["x"], res["y"] = pts.x.where(found), pts.y.where(found)
res["error_m"] = np.hypot(res.x - res.Easting, res.y - res.Northing)
Output — distance from the GIAS coordinates
distance from the GIAS coordinates (m; percentages of the schools that were found):
found median_m p90_m within_50m within_100m within_250m over_1km
method
Nominatim free text 183/192 102.2 270.0 18.6 48.6 85.2 1
Nominatim structured 189/192 104.2 272.3 18.0 47.1 84.7 2
Nominatim by name 175/192 43.1 138.8 56.0 82.3 96.0 4
postcodes.io postcode 192/192 88.4 199.6 27.6 56.8 94.8 1

Report the whole distribution, not one number. Median, 90th percentile, the share within useful distances, the gross errors and the misses each tell you something different.

Four maps of central Sheffield, one per method, with a black dot at each school's official position joined by a line to its geocoded position. The name search panel has mostly overlapping dots; the free-text and structured panels have many short coloured lines; the postcode panel has small offsets everywhere.
Figure 2. Where each method put each school in central Sheffield. Black: the GIAS coordinate; colour: the geocoded point. Lines are coloured where the error exceeds 100 m. The long north–south line in three panels is the same school: both Nominatim and postcodes.io agree with each other, about 3 km from the GIAS point.
Cumulative distribution of distance error on a log scale for five methods. Name search and the combined rule rise fastest, reaching about 80% by 100 m; postcodes.io follows and reaches 95% by 250 m; free-text and structured address searches rise last. Name search flattens at about 91% because of misses.
Figure 3. Cumulative distance error by method. A curve further left is more accurate; a curve that stops below 100% has misses. The dashed line is the cross-checked combination from Step 6.

The ranking is not what most people expect. The full postal address was the least accurate input. The reason is in what Nominatim matched:

Output — free-text matches by addresstype
size median p90
addresstype
residential 98 105.0 269.0
tertiary 38 126.0 351.0
unclassified 14 85.0 183.0
secondary 10 90.0 205.0
amenity 10 25.0 371.0
primary 9 94.0 512.0
trunk 4 96.0 218.0

Only 10 of 183 free-text results were the school itself (amenity); the rest were roads, from residential streets to trunk roads. With no house number in the address, Nominatim returns a point on the named street. The school might be anywhere along it, and on a long road that is hundreds of metres away. The structured query has the same problem. Searching by name finds the amenity=school feature that OpenStreetMap mappers drew, which is why it is so much more precise when it works.

Strip chart of distance error on a log scale, grouped by the type of feature Nominatim matched. Amenity matches from the name search cluster between 10 and 100 metres; residential, tertiary and other road matches from address searches spread from 20 to 800 metres; one city-level match is about 1 km out.
Figure 4. Error grouped by what Nominatim actually matched (its addresstype field). Amenity matches, almost all from the name search, are the most precise; matches on roads of every class sit tens to hundreds of metres from the school.

The lesson generalises: always keep the geocoder’s match level (addresstype, class and type in Nominatim’s raw result) and use it to decide which results to trust.

Failure modes

The largest errors show the failure modes you will meet with any geocoder:

Output — largest errors
27.1 km Nominatim by name Kingfisher Academy | Sevenairs Road, Beighton, Sheffield, S20 1NZ
-> Kingfisher Primary Academy, Coventry Grove, Wheatley, Arksey, Doncaster, South Yorkshire, England, DN2 4PY, Un
3.0 km Nominatim structured Phoenix School of Therapeutic Education | 83 East Bank Road, Sheffield, S2 3PX
-> Cygnet, 83, East Bank Road, Upper Heeley, Sheaf Valley Quarter, Norfolk Park, Sheffield, South Yorkshire, Engl
2.0 km Nominatim by name Sheffield Hallam University | City Campus, Pond Street, Sheffield, S1 1WB
-> Sheffield Hallam University Collegiate Campus, Denham Road, Broomfield, Devonshire Quarter, Sharrow, Sheffield
1.7 km Nominatim by name King Edward VII School | Glossop Road, Sheffield, S10 2PW
-> King Edward VII School - Lower Site, Darwin Lane, Stannington, Ranmoor, Sheffield, South Yorkshire, England, S
1.0 km Nominatim structured The Sheffield College | Granville Road, Sheffield, S2 2RL
-> Sheffield, South Yorkshire, England, United Kingdom
0.8 km Nominatim structured Kenwood Academy | 9 Moncrieffe Road, Nether Edge, Sheffield, S7 1HQ
-> Moncrieffe Road, Kenwood, Nether Edge, Sheffield, South Yorkshire, England, S7 1HQ, United Kingdom
  • Wrong place with a similar name. “Kingfisher Academy, Sheffield” matched Kingfisher Primary Academy in Doncaster. Nominatim treats words loosely, and adding the city to the query does not force the result to be in it. To restrict results, pass viewbox= with bounded=True, or check the returned display_name.
  • Multi-site institutions. The two universities and King Edward VII School each have several sites. The geocoder found a real site, just not the one GIAS records.
  • Fallback to a coarser level. When the structured query for The Sheffield College matched nothing more precise, Nominatim returned the centre of Sheffield (addresstype city), 1.0 km out. Without checking the match level, that looks like a normal result.
  • Street-level matches on long roads. Kenwood Academy’s address resolved to Moncrieffe Road, 825 m from the school.
  • No result. 17 name searches, 9 free-text searches and 3 structured searches returned nothing, mostly because of names that differ from those in OpenStreetMap, such as trust names and abbreviations.
  • The reference can be wrong too. For the Phoenix School of Therapeutic Education, Nominatim found 83 East Bank Road exactly, and the postcode centroid agrees with it, yet both are 3.0 km from the GIAS coordinate. When two independent sources agree and the reference disagrees, suspect the reference: a school that has moved, or a registered address that differs from the teaching site. Similarly, 20 of the 192 GIAS coordinates lie within 5 m of their own postcode centroid, so some of the “authoritative” positions are themselves postcode geocodes.

Step 6: cross-check two geocoders

In real work there is no reference to compare against; that is why you are geocoding. You can still catch most bad matches by comparing two independent geocoders. A name match far from its own postcode centroid is suspect:

geocode_schools.py
to_bng = lambda d: gpd.GeoSeries(gpd.points_from_xy(d.lon.fillna(0), d.lat.fillna(0)), crs=4326,
index=d.index).to_crs(27700)
nm["agree_m"] = to_bng(nm).distance(to_bng(pc)).where(nm.lat.notna()) # name match vs postcode centroid
AGREE = 250
accept = nm.agree_m <= AGREE
combo = pc.copy()
combo.loc[accept, ["lat", "lon", "error_m"]] = nm.loc[accept, ["lat", "lon", "error_m"]]
Output — cross-check and combined rule
cross-check: Nominatim by name vs postcodes.io centroid for the same school: median 113 m apart
0- 250 m apart: 158 schools, error of the name match median 42 m, max 292 m
250- 1000 m apart: 13 schools, error of the name match median 122 m, max 326 m
1000- inf m apart: 4 schools, error of the name match median 1803 m, max 27137 m
combined rule (name match if within 250 m of the postcode centroid, else postcode centroid): 158 from Nominatim by name, 34 from postcodes.io
found 192/192
median_m 46.157467
p90_m 171.030545
within_50m 53.125
within_100m 78.125
within_250m 94.270833
over_1km 1

The rule keeps the precision of the name search (median 46 m) and the completeness of the postcode lookup (192 of 192). It also discards all four name matches that were more than 1 km out, including the Doncaster school, using only the two geocoders’ own answers. The one remaining error over 1 km is the Phoenix school, where the reference itself is in doubt.

Log-log scatter plot. The x axis is the distance between each school's name match and its postcode centroid; the y axis is the true error of the name match. A dashed line at 250 m separates 158 accepted points in blue, mostly between 10 and 300 m error, from 17 rejected points in red, including Kingfisher Academy at 27 km and three university or multi-site matches near 2 km.
Figure 6. Agreement between two geocoders predicts the true error. Name matches within 250 m of the postcode centroid (blue) are accepted; the 17 further away (red) are replaced by the centroid, which removes every gross error.

The 250 m threshold suits urban UK postcodes, which typically cover 15 to 20 addresses. Rural postcodes are larger, so use a wider threshold there, or compare the distance with the postcode’s own extent.

Step 7: reverse geocoding

Reverse geocoding goes the other way, from coordinates to a description. The script reverse geocodes the official coordinates of a random sample of 40 schools with Nominatim, which is cached in the same way, and asks postcodes.io for the nearest postcode in one bulk request:

geocode_schools.py
sample = ref.sample(40, random_state=1).to_crs(4326)
for r in sample.itertuples():
raw = cached("reverse", f"{r.geometry.y:.6f}, {r.geometry.x:.6f}", zoom=18, addressdetails=True)
...
geoloc = [{"longitude": r.lon, "latitude": r.lat, "limit": 1, "radius": 2000} for r in rev.itertuples()]
resp = requests.post("https://api.postcodes.io/postcodes", json={"geolocations": geoloc}, timeout=60)

zoom=18 asks Nominatim for building-level detail; lower values return streets (16), suburbs (14) or towns (10).

Output — reverse geocoding
reverse geocoding 40 GIAS school coordinates:
Nominatim (zoom=18): returned a school feature for 28, the same full postcode as GIAS for 35, the same postcode district for 40, no postcode at all for 0
postcodes.io (nearest postcode centroid): same full postcode as GIAS for 28; nearest centroid median 55 m away
nom_class
amenity=school 24
emergency=defibrillator 3
amenity=grit_bin 2
building=school 2
amenity=college 2
building=semidetached_house 1
highway=residential 1
amenity=restaurant 1
Two bar charts. Left: for 40 schools, Nominatim reverse returned the GIAS postcode for 35 and the right district for the rest; postcodes.io's nearest postcode matched for 28 and the right district for the other 12. Right: the features Nominatim returned, led by amenity=school with 24, followed by defibrillators, grit bins, colleges and single houses, a road, a restaurant, a pitch and a library.
Figure 5. Reverse geocoding 40 official school coordinates. (a) Whether the postcode returned matches the school's own; (b) what Nominatim says is at the coordinate. Building-level reverse geocoding returns the nearest mapped object, which can be a defibrillator or a grit bin at the school gate.

Two things stand out. The nearest postcode centroid is not necessarily the postcode of the address: a school’s postcode can be represented by a point on the other side of the site, so postcodes.io matched only 28 of 40, although it got the district right every time. And at building level, Nominatim returns the nearest mapped object: three schools came back as a defibrillator and two as a grit bin, because those were the closest mapped features to the official point. If you need the name of the place, query at a coarser zoom, or look for the nearest feature of the class you want.

Licensing of geocoded results

Geocoded coordinates come with the licence of the data behind the geocoder, so record which service produced each one.

  • Nominatim results are derived from OpenStreetMap, © OpenStreetMap contributors, under the Open Database Licence (ODbL). The OSMF geocoding guideline treats individual results as insubstantial extracts. You can store them alongside your own data without share-alike applying to that data, provided you give attribution and the results do not add up to a substantial part of OSM, such as every school in a city systematically collected as a general-purpose database. Geocoding your own list of addresses is fine; harvesting OSM through the geocoder is not.
  • postcodes.io serves the ONS Postcode Directory under the Open Government Licence v3.0. Attribute it as: contains OS data © Crown copyright and database right; contains Royal Mail data © Royal Mail copyright and database right; source: Office for National Statistics. Northern Ireland (BT) postcode coordinates carry extra conditions from Land and Property Services, so check them before commercial use.
  • Commercial geocoders often forbid storing results, or showing them on a map from another provider. Read the terms before you build a database from them.

Caveats

  • One city, one kind of address. Schools have well-mapped names and addresses that often lack house numbers. Residential addresses with house numbers behave differently: Nominatim matches them to buildings where OSM has house numbers and interpolates elsewhere, and the UK’s OSM address coverage is patchy.
  • The reference is not perfect. Some GIAS coordinates are themselves postcode centroids, and at least one disagrees with both geocoders by 3 km. The errors here are distances from the official point, not from the true one.
  • A random sample of 40 for reverse geocoding gives proportions with wide confidence intervals; treat 35 of 40 as “most”, not as 87.5% exactly.
  • OSM changes daily. Rerunning without the cache will give slightly different results as mappers add schools and addresses.
  • Speed. At one request per second, 10,000 addresses take nearly three hours on the public server, and regular jobs of that size breach its usage policy. Use postcodes.io for postcodes, Ordnance Survey AddressBase or a self-hosted Nominatim for large jobs.

Frequently asked questions

How do I geocode addresses in Python?

Install geopy, create a geocoder such as Nominatim(user_agent="my-app"), and call geolocator.geocode("10 Downing Street, London"). The result has .latitude, .longitude, .address and .raw, the full JSON response. For many addresses, wrap the call in RateLimiter, apply it to a pandas column, and cache the results. geopy also supports commercial services such as Google, Bing and Here behind the same interface.

How do I batch geocode addresses in Python without being blocked?

With the public Nominatim server, use one thread, at most one request per second (RateLimiter(min_delay_seconds=1)), a user agent that names your application, and an on-disk cache so no query is ever repeated. Keep jobs to a few thousand addresses at most, and do not schedule repeated runs. For UK postcodes, use postcodes.io’s bulk endpoint, which takes 100 postcodes per request. For larger jobs, run your own Nominatim or use a paid service.

How do I do reverse geocoding in Python?

Call geolocator.reverse("53.3811, -1.4701", zoom=18) with geopy’s Nominatim geocoder. It returns the nearest address or feature; .raw["address"] holds the parts, such as road, suburb and postcode. Choose zoom for the level of detail you need. For the nearest UK postcode, use postcodes.io: GET https://api.postcodes.io/postcodes?lon=-1.47&lat=53.38, or POST up to 100 points to /postcodes with a geolocations list.

How accurate is Nominatim geocoding?

It depends on what it matches. In this test, searching by school name gave a median error of 43 m when it found the school, but street addresses without house numbers gave a median of 102 m, because Nominatim could only match the street. Gross errors of 1 to 27 km came from similarly named places and multi-site institutions. Always check the match level in the raw result (addresstype) and, where possible, cross-check against a second source.

Yes, with attribution. Under the OSMF geocoding guideline, individual results are insubstantial extracts of OpenStreetMap. You can store them with your own data without share-alike obligations, as long as you credit “© OpenStreetMap contributors” and do not use the geocoder to collect a substantial part of OSM. The public server’s usage policy additionally requires you to cache results.

What is the best free geocoder for UK postcodes?

postcodes.io. It is free, needs no key, has no published rate limit for reasonable use, serves the ONS Postcode Directory under the Open Government Licence, and handles 100 postcodes per bulk request. Here it found all 192 schools’ postcodes in two requests, with a median error of 88 m. You can also download the ONS Postcode Directory and join on the postcode yourself, which needs no API at all.

Next steps

Data and licences

  • Get Information about Schools, all establishments extract (3 November 2026): Department for Education, licensed under the Open Government Licence v3.0. From GIAS downloads, accessed on 3 November 2026.
  • Nominatim geocoding results: © OpenStreetMap contributors, available under the Open Database Licence (ODbL) v1.0, via the public Nominatim server on 3 November 2026.
  • postcodes.io results: ONS Postcode Directory, Office for National Statistics, licensed under the Open Government Licence v3.0. Contains OS data © Crown copyright and database right 2026. Contains Royal Mail data © Royal Mail copyright and database right 2026. Via postcodes.io, accessed on 3 November 2026.
  • Local Authority Districts (May 2026) boundaries, BGC: Source: Office for National Statistics, licensed under the Open Government Licence v3.0. Contains OS data © Crown copyright and database right 2026. From the ONS Open Geography Portal, accessed on 3 November 2026.
  • Code: geocode_schools.py, tested with Python 3.12.0, geopy 2.5.0, geopandas 1.2.0, shapely 2.2.0, pyproj 3.8.0 and pandas 3.1.0.

Key terms

Geocoding
Converting a description of a place, such as an address, postcode or name, into coordinates. Also called forward geocoding.
Reverse geocoding
Converting coordinates into a description of what is there, such as the nearest address, postcode or named feature.
Nominatim
The open-source geocoder for OpenStreetMap data. The public server at nominatim.openstreetmap.org is free but limited to one request per second.
Postcode centroid
A single point that represents a postcode. In the ONS Postcode Directory, which postcodes.io serves, it is the position of a representative address in the postcode.
Distance error
The straight-line distance between a geocoded point and an authoritative position for the same place, measured here in metres in British National Grid.
Match level
What a geocoder actually matched: a building or named feature, a street, a postcode or a whole town. It predicts the error better than any confidence score.

Tutorial Python Part 2 of 3

Spatial joins in GeoPandas: sjoin, predicates, sjoin_nearest and point in polygon

A practical guide to spatial joins in GeoPandas with real Greater Manchester data: 13,187 NaPTAN bus stops, 1,386 OpenStreetMap schools, 215 ONS wards and 1,702 LSOAs. Covers point in polygon with sjoin, predicates, counts per polygon, how='left'/'inner'/'right', sjoin_nearest with max_distance, clip and overlay, and a timing comparison of a naive loop against the spatial index.

Beginner 12 min read

Four panels showing how Voronoi polygons are built for 47 rain gauges around the River Eden catchment: the gauges and the catchment outline, their Delaunay triangulation, the triangle circumcentres joined into red Voronoi edges, and the cells clipped to the catchment and shaded by each gauge's rainfall.

Tutorial Python

Voronoi (Thiessen) polygons in Python: catchment rainfall from EA rain gauges and fire station areas in Cumbria

Build Voronoi (Thiessen) polygons in Python with shapely and GeoPandas, clipped to a real boundary. Compute Thiessen-weighted catchment rainfall for the River Eden from Environment Agency rain gauge readings, check the gauges, compare with the gauge average and IDW, then compare Voronoi 'nearest fire station' areas in Cumbria with drive-time service areas from OpenStreetMap.

Intermediate 15 min read

Tutorial Python

GPS data analysis in Python with MovingPandas: trajectories, speed, stops, generalisation and flow maps

A MovingPandas tutorial on one day of real AIS vessel tracks in Puget Sound: 277,574 GPS fixes from 703 vessels turned into trajectories, with speed and direction, outlier cleaning, stop detection at ferry terminals, splitting into 148 trips, Douglas-Peucker against time-aware generalisation, a flow map of 319 aggregated flows, and reading and writing GPX.

Intermediate 14 min read