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}
County map of the 2024 presidential margin produced by a naive join with FIPS codes read as integers. California, Arizona, Colorado, Arkansas, Alabama, Connecticut and all of Alaska are solid grey because no county matched; the rest of the country is coloured normally, including Hawaii. County map of the 2024 presidential margin produced by a naive join with FIPS codes read as integers. California, Arizona, Colorado, Arkansas, Alabama, Connecticut and all of Alaska are solid grey because no county matched; the rest of the country is coloured normally, including Hawaii.
What the join produces when FIPS codes are read as numbers: 319 of 3,144 counties unmatched. Data: county results compiled by tonmcg; US Census Bureau boundaries.

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.

Which kind of join failure is this? what does the join show?After an outer join with an indicatorwhole states unmatchedCodes as numbersdtype=str, zfill(5)a few rows unmatchedDifferent unitsaggregate or switch vintageall matchedCheck the namesequal codes, different placesThe third branch is the one that raises nothing spatialworkflow.io
The third case raises no error at all.

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.

Join outcomes for each failure reproduced on this page. what each join producedmatchedunmatchedwrong matchFIPS as integers2,825βœ•319–DC wards17βœ•ward 1 = all DCAlaska districts337βœ•3 boroughsCT 2020 results0βœ•8 + 9–UK by name649!1 (GlyndΕ΅r)–UK by ONS code: 650 of 650 spatialworkflow.io
What each join produced. Joined on ONS codes, all 650 UK seats match.

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
A join procedure for election results and boundaries. join, then inspectkeys as textnever intouter +indicatorvalidate one_to_onelist unmatchedboth sidescompare namesin matched rowsdecide eachcaseaggregate, drop,re-vintageUS 2024: 3,116 matched, 28 shapes and 44 results left over spatialworkflow.io
Join, then inspect. For the US 2024 file: 3,116 matched, 28 shapes and 44 results left over.

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.

Why election results and boundaries disagree. two lists, two publishersresults reportβœ“the units votes are counted inβœ“wards, districts, regionsβœ“on the electoral calendarboundaries describeβœ“the units an agency maintainsβœ“counties, census areasβœ“on their own revision cycle99% overlap is what makes the 1% easy to miss spatialworkflow.io
Two lists from two publishers.

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

Election join failures and their fixes. join failuresSymptomFix7 states blankread FIPS with dtype=strAnchorage shows a house districtAlaska as one statewide unitConnecticut blank in 2020boundaries of the same vintageone UK seat missingjoin on ONS codes, not namesUS 2020/2024 counties, UK 2024 constituencies spatialworkflow.io
Election join failures and their fixes.
  • 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.

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.