Introduction

This analysis examines competition and representation in New Mexico’s state legislature over the last 20 years (2004-2024), covering all 70 State House and 42 State Senate districts across 11 election cycles. We measure competitiveness through victory margins and unopposed races, then evaluate whether vote share translates proportionally into seat share. Low competition and disproportionate representation often go hand-in-hand, but understanding each separately reveals different aspects of electoral fairness—whether individual races are contested, and whether the overall system accurately reflects voter preferences.


Data

This analysis uses election results data from the New Mexico Political Data repository, which collates election data from the New Mexico Secretary of State and combines it with precinct boundary maps. The dataset includes election results from 2000-2024, with consistent formatting and party names. For this analysis, we focus on State House and State Senate races from 2004-2024, covering 11 election cycles and all 70 House districts and 42 Senate districts.

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

suppressPackageStartupMessages(
  pacman::p_load(
    dplyr,
    ggplot2,
    tidyr,
    viridis,
    scales,
    patchwork,
    purrr,
    data.table,
    sf,
    ggrepel,
    tidycensus,
    tigris,
    DT,
    kableExtra
  )
)
# 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)

# Filter to State House and Senate races and last 20 years (2004-2024)
house_results <- results |>
  filter(office_name == "State Representative") |>
  filter(election_date >= 2004 & election_date <= 2024)

senate_results <- results |>
  filter(office_name == "State Senate") |>
  filter(election_date >= 2004 & election_date <= 2024)

Competitiveness Metrics

We examine competitiveness through several lenses: victory margins, unopposed races, and the share of competitive races over time. Victory margins measure how close each race was, calculated as the difference between the winner’s vote share and 50%. We classify races as “very competitive” (<5% margin) or “competitive” (<10% margin).

The visualizations track these patterns across election cycles. Beyond individual race competitiveness, understanding where parties fail to field candidates helps explain broader patterns in representation—unopposed districts often signal structural partisan advantages that affect both seat distribution and overall legislative outcomes.

# Identify races unopposed by party for both chambers
unopposed_analysis_house <- house_results |>
  group_by(election_date, district_name) |>
  summarise(
    has_dem = any(candidate_party_name == "Democratic", na.rm = TRUE),
    has_rep = any(candidate_party_name == "Republican", na.rm = TRUE),
    winner_party = candidate_party_name[is_winner == TRUE][1],
    .groups = "drop"
  ) |>
  mutate(
    dem_unopposed = !has_rep & winner_party == "Democratic",
    rep_unopposed = !has_dem & winner_party == "Republican",
    chamber = "House"
  )

unopposed_analysis_senate <- senate_results |>
  group_by(election_date, district_name) |>
  summarise(
    has_dem = any(candidate_party_name == "Democratic", na.rm = TRUE),
    has_rep = any(candidate_party_name == "Republican", na.rm = TRUE),
    winner_party = candidate_party_name[is_winner == TRUE][1],
    .groups = "drop"
  ) |>
  mutate(
    dem_unopposed = !has_rep & winner_party == "Democratic",
    rep_unopposed = !has_dem & winner_party == "Republican",
    chamber = "Senate"
  )

unopposed_analysis <- bind_rows(unopposed_analysis_house, unopposed_analysis_senate)

# Create summary for unopposed races by chamber (needed for competitiveness plot)
unopposed_summary <- unopposed_analysis |>
  group_by(election_date, chamber) |>
  summarise(
    total_races = n(),
    unopposed_count = sum(dem_unopposed | rep_unopposed, na.rm = TRUE),
    unopposed_pct = (unopposed_count / total_races) * 100,
    .groups = "drop"
  )


# Calculate margins for each race (House and Senate)
house_margins <- house_results |>
  group_by(election_date, district_name) |>
  mutate(
    total_votes = sum(votes, na.rm = TRUE),
    pct = (votes / total_votes) * 100
  ) |>
  filter(is_winner == TRUE) |>
  mutate(
    margin = pct - 50,
    margin_abs = abs(margin),
    competitive_5 = margin_abs < 5,
    competitive_10 = margin_abs < 10
  ) |>
  ungroup()

senate_margins <- senate_results |>
  group_by(election_date, district_name) |>
  mutate(
    total_votes = sum(votes, na.rm = TRUE),
    pct = (votes / total_votes) * 100
  ) |>
  filter(is_winner == TRUE) |>
  mutate(
    margin = pct - 50,
    margin_abs = abs(margin),
    competitive_5 = margin_abs < 5,
    competitive_10 = margin_abs < 10
  ) |>
  ungroup()


# Calculate for both chambers
competitive_summary_house <- house_margins |>
  group_by(election_date) |>
  summarise(
    total_races = n(),
    competitive_5_count = sum(competitive_5, na.rm = TRUE),
    competitive_10_count = sum(competitive_10, na.rm = TRUE),
    competitive_5_pct = (competitive_5_count / total_races) * 100,
    competitive_10_pct = (competitive_10_count / total_races) * 100,
    chamber = "House",
    .groups = "drop"
  )

competitive_summary_senate <- senate_margins |>
  group_by(election_date) |>
  summarise(
    total_races = n(),
    competitive_5_count = sum(competitive_5, na.rm = TRUE),
    competitive_10_count = sum(competitive_10, na.rm = TRUE),
    competitive_5_pct = (competitive_5_count / total_races) * 100,
    competitive_10_pct = (competitive_10_count / total_races) * 100,
    chamber = "Senate",
    .groups = "drop"
  )

# Get unopposed percentages
unopposed_summary <- unopposed_analysis |>
  group_by(election_date, chamber) |>
  summarise(
    total_races = n(),
    unopposed_count = sum(dem_unopposed | rep_unopposed, na.rm = TRUE),
    unopposed_pct = (unopposed_count / total_races) * 100,
    .groups = "drop"
  )

competitive_summary <- bind_rows(competitive_summary_house, competitive_summary_senate) |>
  left_join(unopposed_summary, by = c("election_date", "chamber")) |>
  pivot_longer(
    cols = c(competitive_5_pct, competitive_10_pct, unopposed_pct),
    names_to = "metric",
    values_to = "percentage"
  ) |>
  mutate(
    metric = case_when(
      metric == "competitive_5_pct" ~ "Very Competitive (<5%)",
      metric == "competitive_10_pct" ~ "Competitive (<10%)",
      metric == "unopposed_pct" ~ "Unopposed"
    )
  )

ggplot(competitive_summary, aes(x = as.numeric(election_date), y = percentage, color = metric)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  facet_wrap(~ chamber, ncol = 1) +
  scale_color_manual(values = c("Very Competitive (<5%)" = "#ff7f0e", 
                               "Competitive (<10%)" = "#2ca02c",
                               "Unopposed" = "#9467bd")) +
  labs(
    title = "Competitiveness Trends Over Time",
    x = "Election Year",
    y = "Percentage of Races",
    color = "Metric"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16),
    legend.position = "bottom",
    strip.text = element_text(size = 12, face = "bold")
  ) +
  scale_x_continuous(breaks = seq(2004, 2024, by = 2)) +
  scale_y_continuous(labels = scales::percent_format(scale = 1))

Competitiveness Summary by Year, Chamber, and Party

This table summarizes the number of competitive, very competitive, and unopposed races by election year, chamber, and winning party.

# Combine margins data with unopposed data and winner party
# Get winner party from margins data directly
house_competitive_summary <- house_margins |>
  select(election_date, district_name, candidate_party_name, competitive_5, competitive_10) |>
  rename(winner_party = candidate_party_name) |>
  left_join(
    unopposed_analysis_house |>
      select(election_date, district_name, dem_unopposed, rep_unopposed),
    by = c("election_date", "district_name")
  ) |>
  mutate(
    chamber = "House",
    unopposed = (winner_party == "Democratic" & dem_unopposed) | 
                (winner_party == "Republican" & rep_unopposed)
  )

senate_competitive_summary <- senate_margins |>
  select(election_date, district_name, candidate_party_name, competitive_5, competitive_10) |>
  rename(winner_party = candidate_party_name) |>
  left_join(
    unopposed_analysis_senate |>
      select(election_date, district_name, dem_unopposed, rep_unopposed),
    by = c("election_date", "district_name")
  ) |>
  mutate(
    chamber = "Senate",
    unopposed = (winner_party == "Democratic" & dem_unopposed) | 
                (winner_party == "Republican" & rep_unopposed)
  )

# Combine and summarize
competitiveness_by_party <- bind_rows(house_competitive_summary, senate_competitive_summary) |>
  mutate(
    non_competitive = !competitive_10 & !unopposed
  ) |>
  group_by(election_date, chamber, winner_party) |>
  summarise(
    competitive = sum(competitive_10, na.rm = TRUE),
    very_competitive = sum(competitive_5, na.rm = TRUE),
    unopposed = sum(unopposed, na.rm = TRUE),
    non_competitive = sum(non_competitive, na.rm = TRUE),
    total = n(),
    .groups = "drop"
  ) |>
  filter(!is.na(winner_party), total >= 2) |>
  mutate(
    party = ifelse(winner_party == "Democratic", "D", "R")
  ) |>
  select(election_date, chamber, party, competitive, very_competitive, non_competitive, unopposed, total) |>
  arrange(chamber, election_date, party)

# Create DT table
competitiveness_by_party |>
  DT::datatable(
    colnames = c("Year", "Chamber", "Party", 
                 "Comp", "Very Comp", 
                 "Non Comp", "Unopposed", "Total"),
    caption = "Competitiveness Metrics by Year, Chamber, and Winning Party",
    options = list(
      pageLength = 10,
      dom = 'frtip',
      scrollX = TRUE
    ),
    rownames = FALSE
  )

Geographic Distribution of Unopposed Districts

Maps showing which districts lacked competition from one major party in 2024. This spatial view helps identify geographic patterns in candidate recruitment and party strength across New Mexico.

# Load district boundaries using tigris
# Get New Mexico state legislative districts (suppress download messages)
options(tigris_use_cache = TRUE)
invisible(capture.output({
  suppressMessages({
    suppressWarnings({
      house_districts_raw <- tigris::state_legislative_districts(state = "NM", house = "lower", year = 2021)
      senate_districts_raw <- tigris::state_legislative_districts(state = "NM", house = "upper", year = 2021)
    })
  })
}))

# Extract district number from available columns
# tigris uses SLDLST for lower house and SLDUST for upper house
# Need to handle potential leading zeros and format matching
if("SLDLST" %in% names(house_districts_raw)) {
  house_districts <- house_districts_raw |>
    mutate(district_num = as.character(as.numeric(SLDLST))) |>
    select(district_num, geometry)
} else if("NAME" %in% names(house_districts_raw)) {
  house_districts <- house_districts_raw |>
    mutate(
      district_num = gsub(".*District (\\d+).*", "\\1", NAME),
      district_num = as.character(as.numeric(district_num))
    ) |>
    select(district_num, geometry)
} else {
  house_districts <- house_districts_raw |>
    mutate(district_num = as.character(row_number())) |>
    select(district_num, geometry)
}

if("SLDUST" %in% names(senate_districts_raw)) {
  senate_districts <- senate_districts_raw |>
    mutate(district_num = as.character(as.numeric(SLDUST))) |>
    select(district_num, geometry)
} else if("NAME" %in% names(senate_districts_raw)) {
  senate_districts <- senate_districts_raw |>
    mutate(
      district_num = gsub(".*District (\\d+).*", "\\1", NAME),
      district_num = as.character(as.numeric(district_num))
    ) |>
    select(district_num, geometry)
} else {
  senate_districts <- senate_districts_raw |>
    mutate(district_num = as.character(row_number())) |>
    select(district_num, geometry)
}

# Prepare unopposed data for mapping (focus on 2024)
# Extract district numbers and normalize (remove leading zeros)
unopposed_map_house <- unopposed_analysis_house |>
  filter(election_date == 2024) |>
  mutate(
    district_num = gsub(".*District (\\d+).*", "\\1", district_name),
    district_num = as.character(as.numeric(district_num)),  # Normalize to remove leading zeros
    unopposed_status = case_when(
      dem_unopposed ~ "Democrat Unopposed",
      rep_unopposed ~ "Republican Unopposed",
      TRUE ~ "Contested"
    )
  )

unopposed_map_senate <- unopposed_analysis_senate |>
  filter(election_date == 2024) |>
  mutate(
    district_num = gsub(".*District (\\d+).*", "\\1", district_name),
    district_num = as.character(as.numeric(district_num)),  # Normalize to remove leading zeros
    unopposed_status = case_when(
      dem_unopposed ~ "Democrat Unopposed",
      rep_unopposed ~ "Republican Unopposed",
      TRUE ~ "Contested"
    )
  )

# Join with district boundaries
house_map_data <- house_districts |>
  left_join(unopposed_map_house, by = "district_num") |>
  mutate(
    unopposed_status = ifelse(is.na(unopposed_status), "Contested", unopposed_status)
  )

senate_map_data <- senate_districts |>
  left_join(unopposed_map_senate, by = "district_num") |>
  mutate(
    unopposed_status = ifelse(is.na(unopposed_status), "Contested", unopposed_status)
  )



# State House map
p1 <- ggplot(house_map_data) +
  geom_sf(aes(fill = unopposed_status), color = "white", linewidth = 0.3) +
  scale_fill_manual(
    values = c("Democrat Unopposed" = "#1f77b4", 
               "Republican Unopposed" = "#d62728",
               "Contested" = "grey90"),
    name = "Status"
  ) +
  labs(
    title = "2024 State House: Unopposed Districts"
  ) +
  theme_void() +
  theme(
    plot.title = element_text(size = 14),
    legend.position = "bottom"
  )

# State Senate map
p2 <- ggplot(senate_map_data) +
  geom_sf(aes(fill = unopposed_status), color = "white", linewidth = 0.3) +
  scale_fill_manual(
    values = c("Democrat Unopposed" = "#1f77b4", 
               "Republican Unopposed" = "#d62728",
               "Contested" = "grey90"),
    name = "Status"
  ) +
  labs(
    title = "2024 State Senate: Unopposed Districts"
  ) +
  theme_void() +
  theme(
    plot.title = element_text(size = 14),
    legend.position = "bottom"
  )

p1

p2


District Margins: Ranked by Democratic Performance

These visualizations show all State House and State Senate districts ranked by Democratic margin, providing a clear view of the distribution of competitiveness and partisan lean across districts. The ranked bar charts reveal the “seats-votes curve” - how districts are distributed along the partisan spectrum.

State House

All 70 State House districts ranked by Democratic margin in the 2024 election, from most Democratic-leaning to most Republican-leaning. This view helps identify competitive districts and shows the overall partisan distribution.

# Calculate margins for 2024 House
house_2024 <- house_results |>
  filter(election_date == 2024) |>
  group_by(district_name) |>
  mutate(
    total_votes = sum(votes, na.rm = TRUE),
    dem_votes = sum(votes[candidate_party_name == "Democratic"], na.rm = TRUE),
    rep_votes = sum(votes[candidate_party_name == "Republican"], na.rm = TRUE),
    dem_pct = (dem_votes / total_votes) * 100,
    rep_pct = (rep_votes / total_votes) * 100,
    dem_margin = dem_pct - rep_pct
  ) |>
  slice(1) |>
  ungroup() |>
  arrange(desc(dem_margin)) |>
  mutate(
    rank = row_number(),
    winner = ifelse(dem_margin > 0, "Democratic", "Republican"),
    district_num = gsub(".*District (\\d+).*", "\\1", district_name)
  )

ggplot(house_2024, aes(x = rank, y = dem_margin, fill = winner)) +
  geom_col() +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  geom_hline(yintercept = c(-5, 5), linetype = "dashed", color = "grey50", alpha = 0.7) +
  scale_fill_manual(values = c("Democratic" = "#1f77b4", "Republican" = "#d62728")) +
  labs(
    title = "2024 State House Districts Ranked by Democratic Margin",
    x = "District Rank (by Democratic Margin)",
    y = "Democratic Margin (percentage points)",
    fill = "Winner"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16),
    legend.position = "bottom",
    axis.text.x = element_blank(),
    axis.ticks.x = element_blank()
  ) +
  scale_y_continuous(breaks = seq(-60, 80, by = 20))

State Senate

All 42 State Senate districts ranked by Democratic margin in the 2024 election, from most Democratic-leaning to most Republican-leaning. Senate districts are larger and typically less competitive than House districts.

# Calculate margins for 2024 Senate
senate_2024 <- senate_results |>
  filter(election_date == 2024) |>
  group_by(district_name) |>
  mutate(
    total_votes = sum(votes, na.rm = TRUE),
    dem_votes = sum(votes[candidate_party_name == "Democratic"], na.rm = TRUE),
    rep_votes = sum(votes[candidate_party_name == "Republican"], na.rm = TRUE),
    dem_pct = (dem_votes / total_votes) * 100,
    rep_pct = (rep_votes / total_votes) * 100,
    dem_margin = dem_pct - rep_pct
  ) |>
  slice(1) |>
  ungroup() |>
  arrange(desc(dem_margin)) |>
  mutate(
    rank = row_number(),
    winner = ifelse(dem_margin > 0, "Democratic", "Republican"),
    district_num = gsub(".*District (\\d+).*", "\\1", district_name)
  )

ggplot(senate_2024, aes(x = rank, y = dem_margin, fill = winner)) +
  geom_col() +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  geom_hline(yintercept = c(-5, 5), linetype = "dashed", color = "grey50", alpha = 0.7) +
  scale_fill_manual(values = c("Democratic" = "#1f77b4", "Republican" = "#d62728")) +
  labs(
    title = "2024 State Senate Districts Ranked by Democratic Margin",
    x = "District Rank (by Democratic Margin)",
    y = "Democratic Margin (percentage points)",
    fill = "Winner"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16),
    legend.position = "bottom",
    axis.text.x = element_blank(),
    axis.ticks.x = element_blank()
  ) +
  scale_y_continuous(breaks = seq(-60, 80, by = 20))


Representational Fairness: Seats-Votes Relationship

TThe seats-votes curve evaluates how well vote share translates to seat share across all election years, complementing our district-level competitiveness analysis. While individual races may lack competition, the system-level question is whether the overall distribution of seats reflects the distribution of votes.

Points falling on the diagonal line represent perfect proportionality. Points above indicate Democrats won more seats than their vote share suggests, while points below indicate fewer seats. This relationship can reveal whether low competitiveness stems from strategic district design or natural geographic clustering of voters.

# Function to calculate seats-votes metrics
calculate_seats_votes <- function(election_data) {
  election_data |>
    group_by(district_name) |>
    mutate(
      total_votes = sum(votes, na.rm = TRUE),
      dem_votes = sum(votes[candidate_party_name == "Democratic"], na.rm = TRUE),
      rep_votes = sum(votes[candidate_party_name == "Republican"], na.rm = TRUE),
      dem_pct = (dem_votes / total_votes) * 100,
      rep_pct = (rep_votes / total_votes) * 100
    ) |>
    slice(1) |>
    ungroup() |>
    summarise(
      total_dem_votes = sum(dem_votes, na.rm = TRUE),
      total_rep_votes = sum(rep_votes, na.rm = TRUE),
      total_votes_all = sum(total_votes, na.rm = TRUE),
      dem_seats = sum(dem_pct > 50, na.rm = TRUE),
      rep_seats = sum(rep_pct > 50, na.rm = TRUE),
      dem_seat_share = (sum(dem_pct > 50, na.rm = TRUE) / n()) * 100,
      dem_vote_share = (sum(dem_votes, na.rm = TRUE) / sum(total_votes, na.rm = TRUE)) * 100,
      .groups = "drop"
    )
}

# Calculate seats-votes for all election years for both chambers
all_elections <- unique(house_results$election_date)

seats_votes_house <- map_dfr(all_elections, function(year) {
  year_data <- house_results |>
    filter(election_date == year)
  
  sv_result <- calculate_seats_votes(year_data)
  sv_result$election_date <- year
  sv_result$chamber <- "House"
  sv_result
}) |>
  arrange(election_date)

seats_votes_senate <- map_dfr(all_elections, function(year) {
  year_data <- senate_results |>
    filter(election_date == year)
  
  if(nrow(year_data) > 0) {
    sv_result <- calculate_seats_votes(year_data)
    sv_result$election_date <- year
    sv_result$chamber <- "Senate"
    sv_result
  } else {
    NULL
  }
}) |>
  arrange(election_date)

seats_votes <- bind_rows(seats_votes_house, seats_votes_senate)


##
ggplot(seats_votes, aes(x = dem_vote_share, y = dem_seat_share)) +
  geom_point(aes(color = as.numeric(election_date)), size = 4, alpha = 0.8) +
  ggrepel::geom_text_repel(aes(label = election_date), size = 3, min.segment.length = 0) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50", linewidth = 1) +
  facet_wrap(~ chamber, ncol = 1) +
  scale_color_gradient(low = "#1f77b4", high = "#ff7f0e", guide = "none") +
  labs(
    title = "Seats-Votes Curve: All Election Years",
    x = "Democratic Vote Share (%)",
    y = "Democratic Seat Share (%)"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16),
    strip.text = element_text(size = 12, face = "bold")
  ) +
  xlim(40, 65) +
  ylim(40, 65)

Seats-Votes Summary Table

A detailed table showing vote share, seat share, and the difference between them for each election year. The “Seat-Vote Diff” column indicates how much seat share deviates from vote share—positive values mean Democrats won more seats than their vote share, negative values mean fewer seats.

Democrats have consistently won a seat bonus in nearly every cycle, with the advantage ranging from 2-7 points in most years but spiking to 11-13 points in 2020. The only exception was 2014, when Republicans briefly gained a slight seat advantage in the House.

seats_votes_table <- seats_votes |>
  mutate(
    dem_vote_share = round(dem_vote_share, 1),
    dem_seat_share = round(dem_seat_share, 1),
    seat_vote_diff = round(dem_seat_share - dem_vote_share, 1)
  ) |>
  select(chamber, election_date, dem_vote_share, dem_seat_share, seat_vote_diff, dem_seats, rep_seats) |>
  arrange(chamber, election_date)

max_abs_diff <- max(abs(seats_votes_table$seat_vote_diff), na.rm = TRUE)
color_func <- scales::col_numeric(
  palette = "RdBu",
  domain = c(-max_abs_diff, max_abs_diff),
  reverse = FALSE
)

seats_votes_table |>
  select(chamber, election_date, dem_vote_share, dem_seat_share, seat_vote_diff, dem_seats, rep_seats) |>
  DT::datatable(
    colnames = c("Chamber", "Year", "Dem Vote Share (%)", 
                 "Dem Seat Share (%)", "Seat-Vote Diff", "Dem Seats", "Rep Seats"),
    caption = "Seats-Votes Relationship: All Election Years by Chamber",
    options = list(pageLength = 25, dom = 't')
  ) |>
  DT::formatStyle(
    "seat_vote_diff",
    backgroundColor = DT::styleInterval(
      cuts = seq(-max_abs_diff, max_abs_diff, length.out = 20),
      values = color_func(seq(-max_abs_diff, max_abs_diff, length.out = 21))
    )
  ) |>
  DT::formatRound(columns = c("dem_vote_share", "dem_seat_share", "seat_vote_diff"), digits = 1)

Summary

This analysis examined competition and representation in New Mexico’s state legislature from 2004-2024. District-level competitiveness remains low, with many races decided by large margins and frequent unopposed contests showing clear geographic patterns.

Despite low competition, the seats-votes relationship shows relatively modest deviations from proportionality in most cycles. Democrats consistently win a seat bonus averaging 2-7 percentage points, with the notable exception of 2020’s 11-13 point advantage.

These patterns likely reflect geographic concentration of Democratic voters in urban areas, illustrating how limited competition and reasonably fair representation can coexist when driven by residential patterns rather than intentional district design (i.e., gerrymandering).