Election Results Won't Join the Boundaries: Codes, Wards and Renamed Places
On this page
Problem statement
You join election results to boundary polygons and part of the map comes out blank. Or worse, it comes out full but wrong. Election results and boundaries come from different organisations on different schedules, and the key that should connect them is fragile. FIPS codes lose their leading zeros. Places report in units that are not the boundary units. One code can mean two places. Names gain or lose an accent between files. The join raises no error in any of these cases.
This page reproduces each failure with real files: US county results for the 2020 and 2024 presidential elections against the Census Bureau's 2023 county boundaries, and the UK 2024 general election against the ONS constituency boundaries. Then it fixes them.
Quick answer
Read codes as strings, do an outer join with indicator=True and validate, and look at every unmatched row and every suspicious match before mapping:
res = pd.read_csv("data/2024_US_County_Level_Presidential_Results.csv", dtype={"county_fips": str})
m = counties.merge(res, left_on="GEOID", right_on="county_fips", how="outer", indicator=True, validate="one_to_one")
print(m._merge.value_counts().to_dict())
print("unmatched results by state:", m[m._merge == "right_only"].state_name.value_counts().to_dict())
print("unmatched shapes by state:", m[m._merge == "left_only"].STATEFP.map(STATE).value_counts().to_dict())
{'both': 3116, 'right_only': 44, 'left_only': 28}
unmatched results by state: {'Alaska': 37, 'District of Columbia': 7}
unmatched shapes by state: {'AK': 27, 'HI': 1}
Numeric codes, shared codes, changed units, near-matching names
1. Codes read as numbers
A FIPS code is a five-character identifier, not a number. Read it with the default parser and the first seven states lose their leading zero:
naive = pd.read_csv("data/2024_US_County_Level_Presidential_Results.csv")
print("parsed as", naive.county_fips.dtype, "->", naive.county_fips.head(2).tolist())
bad = counties.merge(naive.assign(key=naive.county_fips.astype(str)), left_on="GEOID", right_on="key", how="left")
lost = bad[bad.total_votes.isna()]
print(f"{len(lost)} of {len(counties)} counties unmatched; states: {sorted(lost.STATEFP.map(STATE).unique())}")
parsed as int64 -> [1001, 1003]
319 of 3144 counties unmatched; states: ['AK', 'AL', 'AR', 'AZ', 'CA', 'CO', 'CT', 'HI']
Fix: dtype={"county_fips": str}, or str.zfill(5) if the file is already damaged.
2. One code, two places
DC reports its eight wards with codes 11001β11008, and 11001 is also DC's county code. Alaska reports by state house district, 02001β02040, and some of those numbers are borough codes:
ok = m[m._merge == "both"]
print("DC row that matched:", ok[ok.GEOID == "11001"][["NAME", "county_name", "total_votes"]].values.tolist(),
f"of {res[res.state_name == 'District of Columbia'].total_votes.sum():,} DC votes")
print("Alaska 'matches':", ok[ok.STATEFP == "02"][["GEOID", "NAME", "county_name"]].values.tolist())
DC row that matched: [['District of Columbia', 'Ward 1', 40463.0]] of 325,869 DC votes
Alaska 'matches': [['02013', 'Aleutians East', 'State House District 13'], ['02016', 'Aleutians West', 'State House District 16'], ['02020', 'Anchorage', 'State House District 20']]
These rows pass every join check: the codes are equal and unique. Only reading the names side by side shows that Anchorage got a house district's result. Fix: aggregate DC's wards, and map Alaska as one unit, since it has no county-level presidential results.
3. The units changed
Connecticut abolished its counties as administrative units long ago. In 2022 the Census Bureau replaced them with nine planning regions as county-equivalents. Results compilations followed in different years:
r20 = pd.read_csv("data/2020_US_County_Level_Presidential_Results.csv", dtype={"county_fips": str})
ct = counties[counties.STATEFP == "09"]
for year, r in ((2020, r20), (2024, res)):
codes = set(r[r.state_name == "Connecticut"].county_fips)
print(f"{year}: Connecticut result codes {sorted(codes)[:3]}...; matching 2023 shapes: {len(codes & set(ct.GEOID))} of {len(codes)}")
2020: Connecticut result codes ['09001', '09003', '09005']...; matching 2023 shapes: 0 of 8
2024: Connecticut result codes ['09110', '09120', '09130']...; matching 2023 shapes: 9 of 9
Fix: join each year to boundaries of the same vintage, or, for comparisons across years, aggregate to a unit both share β the state.
4. Names that nearly match
UK results have both names and ONS codes. Join on names and one seat drops out:
by_name = bounds.merge(uk, left_on="PCON24NM", right_on="Constituency name", how="outer", indicator=True)
print(by_name._merge.value_counts().to_dict())
print(by_name[by_name._merge != "both"][["PCON24NM", "Constituency name"]].values.tolist())
by_code = bounds.merge(uk, left_on="PCON24CD", right_on="Constituency geographic code", how="outer", indicator=True)
print("by code:", by_code._merge.value_counts().to_dict())
{'both': 649, 'left_only': 1, 'right_only': 1}
[['Montgomeryshire and Glyndwr', nan], [nan, 'Montgomeryshire and GlyndΕ΅r']]
by code: {'both': 650, 'left_only': 0, 'right_only': 0}
The generalised ONS boundary file spells GlyndΕ΅r without its circumflex. Codes exist to make joins independent of spelling; use them. If a source has only names, normalise them before joining: case, accents, "&" against "and", and punctuation.
5. Shapes with no result
print(m[m._merge == "left_only"][["GEOID", "NAME", "STATEFP"]].query("STATEFP != '02'").values.tolist())
[['15005', 'Kalawao', '15']]
Kalawao County, Hawaii, a former leprosy settlement with a population under a hundred, has a shape but no separate result; its votes are counted with Maui. Leave it grey and say why, or merge its shape into Maui's.
A reusable join check
The data used on this page
County-level presidential results for 2020 and 2024 from tonmcg/US_County_Level_Election_Results_08-24; US Census Bureau cartographic boundary files, 2023, 1:20 million; 2024 UK general election results from the House of Commons Library; constituency boundaries (July 2024, ultra-generalised) from the ONS Open Geography Portal.
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
TON = "https://raw.githubusercontent.com/tonmcg/US_County_Level_Election_Results_08-24/master/"
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/Westminster_Parliamentary_Constituencies_"
"July_2024_Boundaries_UK_BUC/FeatureServer/0/query?where=1%3D1&outFields=PCON24CD,PCON24NM&outSR=27700&f=geojson")
SOURCES = {"2020_US_County_Level_Presidential_Results.csv": TON + "2020_US_County_Level_Presidential_Results.csv",
"2024_US_County_Level_Presidential_Results.csv": TON + "2024_US_County_Level_Presidential_Results.csv",
"cb_2023_us_county_20m.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip",
"uk2024_elections.csv": "https://electionresults.parliament.uk/general-elections/6/elections.csv",
"pcon2024_buc.geojson": ONS}
for name, url in SOURCES.items():
if not Path("data", name).exists():
Path("data", name).write_bytes(requests.get(url, timeout=300).content)
counties = gpd.read_file("data/cb_2023_us_county_20m.zip")
counties = counties[~counties.STATEFP.isin(["60", "66", "69", "72", "78"])]
STATE = counties.drop_duplicates("STATEFP").set_index("STATEFP").STUSPS
uk = pd.read_csv("data/uk2024_elections.csv")
bounds = gpd.read_file("data/pcon2024_buc.geojson")
print(f"{len(counties):,} county shapes; {len(uk)} UK seats; {len(bounds)} UK boundaries")
3,144 county shapes; 650 UK seats; 650 UK boundaries
Example 1: a join check you can reuse
def check_join(left, right, lk, rk, label_l, label_r):
out = left.merge(right, left_on=lk, right_on=rk, how="outer", indicator=True)
n = out._merge.value_counts()
dup = right[rk].duplicated().sum() + left[lk].duplicated().sum()
print(f"matched {n.get('both', 0)}, only {label_l} {n.get('left_only', 0)}, only {label_r} {n.get('right_only', 0)}, duplicate keys {dup}")
return out
_ = check_join(counties, res, "GEOID", "county_fips", "shapes", "results")
_ = check_join(bounds, uk, "PCON24CD", "Constituency geographic code", "boundaries", "results")
matched 3116, only shapes 28, only results 44, duplicate keys 0
matched 650, only boundaries 0, only results 0, duplicate keys 0
Example 2: normalise names when codes are missing
import unicodedata
norm = lambda s: unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().lower().replace("&", "and").strip()
fixed = bounds.assign(k=bounds.PCON24NM.map(norm)).merge(uk.assign(k=uk["Constituency name"].map(norm)), on="k", how="outer", indicator=True)
print("normalised names:", fixed._merge.value_counts().to_dict())
normalised names: {'both': 650, 'left_only': 0, 'right_only': 0}
Counting units against statistical units
Election results are published by electoral authorities in the units they count: counties, wards, townships, legislative districts, constituencies. Boundaries are published by statistical or mapping agencies in the units they maintain, on their own revision cycles. The two lists overlap most of the time, which is exactly why the mismatches are easy to miss. A join that matches 99% of rows looks like success.
The defence is to treat the join as a step with its own output to inspect. Read keys as text. Join outer, with an indicator, and validate one-to-one. List every unmatched row on both sides. Look for codes that match but mean different things, by comparing names in matched rows, especially in places known to be unusual. Then decide each case on purpose: aggregate, drop with a note, or switch to a boundary vintage that matches.
Excel, boundary vintages and notional results
- Excel strips leading zeros on open; never round-trip FIPS through a spreadsheet unformatted.
- Virginia's independent cities are county-equivalents with their own codes; they look like duplicates of the surrounding county but are not.
- Boundary vintages: county changes are rare but real (Connecticut 2022; Chugach and Copper River in Alaska 2019).
- UK boundary sets changed in 2024; 2019 results use the old constituencies and codes.
- Notional results (old votes estimated on new boundaries) exist for UK 2019β2024; label them as estimates.
- Write-in and "other" rows in some results files carry their own codes; filter before joining.
Internal links
- How to make a US county election map β the fixes applied
- How to make an election swing map β matching units across years
- How to make a UK election hex map β joining on ONS codes
- Join attribute data in GeoPandas β joins in general
FAQ
Why are some states missing from my county election map?
The FIPS codes were read as numbers, dropping the leading zero for states 01β09. Read them with dtype=str.
Why does Washington DC show only one ward's result?
DC reports eight wards, and ward 1 uses DC's county code 11001; sum the wards before joining.
Why is Connecticut blank in my 2020 county map?
2020 results use the eight old counties; the Census Bureau's boundaries since 2022 use nine planning regions. Join to boundaries from the same period.
Should I join election results by name or by code?
By code. Names differ between sources in accents, abbreviations and punctuation; in 2024 one UK seat fails a name join for a missing circumflex.