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.

pacman::p_load(dplyr, tidyr, ggplot2, tigris, sf, scales, forcats, DT, stringr, ggrepel)
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 2024
  • R-D-D (33 counties) – flipped Democratic in 2020 and held through 2024
  • R-D-R (32 counties) – classic swing-back; briefly went for Biden in 2020, returned to Trump in 2024
  • D-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.