Fixing Local Authority Names That Do Not Match the Boundary File
On this page
Problem statement
You join a table of statistics by local authority to the official boundaries and a handful of areas come out blank on the map. The names look the same to a reader and differ to a computer. "CITY OF BRISTOL" and "Bristol, City of" are one place, and so are "RHONDDA CYNON TAFF" and "Rhondda Cynon Taf". Names also change when councils merge, so a table from one year and boundaries from another disagree for reasons that have nothing to do with spelling.
This page joins 2024 HM Land Registry Price Paid data, which names districts its own way, to the ONS local authority boundaries for December 2024. It finds every mismatch with an anti-join, fixes spelling and convention differences with a normalisation and a short lookup, and shows how district reorganisations break joins across years.
Quick answer
Find the mismatches on both sides before merging:
norm = lambda s: s.str.upper().str.replace(r"[^A-Z ]", " ", regex=True).str.replace(r"\s+", " ", regex=True).str.strip()
lr = pd.Series(sorted(A.district.unique())); ons = LAD[LAD.LAD24CD.str[0].isin(["E", "W"])]
only_lr = sorted(set(norm(lr)) - set(norm(ons.LAD24NM)))
only_ons = sorted(set(norm(ons.LAD24NM)) - set(norm(lr)))
print(f"Land Registry districts: {len(lr)}; ONS districts in England and Wales: {len(ons)}; exact matches without normalising: {len(set(lr) & set(ons.LAD24NM.str.upper()))}")
print(f"after normalising, unmatched on each side: {len(only_lr)} and {len(only_ons)}")
print("Land Registry only:", only_lr)
print("ONS only:", only_ons)
Land Registry districts: 318; ONS districts in England and Wales: 318; exact matches without normalising: 306
after normalising, unmatched on each side: 11 and 11
Land Registry only: ['CITY OF BRISTOL', 'CITY OF DERBY', 'CITY OF KINGSTON UPON HULL', 'CITY OF NOTTINGHAM', 'CITY OF PETERBOROUGH', 'CITY OF PLYMOUTH', 'CITY OF WESTMINSTER', 'HEREFORDSHIRE', 'RHONDDA CYNON TAFF', 'THE VALE OF GLAMORGAN', 'WREKIN']
ONS only: ['BRISTOL CITY OF', 'DERBY', 'HEREFORDSHIRE COUNTY OF', 'KINGSTON UPON HULL CITY OF', 'NOTTINGHAM', 'PETERBOROUGH', 'PLYMOUTH', 'RHONDDA CYNON TAF', 'TELFORD AND WREKIN', 'VALE OF GLAMORGAN', 'WESTMINSTER']
Both sides have 318 districts, and 306 match once the ONS names are upper-cased. Normalising punctuation leaves 11 on each side. They are the same places written differently: "CITY OF" before or after the name, "COUNTY OF" in the ONS name of Herefordshire, the Welsh spelling of Rhondda Cynon Taf, and "WREKIN" for Telford and Wrekin.
A lookup from the leftovers, then codes, changed districts
1. Build a lookup from the leftovers
Pair each leftover with its counterpart by hand. Sorting both lists and zipping them looks tempting, but "CITY OF DERBY" sorts before "HEREFORDSHIRE" on one side while "DERBY" sorts after "BRISTOL CITY OF" on the other, so the pairs slide out of step. Write the lookup out and review it:
FIX = {"CITY OF BRISTOL": "BRISTOL CITY OF", "CITY OF DERBY": "DERBY", "CITY OF KINGSTON UPON HULL": "KINGSTON UPON HULL CITY OF",
"CITY OF NOTTINGHAM": "NOTTINGHAM", "CITY OF PETERBOROUGH": "PETERBOROUGH", "CITY OF PLYMOUTH": "PLYMOUTH", "CITY OF WESTMINSTER": "WESTMINSTER",
"HEREFORDSHIRE": "HEREFORDSHIRE COUNTY OF", "RHONDDA CYNON TAFF": "RHONDDA CYNON TAF", "THE VALE OF GLAMORGAN": "VALE OF GLAMORGAN", "WREKIN": "TELFORD AND WREKIN"}
assert set(FIX) == set(only_lr) and set(FIX.values()) == set(only_ons)
key = norm(A.district).replace(FIX)
matched = key.isin(set(norm(ons.LAD24NM)))
print(f"sales whose district now matches a boundary: {matched.mean():.2%}")
sales whose district now matches a boundary: 100.00%
With the reviewed lookup every standard sale matches a district. The assert guards against a lookup that covers the wrong names; a 100% match rate alone does not, because any lookup that maps into valid names reaches 100%, right or wrong.
2. Join on codes from then on
Once matched, carry the ONS code, which is stable for an area until its boundaries change:
code = dict(zip(norm(ons.LAD24NM), ons.LAD24CD))
A["LAD24CD"] = key.map(code)
print(f"sales with an ONS code: {A.LAD24CD.notna().mean():.2%}; codes used: {A.LAD24CD.nunique()}")
print("examples:", A.drop_duplicates("district")[["district", "LAD24CD"]].head(3).values.tolist())
sales with an ONS code: 100.00%; codes used: 318
examples: [['LICHFIELD', 'E07000194'], ['TAMWORTH', 'E07000199'], ['STOKE-ON-TRENT', 'E06000021']]
Every sale now carries a code, and the 318 codes match the 318 districts.
3. Years with different districts
District reorganisations change the list itself. Compare the district names in the 2019 and 2024 files:
d19 = set(pd.read_csv(CSV19, header=None, usecols=[12])[12].unique()); d24 = set(lr)
print(f"district names in 2019: {len(d19)}; in 2024: {len(d24)}; in 2019 only: {len(d19 - d24)}; in 2024 only: {len(d24 - d19)}")
print("2019 only:", sorted(d19 - d24)[:8])
print("new 2023 unitary councils present in the 2019 file:", [n for n in ("CUMBERLAND", "WESTMORLAND AND FURNESS", "NORTH YORKSHIRE", "SOMERSET") if n in d19])
district names in 2019: 361; in 2024: 318; in 2019 only: 43; in 2024 only: 0
2019 only: ['ALLERDALE', 'AYLESBURY VALE', 'BARROW-IN-FURNESS', 'BOURNEMOUTH', 'CARLISLE', 'CHILTERN', 'CHRISTCHURCH', 'COPELAND']
new 2023 unitary councils present in the 2019 file: ['CUMBERLAND', 'WESTMORLAND AND FURNESS', 'NORTH YORKSHIRE', 'SOMERSET']
The 2019 file has 361 district names, 43 of them absent in 2024, such as Allerdale, Aylesbury Vale and Bournemouth, districts abolished in reorganisations from 2019 onwards. It also contains the four unitary councils created in 2023, so this 2019 file mixes old and new names, possibly because some records carry the district current when they were registered or amended. Joining across years needs a lookup from old districts to new ones, not a spelling fix.
What normalising alone achieves
The data used on this page
HM Land Registry Price Paid Data for 2019 and 2024 (© Crown copyright 2026, Open Government Licence v3.0), and the ONS Local Authority Districts (December 2024) boundaries from the ONS Open Geography Portal (© Crown copyright, OGL).
from pathlib import Path
import geopandas as gpd
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
def cached_pp(y):
f = Path(f"data/pp-{y}.csv")
if not f.exists():
r = requests.get(f"http://prod1.publicdata.landregistry.gov.uk.s3-website-eu-west-1.amazonaws.com/pp-{y}.csv", timeout=1800); r.raise_for_status(); f.write_bytes(r.content)
return f
CSV, CSV19 = cached_pp(2024), cached_pp(2019)
A = pd.read_csv(CSV, header=None, usecols=[1, 12, 14], names=["price", "district", "ppd_category"]).query("ppd_category == 'A'")
GJ = Path("data/lad_2024_bgc.geojson")
if not GJ.exists():
url = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query"
r = requests.get(url, params={"where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 4326, "f": "geojson"}, timeout=600); r.raise_for_status(); GJ.write_bytes(r.content)
LAD = gpd.read_file(GJ)
print(f"{len(A):,} standard sales; {len(LAD)} UK districts")
763,905 standard sales; 361 UK districts
The boundary file covers all 361 UK districts; England and Wales, with codes starting E or W, are the ones Price Paid Data covers.
Example 1: what normalising alone achieves
Count matches at each stage of cleaning:
stages = {"exact": (lr.isin(ons.LAD24NM)).sum(), "upper case": lr.isin(ons.LAD24NM.str.upper()).sum(),
"normalised": norm(lr).isin(set(norm(ons.LAD24NM))).sum(), "with lookup": norm(lr).replace(FIX).isin(set(norm(ons.LAD24NM))).sum()}
print(stages)
{'exact': np.int64(0), 'upper case': np.int64(306), 'normalised': np.int64(307), 'with lookup': np.int64(318)}
Exact matching finds nothing, because ONS names are in mixed case. Upper case brings 306, normalising punctuation one more, and the lookup the last 11.
Names are a fragile join key
Names are a fragile join key. Different organisations follow different style guides: Land Registry writes district names in capitals with a leading "CITY OF" or "THE", while ONS puts the descriptor after a comma, as in "Bristol, City of". Welsh names may be spelled with or without doubled consonants, and older names linger in one source after a reorganisation, such as "WREKIN" for Telford and Wrekin. An inner join drops all of these silently, and a left join leaves them as missing values that look like "no data" on a map.
The robust approach has three steps. Find mismatches on both sides with an anti-join before merging. Remove the differences in case, punctuation and spacing that carry no meaning. Then map the few remaining conventions with an explicit, reviewed lookup. After that, carry the official code, the nine-character GSS code for UK areas, and join on codes from then on. Codes still change when boundaries change, as they did when Cumberland, Westmorland and Furness, North Yorkshire and Somerset became unitary councils in 2023. Data from different years needs a lookup between old and new areas, not a spelling fix.
Fuzzy matching, Welsh names and counties
- Fuzzy matching: string-similarity matching can find candidates, but always review its pairs; similar names can be different places.
- Welsh names: ONS files also carry Welsh-language names (LAD24NMW) for Welsh districts.
- Postcodes: the ONS Postcode Directory assigns every postcode to a district code, avoiding names entirely.
- Counties vs districts: Land Registry's
countycolumn mixes metropolitan counties, shire counties and unitary authorities.
Internal links
- How to map house prices by local authority in Python — the map this join makes
- Fixing election results that won't join the boundaries — the same problem with wards
- Census boundary changes explained — when areas change
FAQ
Why are some areas blank after joining my data to boundaries?
Their names differ between the table and the boundary file; find them with an anti-join and normalise or map them.
How do I join data to local authority boundaries reliably?
Normalise names, map the remaining differences with a reviewed lookup, then join on the ONS GSS code.
Why do Land Registry district names differ from ONS names?
They follow different conventions, such as "CITY OF BRISTOL" versus "Bristol, City of", and some older names persist.
What happens when local authorities merge?
The old names and codes disappear; data from before and after needs a lookup from old to new areas.