The Covid-19 pandemic reshaped population patterns across the United States, as millions of residents relocated from dense urban centers to suburban and rural communities. These demographic shifts have not only redefined the spatial distribution of people across the U.S., but may have also influenced the political landscape of the nation.
In this analysis, I examine changes in population density at the county level from April 2020 to July 2024 using official estimates from the U.S. Census Bureau. Through the use of choropleth maps and datatables, I visualize percent change in population densities at the county and state levels, highlighting regions that experienced the most significant growth or decline.
To explore how these demographic movements may intersect with political realignment, I analyze county-level voting data from the 2020 and 2024 U.S. Presidential Elections, obtained from MIT's Election Data and Science Lab. By comparing shifts in voter preferences with changes in population density, this study aims to identify potential correlations between post-pandemic migration trends and partisan outcomes - particularly within key swing states that often determine the overall election result.
Ultimately, this analysis seeks to provide a data-driven perspective on how population mobility in the wake of Covid-19 may have reshaped the political geography of the United States, revealing broader relationships between demographic change, density, and electoral behavior.
In this section, I examine how population density changed across the United States between 2020 and 2024, highlighting the demographic shifts brought on by the Covid-19 pandemic. At the county level, I focus on percent changes in population density within key swing states to identify regions that experienced the most significant growth or decline. At the broader scale, I analyze state-level density changes for all 50 states to understand how these swing states compare nationally and whether similar migration patterns emerged across the country.
import pandas as pd
import numpy as np
import folium, requests
from branca.element import Element
# Load population data
pop = pd.read_csv("2020_vs_2024_pop_density_by_county.csv")
# Clean columns
pop = pop.rename(columns={"County":"county_name","2020":"pop_2020","2024":"pop_2024"})
pop["county_name"] = pop["county_name"].str.strip().str.replace("^\.", "", regex=True)
# Split into county + state
pop[["county_part","state_full"]] = pop["county_name"].str.split(", ", n=1, expand=True)
# Map state full names → abbreviations
state_map = {
'Alabama':'AL','Alaska':'AK','Arizona':'AZ','Arkansas':'AR','California':'CA','Colorado':'CO',
'Connecticut':'CT','Delaware':'DE','District of Columbia':'DC','Florida':'FL','Georgia':'GA',
'Hawaii':'HI','Idaho':'ID','Illinois':'IL','Indiana':'IN','Iowa':'IA','Kansas':'KS','Kentucky':'KY',
'Louisiana':'LA','Maine':'ME','Maryland':'MD','Massachusetts':'MA','Michigan':'MI','Minnesota':'MN',
'Mississippi':'MS','Missouri':'MO','Montana':'MT','Nebraska':'NE','Nevada':'NV','New Hampshire':'NH',
'New Jersey':'NJ','New Mexico':'NM','New York':'NY','North Carolina':'NC','North Dakota':'ND',
'Ohio':'OH','Oklahoma':'OK','Oregon':'OR','Pennsylvania':'PA','Rhode Island':'RI',
'South Carolina':'SC','South Dakota':'SD','Tennessee':'TN','Texas':'TX','Utah':'UT','Vermont':'VT',
'Virginia':'VA','Washington':'WA','West Virginia':'WV','Wisconsin':'WI','Wyoming':'WY'
}
pop["state_abbr"] = pop["state_full"].map(state_map)
# Clean county names
pop["county_name_clean"] = (
pop["county_part"]
.str.replace(" County","",regex=False)
.str.replace(" Parish","",regex=False)
.str.replace(" Borough","",regex=False)
.str.replace(" Census Area","",regex=False)
.str.strip()
+ ", " + pop["state_abbr"]
)
# Convert pop columns to numeric
pop["pop_2020"] = pd.to_numeric(pop["pop_2020"].replace(",","",regex=True), errors="coerce")
pop["pop_2024"] = pd.to_numeric(pop["pop_2024"].replace(",","",regex=True), errors="coerce")
# FIPS reference
fips = pd.read_csv("https://raw.githubusercontent.com/kjhealy/fips-codes/master/state_and_county_fips_master.csv")
fips["county_name_clean"] = (
fips["name"]
.str.replace(" County","",regex=False)
.str.replace(" Parish","",regex=False)
.str.replace(" Borough","",regex=False)
.str.replace(" Census Area","",regex=False)
.str.strip()
+ ", " + fips["state"]
)
fips["county_fips"] = fips["fips"].astype(str).str.zfill(5)
merged = pop.merge(fips[["county_name_clean","county_fips"]], on="county_name_clean", how="left")
# Land area
area = pd.read_csv("GEOINFO2023.GEOINFO-Data.csv", dtype=str)
area["county_fips"] = area["GEO_ID"].str[-5:]
area["land_area_sqmi"] = pd.to_numeric(area["AREALAND_SQMI"], errors="coerce")
merged = merged.merge(area[["county_fips","land_area_sqmi"]], on="county_fips", how="left")
# Compute true densities and % change
merged["pop_density_2020_true"] = merged["pop_2020"] / merged["land_area_sqmi"]
merged["pop_density_2024_true"] = merged["pop_2024"] / merged["land_area_sqmi"]
merged["density_change_pct"] = (
(merged["pop_density_2024_true"] - merged["pop_density_2020_true"])
/ merged["pop_density_2020_true"]
) * 100
elec = pd.read_csv("county_election_results_2020_2024.csv")
elec = elec[elec["office"] == "US PRESIDENT"].copy()
def rep_share(df, year):
d = df[df["year"] == year]
pivot = d.pivot_table(index="county_fips", columns="party", values="candidatevotes", aggfunc="sum", fill_value=0).reset_index()
pivot["rep_share_" + str(year)] = pivot.get("REPUBLICAN", 0) / (pivot.get("REPUBLICAN", 0) + pivot.get("DEMOCRAT", 0))
pivot["county_fips"] = pivot["county_fips"].astype(str).str.strip().apply(lambda x: str(int(float(x))).zfill(5))
return pivot[["county_fips","rep_share_" + str(year)]]
rep2020 = rep_share(elec, 2020)
rep2024 = rep_share(elec, 2024)
vote = rep2020.merge(rep2024, on="county_fips", how="inner")
vote["vote_shift_pct"] = (vote["rep_share_2024"] - vote["rep_share_2020"]) * 100
merged_all = merged.merge(vote, on="county_fips", how="inner")
swing_states = ['AZ','GA','MI','NV','NC','PA','WI']
merged_all = merged_all[merged_all["state_abbr"].isin(swing_states)].copy()
import numpy as np
import folium
import requests
import branca
# Load county boundaries
geojson_url = "https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json"
counties_geojson = requests.get(geojson_url).json()
# Column to map
col = "density_change_pct"
# Symmetric range around 0
max_abs = float(np.nanmax(np.abs(merged_all[col])))
vmin, vmax = -max_abs, max_abs
# Custom red–white–green colormap (no yellow/orange), centered at 0
cmap = branca.colormap.LinearColormap(
colors=["red", "white", "green"],
vmin=vmin, vmax=vmax
)
cmap.caption = "% Change in Population Density (2020→2024)" # legend title
# Value lookups for styling & tooltip
name_map = merged_all.set_index("county_fips")["county_name"].to_dict()
val_map = merged_all.set_index("county_fips")[col].to_dict()
# Make map
m_density = folium.Map(location=[37.8, -96], zoom_start=4, tiles="cartodbpositron")
# GeoJson layer with custom color per feature
def style_fn(feature):
fips = feature["id"]
v = val_map.get(fips, None)
if v is None or (isinstance(v, float) and np.isnan(v)):
return {"fillColor": "lightgrey", "color": "black", "weight": 0.2, "fillOpacity": 0.6}
return {"fillColor": cmap(v), "color": "black", "weight": 0.2, "fillOpacity": 0.8}
# Add tooltip fields to features
for f in counties_geojson["features"]:
fips = f["id"]
f["properties"]["county_name"] = name_map.get(fips, "Unknown")
v = val_map.get(fips)
f["properties"]["density_chg"] = "No data" if v is None or (isinstance(v, float) and np.isnan(v)) else f"{v:+.1f}%"
gj = folium.GeoJson(
counties_geojson,
style_function=style_fn,
tooltip=folium.GeoJsonTooltip(
fields=["county_name","density_chg"],
aliases=["County","Δ Density (%)"],
localize=True,
),
name="Population Density Change (2020–2024)"
).add_to(m_density)
# Title
title_html = """
<h3 align="center" style="font-size:20px;font-weight:bold;margin-top:10px">
Swing States — % Change in Population Density (2020–2024)
</h3>
"""
m_density.get_root().html.add_child(folium.Element(title_html))
# Legend
cmap.add_to(m_density)
# Display & save
display(m_density)
m_density.save("swing_states_density_change.html")
Each shape represents a county. The colors show how population density changed from 2020 to 2024 within the swing states — green counties became more densely populated, while red counties saw declines in density. Darker shades indicate a stronger change. This helps highlight where population growth or loss was most concentrated across key battleground states.
df_all = merged.copy()
# Compute total population & land area per state
state_density_all = (
df_all.groupby("state_abbr")
.agg({
"pop_2020": "sum",
"pop_2024": "sum",
"land_area_sqmi": "sum"
})
.reset_index()
)
# Compute 2020 & 2024 population density
state_density_all["density_2020"] = state_density_all["pop_2020"] / state_density_all["land_area_sqmi"]
state_density_all["density_2024"] = state_density_all["pop_2024"] / state_density_all["land_area_sqmi"]
# Compute % change
state_density_all["density_change_pct"] = (
(state_density_all["density_2024"] - state_density_all["density_2020"])
/ state_density_all["density_2020"]
) * 100
# Sort descending by change
state_density_all = state_density_all.sort_values("density_change_pct", ascending=False).reset_index(drop=True)
# Add rank & clean up output
state_density_all["Rank"] = state_density_all.index + 1
state_density_all = state_density_all[["Rank", "state_abbr", "density_change_pct"]]
# Print formatted list
pd.set_option("display.float_format", lambda x: f"{x:.2f}%")
print(state_density_all.to_string(index=False))
This table ranks all 50 states by their percent change in population density from 2020 to 2024. States near the top experienced the fastest growth, meaning their populations increased relative to land area, while those near the bottom saw little growth or declines in density. In other words, it shows which states experienced the fastest population growth and which saw slower or declining density after the Covid-19 pandemic.
When looking at our swing states, we can see they ranked as following:
AZ - 7
NC - 8
NV - 9
GA - 12
WI - 35
MI - 41
PA - 43
The majority of swing states ranked in the upper half, indicating stronger population growth between 2020 and 2024 nationally. States like Arizona, North Carolina, Nevada, and Georgia all experienced some of the highest increases in density, reflecting continued in-migration and expansion in key Sun Belt regions. In contrast, Wisconsin, Michigan, and Pennsylvania ranked lower, showing slower growth consistent with broader population stagnation in parts of the Midwest and Northeast.
Having explored patterns of population growth and migration, we next will analyze changes in partisan voting between 2020 and 2024.
This section explores how voting behavior evolved between the 2020 and 2024 U.S. Presidential Elections, with a focus on changes in Republican vote share at both the county and state level within key swing states. By quantifying percent change in vote share, this analysis highlights where partisan support strengthened or weakened over time. Examining these shifts alongside population changes offers insight into how demographic movement may have intersected with political realignment across pivotal regions of the country.
# Column and symmetric color bins
col = "vote_shift_pct"
max_abs = np.nanmax(np.abs(merged_all[col]))
qbins = np.linspace(-max_abs, max_abs, 7) # Symmetric bins centered on 0
# Create map
m_vote = folium.Map(location=[37.8, -96], zoom_start=4, tiles="cartodbpositron")
# Choropleth layer
folium.Choropleth(
geo_data=counties_geojson,
name="Republican Vote Share Change (2020–2024)",
data=merged_all,
columns=["county_fips", col],
key_on="feature.id",
fill_color="RdBu_r", # Red = GOP gain, Blue = Dem gain
fill_opacity=0.8,
line_opacity=0.2,
bins=qbins, # Symmetric bins ensure 0 is midpoint
nan_fill_color="lightgrey",
legend_name="Change in Republican Vote Share (p.p.) 2020→2024",
).add_to(m_vote)
# Tooltip setup
name_map = merged_all.set_index("county_fips")["county_name"].to_dict()
val_map = merged_all.set_index("county_fips")[col].round(1).to_dict()
for f in counties_geojson["features"]:
fips = f["id"]
f["properties"]["county_name"] = name_map.get(fips, "Unknown")
v = val_map.get(fips)
f["properties"]["vote_shift"] = "No data" if v is None or np.isnan(v) else f"{v:+.1f} p.p."
folium.GeoJson(
counties_geojson,
style_function=lambda x: {"fillOpacity": 0, "weight": 0},
tooltip=folium.GeoJsonTooltip(
fields=["county_name", "vote_shift"],
aliases=["County", "Δ Republican Share (p.p.)"],
localize=True,
),
).add_to(m_vote)
# Title
title_html = """
<h3 align="center" style="font-size:20px;font-weight:bold;margin-top:10px">
Swing States — Change in Republican Vote Share (2020–2024)
</h3>
"""
m_vote.get_root().html.add_child(folium.Element(title_html))
# Display and save map
display(m_vote)
m_vote.save("swing_states_vote_shift.html")