Introduction

This analysis examines elections for New Mexico’s State House from 2004 to 2024, covering all 70 districts over 11 election cycles.

The central question is whether votes translate into seats proportionally. When they don’t — when a party wins more seats than its vote share would justify — that gap is partisan bias. Identifying bias is only the first step. The more consequential question is what produces it: district boundaries, geography, or structural features of how races are contested.

Two structural features receive the most attention here: how often races go uncontested, and how competitive districts are when both parties do field candidates. Both shape the distribution of votes across districts in ways that can generate or amplify bias independently of how the map was drawn.


Data

Election results come from newmexico-political-data, a GitHub resource that collates results from the New Mexico Secretary of State. It covers elections from 2000–2024 with consistent formatting and party names. This analysis focuses on State House races from 2004–2024.

pacman::p_load(dplyr, ggplot2, tidyr, viridis, scales, patchwork, purrr, data.table, sf, ggrepel, tidycensus, tigris, DT)
results <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_election_results_2000-24.csv",
                    stringsAsFactors = FALSE)

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

race_status <- house_results |>
  group_by(election_date, district_name) |>
  summarise(
    n_candidates = n_distinct(candidate_name),
    winner_party = candidate_party_name[is_winner == TRUE][1],
    .groups = "drop"
  ) |>
  mutate(unopposed = n_candidates == 1)

unopposed_summary <- race_status |>
  group_by(election_date) |>
  summarise(
    total_races     = n(),
    unopposed_count = sum(unopposed),
    unopposed_pct   = (unopposed_count / total_races) * 100,
    .groups = "drop"
  )

Representational Fairness

The seats-votes curve is the first diagnostic. Each point plots a party’s statewide vote share against its seat share for a given cycle — proportional representation sits on the diagonal, and deviation from it measures bias. The consistent above-diagonal clustering confirms a modest but durable pro-Democratic bias, present in nearly every cycle and most pronounced in 2020. The one exception was 2014, when Republicans briefly held a slight seat advantage.

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(
      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"
    )
}

all_elections <- unique(house_results$election_date)

seats_votes <- map_dfr(all_elections, function(year) {
  sv_result <- calculate_seats_votes(house_results |> filter(election_date == year))
  sv_result$election_date <- year
  sv_result
}) |>
  arrange(election_date)

ggplot(seats_votes, aes(x = dem_vote_share, y = dem_seat_share)) +
  geom_point(color = "#DD8E58", size = 1.5, alpha = 0.8) +
  ggrepel::geom_text_repel(aes(label = election_date), size = 3.5, min.segment.length = 0.5) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50", linewidth = 1) +
  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)) +
  xlim(40, 65) +
  ylim(40, 65)

Seats-Votes Summary Table

The Democratic seat bonus ranged from 2–7 points in most years — persistent, but not dramatic.

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(election_date, dem_vote_share, dem_seat_share, seat_vote_diff, dem_seats, rep_seats) |>
  arrange(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 <- seats_votes_table


seats_votes_table |>
  DT::datatable(
    colnames = c("Year", "Dem Vote Share (%)",
                 "Dem Seat Share (%)", "Seat-Vote Diff", "Dem Seats", "Rep Seats"),
    caption = "Seats-Votes Relationship: State House, All Election Years",
    rownames = FALSE,
    options = list(pageLength = 25, dom = 't', scrollX = TRUE)
  ) |>
  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)

Partisan Distribution and the “Seat Bonus”

The 2024 results — Democrats holding a 43–27 advantage — point to a deeper structural story. To understand why seat share remains disconnected from the statewide vote, the distribution of actual victory margins across the 70 districts is more revealing than the aggregate totals.

Ranking every district by its 2024 Democratic margin produces a striking picture: nearly every district falls well outside the ±10-point competitive zone. The distribution isn’t an anomaly. It reflects the consistent absence of genuinely close races that has defined the chamber for two decades.

Part of that pattern traces directly to uncontested seats. When a significant share of the chamber goes unchallenged each cycle, the effective vote distribution is distorted before a single ballot is cast. The notable drop in unopposed races in 2020 is corroborated by Ballotpedia’s coverage of that cycle; aside from that inflection, the floor has remained largely stable.

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(-10, 10), linetype = "dashed", color = "grey50", alpha = 0.7) +
  scale_fill_manual(values = c("Democratic" = "#1f77b4", "Republican" = "#d62728")) +
  labs(
    title = "Partisan Lean Distribution: 2024 State House",
    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(-100, 100, by = 20), limits = c(-100, 100))

unopposed_by_party <- race_status |>
  filter(unopposed) |>
  group_by(election_date, winner_party) |>
  summarise(n = n(), .groups = "drop")
ggplot(unopposed_by_party,
       aes(x = as.numeric(election_date), y = n, fill = winner_party)) +
  geom_col(alpha = 0.85) +
  geom_text(aes(label = n), position = position_stack(vjust = 0.5),
            color = "white", size = 3.5, fontface = "bold") +
  scale_fill_manual(
    values = c("Democratic" = "#1f77b4", "Republican" = "#d62728"),
    name = "Party running unopposed"
  ) +
  labs(
    title = "Unopposed Races Over Time",
    x = "Election Year",
    y = "Number of Unopposed Districts"
  ) +
  theme_minimal() +
  theme(plot.title = element_text(size = 16),
        legend.position = "bottom") +
  scale_x_continuous(breaks = seq(2004, 2024, by = 2))


The Geography of Uncontested Seats

Uncontested seats are not distributed randomly. They cluster in specific regional strongholds — rural, lower-population areas and deep urban pockets — where partisan dominance is sufficient that the minority party consistently declines to field a candidate. This geographic anchor is what keeps the seat bonus durable across redistricting cycles and shifting national environments.

It also leaves only a small fraction of the map genuinely in play. Even among contested races, toss-ups are rare: the share of districts falling within the competitive zone in any given cycle is small, and it has not grown meaningfully over the period examined.

options(tigris_use_cache = TRUE)
invisible(capture.output({
  suppressMessages({
    suppressWarnings({
      house_districts_raw <- tigris::state_legislative_districts(
        state = "NM", house = "lower", year = 2021)
    })
  })
}))

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)
}

unopposed_map_house <- race_status |>
  filter(election_date == 2024) |>
  mutate(
    district_num = gsub(".*District (\\d+).*", "\\1", district_name),
    district_num = as.character(as.numeric(district_num)),
    unopposed_status = case_when(
      unopposed & winner_party == "Democratic"  ~ "Democrat Unopposed",
      unopposed & winner_party == "Republican"  ~ "Republican Unopposed",
      TRUE                                       ~ "Contested"
    )
  )

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

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 = 16),
    legend.position = "bottom"
  )

contested_districts <- race_status |>
  filter(!unopposed) |>
  select(election_date, district_name)

house_margins <- house_results |>
  inner_join(contested_districts, by = c("election_date", "district_name")) |>
  group_by(election_date, 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,
    margin_abs  = abs(dem_pct - rep_pct),
    competitive_5  = margin_abs < 5,
    competitive_10 = margin_abs < 10
  ) |>
  filter(is_winner == TRUE) |>
  ungroup()

comp_summary <- house_margins |>
  group_by(election_date) |>
  summarise(
    very_competitive = sum(competitive_5, na.rm = TRUE),
    competitive     = sum(competitive_10, na.rm = TRUE),
    .groups = "drop"
  ) |>
  left_join(unopposed_summary |> select(election_date, total_races, unopposed_count), by = "election_date") |>
  pivot_longer(cols = c(very_competitive, competitive),
               names_to = "metric", values_to = "count") |>
  mutate(
    pct = (count / total_races) * 100,
    metric = case_when(
      metric == "very_competitive" ~ "Very Competitive (<5%)",
      metric == "competitive"      ~ "Competitive (<10%)"
    )
  )

ggplot(comp_summary, aes(x = as.numeric(election_date), y = pct, color = metric)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_color_manual(values = c("Very Competitive (<5%)" = "#9467bd",
                                "Competitive (<10%)"     = "#5ba3a0")) +
  labs(
    title = "Competitiveness in State House Races Over Time",
    x = "Election Year",
    y = "Percentage of All Districts",
    color = NULL
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16),
    legend.position = "bottom"
  ) +
  scale_x_continuous(breaks = seq(2004, 2024, by = 2)) +
  scale_y_continuous(labels = scales::percent_format(scale = 1))

Competitiveness Summary by Year

The table below breaks out counts by cycle, distinguishing unopposed races from contested ones within and outside the competitive zone. All counts sum to 70.

house_margins |>
  group_by(election_date) |>
  summarise(
    `Very Competitive (<5%)`  = sum(competitive_5, na.rm = TRUE),
    `Competitive (5-10%)`     = sum(competitive_10 & !competitive_5, na.rm = TRUE),
    `Non-Competitive (>10%)`  = sum(!competitive_10, na.rm = TRUE),
    .groups = "drop"
  ) |>
  left_join(unopposed_summary |> select(election_date, total_races, unopposed_count), by = "election_date") |>
  rename(Year = election_date, Unopposed = unopposed_count) |>
  select(-total_races) |>
  DT::datatable(
    caption = "Competitiveness in State House Races by Year (out of all districts)",
    rownames = FALSE,
    options = list(pageLength = 15, dom = 't', scrollX = TRUE)
  )

Summary

The consistency of New Mexico’s seats-votes relationship is less a product of map-drawing than of two reinforcing structural forces: a large share of districts that go unopposed each cycle, and a distribution of contested margins that concentrates outcomes well away from the center. The Democratic seat bonus is real but bounded — typically 2–7 points — because the same structural patterns that produce it also cap it. Geography and candidate entry, not the map, are doing most of the work.