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
- `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.
- 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.
- postcodes.io found all 192 schools' postcodes in two bulk requests, with a median error of 88 m and 95% within 250 m.
- 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.
- 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:
- load the addresses and the reference coordinates;
- build a polite, cached Nominatim client with geopy;
- geocode each school three ways: free-text address, structured address and name;
- look up every postcode in bulk with postcodes.io;
- measure the distance errors and see where and why they occur;
- cross-check the two geocoders to catch bad matches without reference data;
- reverse geocode a sample.
All the code is in geocode_schools.py, which you can download with its pinned requirements.
The public Nominatim server runs on donated hardware. Its usage policy allows an absolute maximum of 1 request per second from a single thread on one machine. It requires a User-Agent that identifies your application, not the library default, and it requires that results are cached so the same query is never repeated. Smaller one-time bulk jobs like this one, of a few hundred addresses, may be acceptable. Regular or large bulk geocoding is not, and nor are autocomplete or systematic grid queries. Do not send personal data such as customers’ home addresses to the public server. For any of those, run your own Nominatim instance or use a commercial geocoder.
Set up
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txt # geopy 2.5.0, geopandas 1.2.0, pandas 3.1.0, ...python geocode_schools.pyThe 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:
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.
open establishments in Sheffield in GIAS: 192 (secure units left out) URN name Street Locality Town Postcode Easting Northing106973 Broomhall Nursery School 4 Broomhall Road Sheffield S10 2DN 434273.0 386604.0106975 Grace Owen Nursery School 1-7 the Pavement Duke Street Sheffield S2 5QD 436122.0 387343.0106987 Brightside Nursery Infant School Jenkin Road Sheffield S9 1AS 438585.0 390675.0106988 Carter Knowle Junior School Carter Knowle Road Sheffield S7 2DY 433952.0 384093.0Notice 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.
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:
from geopy.extra.rate_limiter import RateLimiterfrom 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":
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:
Nominatim: 576 forward queries, 559 sent live, 17 from the cache, 742 slive 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:
Nominatim: 576 forward queries, 0 sent live, 576 from the cache, 0 sStep 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:
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 postcodeAll 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:
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)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_1kmmethodNominatim free text 183/192 102.2 270.0 18.6 48.6 85.2 1Nominatim structured 189/192 104.2 272.3 18.0 47.1 84.7 2Nominatim by name 175/192 43.1 138.8 56.0 82.3 96.0 4postcodes.io postcode 192/192 88.4 199.6 27.6 56.8 94.8 1Report 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.
The ranking is not what most people expect. The full postal address was the least accurate input. The reason is in what Nominatim matched:
size median p90addresstyperesidential 98 105.0 269.0tertiary 38 126.0 351.0unclassified 14 85.0 183.0secondary 10 90.0 205.0amenity 10 25.0 371.0primary 9 94.0 512.0trunk 4 96.0 218.0Only 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.
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:
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=withbounded=True, or check the returneddisplay_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 (
addresstypecity), 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:
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 centroidAGREE = 250accept = nm.agree_m <= AGREEcombo = pc.copy()combo.loc[accept, ["lat", "lon", "error_m"]] = nm.loc[accept, ["lat", "lon", "error_m"]]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 mcombined rule (name match if within 250 m of the postcode centroid, else postcode centroid): 158 from Nominatim by name, 34 from postcodes.iofound 192/192median_m 46.157467p90_m 171.030545within_50m 53.125within_100m 78.125within_250m 94.270833over_1km 1The 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.
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:
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).
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 awaynom_classamenity=school 24emergency=defibrillator 3amenity=grit_bin 2building=school 2amenity=college 2building=semidetached_house 1highway=residential 1amenity=restaurant 1
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.
Is it legal to store geocoding results from Nominatim?
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
- Use geocoded points in a spatial join with GeoPandas to count schools, shops or patients per ward.
- Measure walking distances from each geocoded school along the street network with street networks with OSMnx.
- Turn geocoded demand and supply points into accessibility scores with two-step floating catchment area (2SFCA) in Python.
- Put the results on an interactive map with interactive web maps with Folium, crediting OpenStreetMap in the attribution.
- Work with points that move rather than addresses in GPS data analysis in Python with MovingPandas.
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.
Data for this tutorial
- Get Information about Schools (GIAS) · Department for Education all-establishments extract, with an easting and northing for every school
- postcodes.io · Free postcode lookup API built on the ONS Postcode Directory
- OpenStreetMap · The data behind Nominatim's forward and reverse geocoding results
- ONS Open Geography Portal · Local Authority Districts (May 2026) boundary for Sheffield, used for the maps
Licences and formats for each source are listed in the GIS data sources catalogue.
Need this done on your own data? TechnicalGIS takes on consulting projects like this one, from a single analysis to a pipeline your team can rerun.