Introduction

This post explores New Mexico’s political landscape from 2000 to 2024 using a cleaned and standardized dataset of election results sourced from the New Mexico Secretary of State’s election statistics. The dataset covers all general elections for statewide offices, the state legislature, and the federal delegation, with consistent party names, uniform formatting, and structured organization. It’s available on GitHub at newmexico-political-data and processed to be analysis-ready. The visualizations below track election results, party control, and compositional changes over this 24-year period.

Data

Data are loaded directly from GitHub. The dataset includes statewide results, county-level statewide results, and precinct-level results (2004-2024).

if (!requireNamespace("pacman", quietly = TRUE)) {
  install.packages("pacman")
}

suppressPackageStartupMessages(
  pacman::p_load(
    dplyr,
    ggplot2,
    tidyr,
    viridis,
    scales,
    patchwork,
    purrr,
    rlang,
    DT,
    knitr
  )
)
# Load final results data from GitHub
results <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_election_results_2000-24.csv", 
                    stringsAsFactors = FALSE)

# Define office categories
statewide_offices <- c("Governor", "Attorney General", "Secretary of State", 
                      "State Treasurer", "State Auditor", "Commissioner of Public Lands",
                      "President of the United States", "United States Senator")

legislative_offices <- c("State Representative", "State Senate")

federal_offices <- c("United States Representative", "United States Senator")

# Load county-level statewide results from GitHub
county_results <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_county_statewide_results_2000-24.csv", 
                           stringsAsFactors = FALSE)

# Load precinct-level data from GitHub (2004-2024)
precinct_data <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_precinct_results_2004-24.csv", 
                          stringsAsFactors = FALSE)
# Show first 100 records
results |>
  head(100) |>
  datatable(
    options = list(
      pageLength = 10,
      scrollX = TRUE
    ),
    rownames = FALSE
  )

Statewide Offices and Presidential Elections

This section shows vote margins and winners for statewide offices and presidential elections from 2000-2024.

Statewide Office Control Over Time

Vote margins for statewide offices (Governor, Attorney General, Secretary of State, State Treasurer, State Auditor) by election year. Margins are calculated as Democratic vote share minus Republican vote share; values shown are absolute values. Third parties are excluded from the calculation. Blue indicates Democratic margins, red indicates Republican margins. The winner’s last name and margin are shown for each office and year.

# Create heatmap of vote margins by office and year
# Filter to 2002-2022 and order by importance
statewide_offices_list <- c("Governor", "Secretary of State", "Attorney General", 
                            "State Treasurer", "State Auditor")

# Get winners and their names
winners <- results |>
  filter(office_name %in% statewide_offices_list) |>
  filter(election_date >= 2002 & election_date <= 2022) |>
  filter(election_date != 2016) |>
  filter(is_winner == TRUE) |>
  select(election_date, office_name, candidate_name, candidate_party_name) |>
  mutate(
    last_name = gsub(".* ", "", candidate_name),  # Get everything after last space
    last_name = gsub(" and .*", "", last_name)  # Handle ticket names
  )

# Calculate margins using existing pct column from results
heatmap_data <- results |>
  filter(office_name %in% statewide_offices_list) |>
  filter(election_date >= 2002 & election_date <= 2022) |>
  filter(election_date != 2016) |>
  filter(candidate_party_name %in% c("Democratic", "Republican")) |>
  group_by(election_date, office_name, candidate_party_name) |>
  summarise(pct = first(pct), .groups = "drop") |>
  pivot_wider(names_from = candidate_party_name, values_from = pct) |>
  mutate(
    Democratic = ifelse(is.na(Democratic), 0, Democratic),
    Republican = ifelse(is.na(Republican), 0, Republican),
    margin = Democratic - Republican
  ) |>
  left_join(winners, by = c("election_date", "office_name")) |>
  select(election_date, office_name, margin, last_name) |>
  mutate(
    office_name = factor(office_name, levels = rev(statewide_offices_list))
  )

# Create the heatmap
ggplot(heatmap_data, aes(x = as.factor(election_date), y = office_name, fill = margin)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = paste0(last_name, "\n", round(abs(margin), 1))), 
            color = "black", size = 3.5) +
  scale_fill_gradient2(
    low = "#d62728", 
    mid = "white", 
    high = "#1f77b4",
    midpoint = 0,
    limits = c(-40, 40),
    guide = "none"
  ) +
  labs(
    title = "Statewide Office Control in New Mexico (2002-2022)",
    x = "Election Year",
    y = "Office"
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    plot.title = element_text(size = 16, face = "plain")
  )

Presidential Results in Selected Counties

Presidential election results for selected New Mexico counties (Bernalillo, Torrance, Sandoval, Valencia, Santa Fe, Luna, McKinley, Doña Ana) plus statewide totals. Margins are calculated as Democratic vote share minus Republican vote share; values shown are absolute values. Third parties are excluded from the calculation. Shows vote margins and winning candidate by county and election year.

# Filter to presidential elections in selected counties
selected_counties <- c("Bernalillo", "Torrance", "Sandoval", "Valencia", "Santa Fe", "Luna", "McKinley", "Doña Ana")

# Get statewide margins
statewide_prez <- results |>
  filter(office_name == "President of the United States") |>
  filter(candidate_party_name %in% c("Democratic", "Republican")) |>
  group_by(election_date, candidate_party_name) |>
  summarise(pct = first(pct), .groups = "drop") |>
  pivot_wider(names_from = candidate_party_name, values_from = pct) |>
  mutate(
    Democratic = ifelse(is.na(Democratic), 0, Democratic),
    Republican = ifelse(is.na(Republican), 0, Republican),
    margin = Democratic - Republican,
    county_name = "Statewide"
  ) |>
  select(election_date, county_name, margin)

# Get winners by county
prez_winners <- county_results |>
  filter(office_name == "President of the United States") |>
  filter(county_name %in% selected_counties) |>
  filter(is_winner == TRUE) |>
  select(election_date, county_name, candidate_name, candidate_party_name) |>
  mutate(
    last_name = gsub(".* ", "", candidate_name),  # Get everything after last space
    last_name = gsub(" and .*", "", last_name)  # Handle ticket names
  )

# Get statewide winner
statewide_winners <- results |>
  filter(office_name == "President of the United States") |>
  filter(is_winner == TRUE) |>
  group_by(election_date) |>
  slice(1) |>
  select(election_date, candidate_name) |>
  mutate(
    last_name = gsub(".* ", "", candidate_name),  # Get everything after last space
    last_name = gsub(" and .*", "", last_name),  # Handle ticket names
    county_name = "Statewide"
  ) |>
  select(election_date, county_name, last_name)

prez_county <- county_results |>
  filter(office_name == "President of the United States") |>
  filter(county_name %in% selected_counties) |>
  filter(candidate_party_name %in% c("Democratic", "Republican")) |>
  group_by(election_date, county_name, candidate_party_name) |>
  summarise(pct = first(pct), .groups = "drop") |>
  pivot_wider(names_from = candidate_party_name, values_from = pct) |>
  mutate(
    Democratic = ifelse(is.na(Democratic), 0, Democratic),
    Republican = ifelse(is.na(Republican), 0, Republican),
    margin = Democratic - Republican
  ) |>
  left_join(prez_winners, by = c("election_date", "county_name")) |>
  select(election_date, county_name, margin, last_name) |>
  bind_rows(
    statewide_prez |>
      left_join(statewide_winners, by = c("election_date", "county_name"))
  )

# Order counties (Statewide at top)
prez_county$county_name <- factor(prez_county$county_name, 
                                   levels = c("Statewide", "Valencia", "Torrance", "Sandoval", "Bernalillo", "Santa Fe", "Luna", "McKinley", "Doña Ana"))

# Calculate range from data
margin_range <- range(prez_county$margin, na.rm = TRUE)
margin_max <- max(abs(margin_range))

ggplot(prez_county, aes(x = as.factor(election_date), y = county_name, fill = margin)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = paste0(last_name, "\n", round(abs(margin), 1))), 
            color = "black", size = 3.5) +
  scale_fill_gradient2(
    low = "#d62728", 
    mid = "white", 
    high = "#1f77b4",
    midpoint = 0,
    limits = c(-margin_max, margin_max),
    guide = "none"
  ) +
  labs(
    title = "Presidential Election Results: Selected Counties",
    x = "Election Year",
    y = "County"
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    plot.title = element_text(size = 16, face = "plain")
  )


The Legislature

This section shows the party composition of the State House and State Senate from 2000-2024.

State House Composition Over Time

Number of Democratic and Republican seats in the State House by election year. Positive values show Democratic seats, negative values show Republican seats. Labels show the seat count for each party.

The State House has 70 seats total. This visualization shows the party breakdown over time, with Democratic seats shown as positive bars and Republican seats as negative bars.

# Calculate State House breakdown by year
house_data <- results |>
  filter(office_name == "State Representative") |>
  filter(is_winner == TRUE)

house_composition <- house_data |>
  group_by(election_date, candidate_party_name) |>
  summarise(seats = n(), .groups = "drop") |>
  complete(election_date, candidate_party_name, fill = list(seats = 0)) |>
  filter(candidate_party_name != "Other") |>
  group_by(election_date) |>
  summarise(
    dem_seats = sum(seats[candidate_party_name == "Democratic"]),
    rep_seats = sum(seats[candidate_party_name == "Republican"]),
    label = paste0(dem_seats, "/", rep_seats),
    .groups = "drop"
  )

ggplot(house_composition, aes(x = as.factor(election_date))) +
  geom_col(aes(y = dem_seats), fill = "#1f77b4", alpha = 0.8) +
  geom_col(aes(y = -rep_seats), fill = "#d62728", alpha = 0.8) +
  geom_text(aes(y = pmax(dem_seats, abs(-rep_seats)) + 2, label = label), size = 4) +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  scale_y_continuous(
    breaks = seq(-40, 50, by = 10),
    labels = abs(seq(-40, 50, by = 10))
  ) +
  labs(
    title = "State House Composition (2000-2024)",
    x = "Election Year",
    y = "Number of Seats"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16, face = "plain")
  )

State Senate Composition Over Time

Number of Democratic and Republican seats in the State Senate by election year. Positive values show Democratic seats, negative values show Republican seats. Labels show the seat count for each party.

The State Senate has 42 seats total. This visualization shows the party breakdown over time, with Democratic seats shown as positive bars and Republican seats as negative bars.

# Calculate State Senate breakdown by year
senate_data <- results |>
  filter(office_name == "State Senate") |>
  filter(is_winner == TRUE)

senate_composition <- senate_data |>
  group_by(election_date, candidate_party_name) |>
  summarise(seats = n(), .groups = "drop") |>
  complete(election_date, candidate_party_name, fill = list(seats = 0)) |>
  filter(candidate_party_name != "Other") |>
  group_by(election_date) |>
  summarise(
    dem_seats = sum(seats[candidate_party_name == "Democratic"]),
    rep_seats = sum(seats[candidate_party_name == "Republican"]),
    label = paste0(dem_seats, "/", rep_seats),
    .groups = "drop"
  )

ggplot(senate_composition, aes(x = as.factor(election_date))) +
  geom_col(aes(y = dem_seats), fill = "#1f77b4", alpha = 0.8) +
  geom_col(aes(y = -rep_seats), fill = "#d62728", alpha = 0.8) +
  geom_text(aes(y = pmax(dem_seats, abs(-rep_seats)) + 2, label = label), size = 4) +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  scale_y_continuous(
    breaks = seq(-25, 30, by = 5),
    labels = abs(seq(-25, 30, by = 5))
  ) +
  labs(
    title = "State Senate Composition (2000-2024)",
    x = "Election Year",
    y = "Number of Seats"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16, face = "plain")
  )


The Federal Delegation

This section shows party control of New Mexico’s federal delegation: 3 U.S. House representatives and 2 U.S. senators from 2000-2024.

# Reusable timeline plot function
create_timeline <- function(data, title, subtitle, group_col = NULL) {
  # Expand to one row per year
  expanded <- data |>
    rowwise() |>
    do(data.frame(label = .$label, party = .$party, 
                  year = seq(.$start_year, .$end_year), stringsAsFactors = FALSE)) |>
    ungroup()
  
  # Order labels - group by class/district if provided, then by start year
  if(!is.null(group_col) && group_col %in% names(data)) {
    label_order <- data |>
      arrange(!!sym(group_col), start_year) |>
      pull(label) |>
      unique() |>
      rev()
  } else {
    label_order <- rev(unique(data$label[order(data$start_year)]))
  }
  
  expanded$label <- factor(expanded$label, levels = label_order)
  
  ggplot(expanded, aes(x = year, y = label, fill = party)) +
    geom_tile(color = "white", linewidth = 0.5, height = 0.8) +
    scale_fill_manual(values = c("Democratic" = "#1f77b4", "Republican" = "#d62728", "Other" = "#ff7f0e")) +
    scale_x_continuous(breaks = seq(2000, 2024, 2), limits = c(1999.5, 2024.5)) +
    labs(title = title, subtitle = subtitle, x = "Year", y = "") +
    theme_minimal() +
    theme(plot.title = element_text(size = 16), 
          axis.text.y = element_text(size = 9),
          panel.grid.major.y = element_blank(), 
          legend.position = "none")
}

U.S. Senate Delegation Timeline

Timeline showing which party controlled each U.S. Senate seat by class (Class 1 and Class 2) from 2000-2024. Each senator’s tenure is shown with their last name and class designation.

# Senate: 2 seats (Class 1: 2000,2006,2012,2018,2024 | Class 2: 2002,2008,2014,2020)
senate <- results |>
  filter(office_name == "United States Senator", is_winner == TRUE) |>
  mutate(
    yr = as.numeric(election_date),
    name = gsub(".*,? ([A-Z][a-z]+)$", "\\1", candidate_name),
    party = ifelse(candidate_party_name == "Democratic", "Democratic", "Republican")
  )

build_seat <- function(years, class_name) {
  map_dfr(years, ~{
    s <- senate |> filter(yr == .x) |> slice(1)
    if(nrow(s) > 0) {
      next_yr <- c(years[years > .x], 2025)[1] - 1
      data.frame(name = s$name, party = s$party, class = class_name,
                 start_year = .x, end_year = next_yr, stringsAsFactors = FALSE)
    }
  }) |> mutate(label = paste0(name, " (", class, ")"))
}

senate_timeline <- bind_rows(
  build_seat(c(2000, 2006, 2012, 2018, 2024), "Class 1"),
  build_seat(c(2002, 2008, 2014, 2020), "Class 2")
)

create_timeline(senate_timeline, "U.S. Senate",
                "Senator tenure by class from 2000-2024", group_col = "class")

U.S. House Delegation Timeline

Timeline showing which party controlled each U.S. House district from 2000-2024. Each representative’s tenure is shown with their last name and district number.

# House: 3 districts with 2-year terms (each election is a separate term)
house_timeline <- results |>
  filter(office_name == "United States Representative", is_winner == TRUE) |>
  mutate(
    yr = as.numeric(election_date),
    name = gsub(".*,? ([A-Z][a-z]+)$", "\\1", candidate_name),
    party = ifelse(candidate_party_name == "Democratic", "Democratic", "Republican"),
    dist = gsub(".*District (\\d+).*", "\\1", district_name)
  ) |>
  arrange(dist, yr) |>
  group_by(dist) |>
  mutate(
    next_election = lead(yr, default = 2025),
    end_year = pmin(next_election - 1, 2024)
  ) |>
  ungroup() |>
  mutate(label = paste0(name, " (D", dist, ")")) |>
  select(label, party, dist, start_year = yr, end_year)

create_timeline(house_timeline, "U.S. House New Mexico Delegation",
                "Representative tenure by district from 2000-2024", group_col = "dist")

Delegation Composition Over Time

Number of Democratic and Republican seats in the U.S. House delegation by election year. Positive values show Democratic seats, negative values show Republican seats. Labels show the seat count for each party.

# Calculate delegation breakdown by year
federal_data <- results |>
  filter(office_name %in% federal_offices) |>
  filter(is_winner == TRUE)

delegation <- federal_data |>
  filter(office_name == "United States Representative") |>
  group_by(election_date, candidate_party_name) |>
  summarise(seats = n(), .groups = "drop") |>
  complete(election_date, candidate_party_name, fill = list(seats = 0)) |>
  filter(candidate_party_name != "Other") |>
  mutate(
    delegation_label = paste0(seats, ifelse(candidate_party_name == "Democratic", "D", "R"))
  ) |>
  group_by(election_date) |>
  summarise(
    dem_seats = sum(seats[candidate_party_name == "Democratic"]),
    rep_seats = sum(seats[candidate_party_name == "Republican"]),
    label = paste0(dem_seats, "/", rep_seats),
    .groups = "drop"
  )

ggplot(delegation, aes(x = as.factor(election_date))) +
  geom_col(aes(y = dem_seats), fill = "#1f77b4", alpha = 0.8) +
  geom_col(aes(y = -rep_seats), fill = "#d62728", alpha = 0.8) +
  geom_text(aes(y = ifelse(dem_seats >= rep_seats, dem_seats + 0.15, -rep_seats - 0.15), label = label), size = 4) +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  scale_y_continuous(
    breaks = seq(-3, 3, by = 1),
    labels = abs(seq(-3, 3, by = 1))
  ) +
  labs(
    title = "U.S. House Delegation Composition (2000-2024)",
    subtitle = "Positive = Democratic seats, Negative = Republican seats",
    x = "Election Year",
    y = "Number of Seats"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16, face = "plain")
  )


Harris Underperformance in 2024

Precinct-level results show Kamala Harris performed worse than House Democratic candidates across New Mexico’s three congressional districts, with the gap varying by region. CD1 (Albuquerque) shows rough parity, CD2 (southern, more conservative) shows the House candidate slightly ahead, and CD3 (northern Democratic stronghold) shows the largest divergence. Points above the diagonal line indicate where the House candidate outperformed Harris.

Scatter Plot by Congressional District

This scatter plot shows Harris vote share (x-axis) versus House Democratic candidate vote share (y-axis) for each precinct, colored by congressional district. The diagonal line represents parity—points above the line show where the House Democratic candidate outperformed Harris, points below show where Harris outperformed the House Democratic candidate.

# Filter for 2024 and standardize column names
precinct_2024 <- precinct_data |>
  filter(election_date == 2024)

# Standardize column names
if("COUNTY_NAM" %in% names(precinct_2024)) {
  precinct_2024$county_name <- precinct_2024$COUNTY_NAM
}
if("VTD_NUM" %in% names(precinct_2024)) {
  precinct_2024$precinct_name <- as.character(precinct_2024$VTD_NUM)
}

precinct_2024 <- precinct_2024 |>
  mutate(cd = gsub(".*District (\\d+).*", "\\1", district_name))

# Get Harris (President) results
harris <- precinct_2024 |>
  filter(office_name == "President of the United States") |>
  mutate(pct_harris = (dem / votes_cast) * 100) |>
  select(county_name, precinct_name, pct_harris)

# Get House results by CD
house_2024 <- precinct_2024 |>
  filter(office_name == "United States Representative") |>
  group_by(county_name, precinct_name, cd) |>
  summarise(
    votes_cast = sum(votes_cast, na.rm = TRUE),
    pct_house_dem = (sum(dem, na.rm = TRUE) / votes_cast) * 100,
    .groups = "drop"
  )

# Merge and plot
comparison <- harris |>
  inner_join(house_2024, by = c("county_name", "precinct_name"))

ggplot(comparison, aes(x = pct_harris, y = pct_house_dem, color = cd)) +
  geom_point(alpha = 0.3, size = 0.8) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "black", linewidth = 1.2) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1.5) +
  scale_color_manual(values = c("#1f77b4", "#ff7f0e", "#2ca02c")) +
  xlim(25, 75) +
  ylim(25, 75) +
  labs(
    x = "Harris %", 
    y = "House Democratic Candidate %",
    title = "Democratic Performance: House vs Harris (2024)",
    subtitle = "Points above diagonal = House outperformed Harris; below = Harris outperformed House (x-axis zoomed 25-75% for visualization purposes)",
    color = "CD"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16, face = "plain")
  )

Summary by Congressional District

The table below summarizes the mean underperformance by congressional district, showing how much Harris underperformed relative to the House Democratic candidate on average in each district (negative values indicate underperformance).

# Summary table by CD
comparison |>
  group_by(cd) |>
  summarise(
    mean_harris = mean(pct_harris, na.rm = TRUE),
    mean_house = mean(pct_house_dem, na.rm = TRUE),
    mean_underperform = mean(pct_harris - pct_house_dem, na.rm = TRUE),
    .groups = "drop"
  ) |>
  arrange(mean_underperform) |>
  mutate(
    mean_harris = round(mean_harris, 1),
    mean_house = round(mean_house, 1),
    mean_underperform = round(mean_underperform, 1)
  ) |>
  knitr::kable(
    col.names = c("CD", "Mean Harris %", "Mean House Democratic Candidate %", "Mean Underperformance"),
    caption = "Harris Underperformance by Congressional District (2024)",
    align = "cccc",
    digits = 1,
    format = "html"
  )
Harris Underperformance by Congressional District (2024)
CD Mean Harris % Mean House Democratic Candidate % Mean Underperformance
3 52.0 56.3 -4.3
2 48.1 51.6 -3.5
1 56.5 56.6 -0.2

Summary

So, a quick visualization of New Mexico electoral data from 2000-2024 using a ready-to-go dataset. The data includes statewide offices, legislative races, and federal congressional races, with consistent formatting across all election years. The clean, structured format makes these types of analyses straightforward—whether examining party control over time, vote margins by office or geography, or tracking individual officeholder tenure.