County Presidential Voting Patterns: 2016, 2020, and 2024
Introduction
In this post, we track how U.S. counties voted across the last three presidential elections—2016, 2020, and 2024—grouping them by their sequence of party winners and mapping the results. We then look at the shift in Democratic two-party vote share from 2020 to 2024, analyzing the scale of the erosion and how it varied across different turnout levels.
Data
Election results come from the tonmcg/US_County_Level_Election_Results_08-24
repository, which provides county-level presidential
returns from 2008 to 2024. County geometries are
downloaded via tigris.
conus <- c(setdiff(state.abb, c("AK", "HI")))
counties_sf <- tigris::counties(cb = TRUE, resolution = "20m", year = 2020) |>
filter(STUSPS %in% conus) |>
sf::st_transform(5070) |>
select(GEOID, geometry)
states_sf <- tigris::states(cb = TRUE, resolution = "20m", year = 2020) |>
filter(STUSPS %in% conus) |>
sf::st_transform(5070)
# tonmcg/US_County_Level_Election_Results_08-24
read_tonmcg <- function(file, yr) {
df <- read.csv(file)
fips_col <- if ("county_fips" %in% names(df)) "county_fips" else "combined_fips"
state_col <- if ("state_name" %in% names(df)) "state_name" else "state_abbr"
df |>
mutate(GEOID = str_pad(.data[[fips_col]], 5, pad = "0"), year = yr) |>
select(GEOID, year, democrat = votes_dem, republican = votes_gop,
county_name, state = all_of(state_col))
}
base_url <- "https://raw.githubusercontent.com/tonmcg/US_County_Level_Election_Results_08-24/master/"
votes <- bind_rows(
read_tonmcg(paste0(base_url, "2016_US_County_Level_Presidential_Results.csv"), 2016),
read_tonmcg(paste0(base_url, "2020_US_County_Level_Presidential_Results.csv"), 2020),
read_tonmcg(paste0(base_url, "2024_US_County_Level_Presidential_Results.csv"), 2024)
) |> filter(!str_sub(GEOID, 1, 2) %in% c("02", "15"),
as.integer(str_sub(GEOID, 1, 2)) <= 56)Sample from 2024; data structured identically for 2016 and 2020.
Build Voting Categories
We pivot the data wide so each county has one column per
election year, then concatenate the three results into a
single label – e.g., D-D-R for a county that
voted Democrat in 2016 and 2020 and flipped Republican in
2024.
patterns <- votes |>
mutate(party = ifelse(republican > democrat, "R", "D")) |>
select(GEOID, year, party) |>
pivot_wider(names_from = year, values_from = party, names_prefix = "y") |>
filter(!is.na(y2016), !is.na(y2020), !is.na(y2024)) |>
mutate(pattern = paste(y2016, y2020, y2024, sep = "-"))
label_map <- c(
"R-R-R" = "Trump-Trump-Trump",
"D-D-D" = "Clinton-Biden-Harris",
"D-D-R" = "Clinton-Biden-Trump", # held D through 2020, flipped R in 2024
"R-D-R" = "Trump-Biden-Trump", # flipped D in 2020, swung back in 2024
"R-D-D" = "Trump-Biden-Harris", # flipped D in 2020, held D in 2024
"D-R-R" = "Clinton-Trump-Trump", # flipped R in 2020, held R in 2024
"R-R-D" = "Trump-Trump-Harris" # held R through 2020, flipped D in 2024
)
pattern_counts <- patterns |>
count(pattern, sort = TRUE) |>
mutate(candidates = label_map[pattern])
DT::datatable(pattern_counts, rownames = FALSE,
caption = "County counts by three-election voting pattern",
options = list(pageLength = 10, dom = 't', scrollX = TRUE))Map Voting Patterns
Each county is colored by its three-election voting
sequence, from 2016 through 2024. R-R-R
and D-D-D together account for the large majority
of counties, with flip patterns scattered across
competitive regions.
map_data <- counties_sf |>
left_join(patterns, by = "GEOID") |>
filter(!is.na(pattern))
pattern_colors <- c(
"R-R-R" = "#d62728",
"R-D-R" = "#ff7f0e",
"D-D-D" = "#1f77b4",
"D-R-R" = "#f7b6b6",
"D-D-R" = "#ffbb78",
"R-R-D" = "#ffbb78",
"D-R-D" = "#98df8a",
"R-D-D" = "#aec7e8"
)
map_data |>
mutate(pattern = factor(pattern, levels = names(pattern_colors))) |>
ggplot() +
geom_sf(aes(fill = pattern), color = "white", linewidth = 0.05) +
geom_sf(data = states_sf, fill = NA, color = "white", linewidth = 0.3) +
scale_fill_manual(
values = pattern_colors,
na.value = "grey85",
name = "2016-2020-2024"
) +
labs(title = "County Voting Patterns: 2016, 2020, and 2024") +
theme_void() +
theme(
plot.title = element_text(size = 16),
legend.position = "bottom"
)Pattern Breakdown
The bar chart below shows the count of counties by voting
pattern, ordered from most to least common. Beyond
R-R-R and D-D-D:
D-D-R(54 counties) – held Democratic through 2020, broke for Trump in 2024R-D-D(33 counties) – flipped Democratic in 2020 and held through 2024R-D-R(32 counties) – classic swing-back; briefly went for Biden in 2020, returned to Trump in 2024D-R-R(14 counties) – flipped Republican in 2020 and stayed there
patterns |>
count(pattern) |>
mutate(label = label_map[pattern],
label = fct_reorder(label, n), # order bars by count
fill = pattern_colors[pattern]) |> # match map colors
ggplot(aes(x = label, y = n, fill = pattern)) +
geom_col(alpha = 0.85) +
geom_text(aes(label = comma(n)), hjust = -0.2, size = 3.5) +
coord_flip() +
scale_fill_manual(values = pattern_colors, breaks = names(pattern_colors)) +
scale_y_continuous(labels = comma, expand = c(0, 0),
limits = c(0, max(pattern_counts$n) * 1.12)) +
labs(title = "Counties by Three-Election Voting Pattern",
x = NULL, y = "Number of Counties") +
theme_minimal() +
theme(legend.position = "none",
plot.title = element_text(size = 16))Margin Shifts: 2020 to 2024
The map below shows the change in Democratic two-party vote share between 2020 and 2024; red indicates a shift toward Republicans, blue toward Democrats. Nearly the entire map runs some shade of red.
margins <- votes |>
filter(year %in% c(2020, 2024)) |>
mutate(dem_2party = democrat / (democrat + republican) * 100) |>
select(GEOID, year, dem_2party) |>
pivot_wider(names_from = year, values_from = dem_2party, names_prefix = "dem_") |>
mutate(shift = dem_2024 - dem_2020) |>
filter(!is.na(shift))
counties_sf |>
left_join(margins, by = "GEOID") |>
filter(!is.na(shift)) |>
ggplot() +
geom_sf(aes(fill = shift), color = "white", linewidth = 0.05) +
geom_sf(data = states_sf, fill = NA, color = "white", linewidth = 0.3) +
scale_fill_gradient2(
low = "#d62728", mid = "white", high = "#1f77b4",
midpoint = 0, limits = c(-10, 10), oob = scales::squish,
name = "Margin shift\n(percentage points)",
labels = scales::label_number(style_positive = "plus")
) +
labs(title = "Shift in Democratic Vote Margin: 2020 to 2024") +
theme_void() +
theme(plot.title = element_text(size = 16),
legend.position = "bottom")The map shows where the shift happened; the scatter below asks whether it was evenly distributed across county sizes. Each point is a county; the loess curve summarizes the relationship between total votes and the scale of Harris’s underperformance relative to Biden.
scatter_data <- votes |>
filter(year == 2024) |>
mutate(total_votes = democrat + republican,
label = paste0(county_name, ", ", state)) |>
inner_join(margins |> select(GEOID, shift), by = "GEOID")
ggplot(scatter_data, aes(x = total_votes, y = shift)) +
geom_point(alpha = 0.2, size = 0.8, color = "grey40") +
geom_smooth(method = "loess", se = TRUE, color = "#d62728", fill = "#d62728", alpha = 0.15) +
geom_hline(yintercept = 0, linetype = "dashed", color = "grey50") +
geom_text(aes(label = label), size = 2.2, color = "grey30",
check_overlap = TRUE) +
annotate("text", x = 100, y = 0.4, label = "HARRIS", hjust = 0, size = 3, color = "#1f77b4") +
annotate("text", x = 100, y = -0.4, label = "BIDEN", hjust = 0, size = 3, color = "#d62728") +
scale_x_log10(labels = scales::comma) +
scale_y_continuous(labels = scales::label_number(style_positive = "plus")) +
labs(title = "Biden-to-Harris Margin Shift by County Vote Total",
x = "Total votes (log scale)", y = "Margin shift (pp)") +
theme_minimal() +
theme(plot.title = element_text(size = 16))Summary
| Margin shift | Counties | Pct |
|---|---|---|
| Harris < Biden: 0-2.5pp | 1949 | 62.9% |
| Harris < Biden: 2.5-5pp | 691 | 22.3% |
| Harris < Biden: >5pp | 103 | 3.3% |
| Harris > Biden | 357 | 11.5% |
While the flip patterns are notable, the margins map perhaps tells the more interesting story. Harris underperformed Biden in 88% of counties. This wasn’t a concentrated collapse, but a broad, shallow erosion; 63% of those counties saw a deficit within 2.5 percentage points. Notably, the loess analysis shows that these losses were even more pronounced in higher-turnout areas. Whether that scale of erosion is unusual, or simply what party transitions look like when measured against the unique Biden-Trump baseline, is a question not addressed here.