How Jesse Vogel Won District 7 But Lost the Election

politics
code
R
Author

Trevin Flickinger

Published

November 14, 2025

Last week Tiara Ross won the Columbus City Council District 7 race over grassroots candidate Jesse Vogel. It was a close election, separated by around 1,600 votes in a race with over 100,000 ballots cast. Ross ended up with 51% of the total vote compared to 49% for Vogel.

But here’s what’s unusual about this election, the entire city of Columbus votes on who represents District 7. In most cities, only residents within a district gets to vote for their representative. Columbus requires candidates to live in their district but earn votes citywide. This makes it extremely difficult for anyone to win without the backing of the Franklin County Democratic Party.

A lot of opinion pieces have been shared over the past week on how ridiculous the current system is.

Recent Commentary on Columbus Voting System

What makes it ridiculous is that Jesse Vogel easily won in District 7, but lost overall. Last week, I put together a thread on Bluesky the day after the election. However, I did everything manually, comparing the City Council District map to the Franklin County Map and vote counts. That was a painful process and I may have made some errors since I compiled everything by hand. So I wanted to scrape the data from the Franklin County website and make the results reproducible.

What the Data Shows

To verify these claims with data, I scraped precinct-level results from the Franklin County Board of Elections. The code below shows how I processed the election results and created detailed maps of the race.

(Note: I only compiled results from Franklin County. While Columbus extends into neighboring counties, this doesn’t affect District 7’s boundaries.)

Show code
# Franklin County Election Results Scraper
# Extracts precinct-level results for races across Franklin County Ohio

library(httr2)
library(jsonlite)
library(dplyr)
library(tidyr)
library(purrr)
library(tibble)

# Step 1: Get precinct geography/names
get_precinct_info <- function() {
  response <- request("https://vote.franklincountyohio.gov/elections/api/precinct") |>
    req_user_agent("R election data analysis") |>
    req_perform()
  data_list <- response |>
    resp_body_string() |>
    parse_json() |>
    pluck("precincts")
  
  # 1. Create a data frame of ALL entities and assign an index
  all_entities <- tibble(precinct_index = seq_along(data_list), data = data_list) |>
    unnest_wider(data) 
  
  # --- STEP 2A: Process 2-Level Precincts (Bexley, Valleyview, etc.) ---
  
  # Find Type 1 parents and link them directly to Type 3 children
  link_data_L2 <- all_entities |>
    filter(type == 1) |> 
    select(parent_name = name, children) |>
    unnest_longer(children) |> 
    rename(child_index = children)
  
  linked_precincts_L2 <- link_data_L2 |>
    left_join(
      all_entities |> filter(type == 3) |> select(-children),
      by = c("child_index" = "precinct_index")
    ) |>
    filter(!is.na(code)) |> # Keep only successful Type 1 -> Type 3 links
    select(
      parent_name, 
      precinct_code = code, 
      type,
      poly,
      precinct_api_id = child_index,  # Keep the original index!
      everything()
    ) |>
    mutate(
      full_name = paste(parent_name, precinct_code),
      source = "2-Level Link"
    )
  
  # --- STEP 2B: Process 3-Level Precincts (Columbus) ---
  
  # 1. Get Type 1 -> Type 2 Links (e.g., "Columbus" -> "1")
  link_data_L3_step1 <- all_entities |>
    filter(type == 1) |>
    select(parent_name = name, children) |>
    unnest_longer(children) |>
    rename(level_2_index = children) |>
    left_join(
      all_entities |> filter(type == 2) |> select(precinct_index, level_2_code = code, children),
      by = c("level_2_index" = "precinct_index")
    ) |>
    filter(!is.na(level_2_code)) # Keep only successful Type 1 -> Type 2 links
  
  # 2. Get Type 2 -> Type 3 Links (e.g., "1" -> "A")
  link_data_L3_step2 <- link_data_L3_step1 |>
    select(parent_name, level_2_code, children) |>
    unnest_longer(children) |>
    rename(level_3_index = children) |>
    left_join(
      all_entities |> filter(type == 3) |> select(precinct_index, level_3_code = code, poly),
      by = c("level_3_index" = "precinct_index")
    ) |>
    filter(!is.na(level_3_code)) # Keep only successful Type 2 -> Type 3 links
  
  # 3. Combine and build the name
  linked_precincts_L3 <- link_data_L3_step2 |>
    mutate(
      precinct_code = paste(level_2_code, level_3_code, sep = "-"),
      full_name = paste(parent_name, precinct_code),
      source = "3-Level Link",
      precinct_api_id = level_3_index  # Keep the original index!
    ) |>
    select(
      parent_name,
      precinct_code,
      full_name,
      poly,
      precinct_api_id,  # Include this!
      source
    )
  
  # --- STEP 3: Combine All Results ---
  final_precincts <- dplyr::bind_rows(
    linked_precincts_L2 |> select(parent_name, precinct_code, full_name, poly, precinct_api_id, source), 
    linked_precincts_L3
  )
  
  return(final_precincts)
}
Show code
# Step 2: Get live election results
get_election_results <- function() {
  response <- request("https://vote.franklincountyohio.gov/elections/api/election/live") %>%
    req_user_agent("R election data analysis") %>%
    req_perform()
  
  data_list <- response |>
    resp_body_string() |>
    parse_json()
  
  # Process results into a tidy format
  
  # Extract races and create initial data frame with indices
  races_list <- data_list |> pluck("races")
  
  all_races <- tibble(race_index = seq_along(races_list), race_data = races_list) |>
    unnest_wider(race_data)
  
  # Unnest candidates within each race
  races_with_candidates <- all_races |>
    select(race_id = id, race_name = name, race_type = level, candidates = choices) |>
    unnest_longer(candidates) |>
    unnest_wider(candidates) |>
    select(race_id, race_name, race_type,
           candidate_name = name, party, 
           votes, abs, prov, 
           overlapVotes, overlapAbs, overlapProv)
  
  # Get precinct codes from the races data
  # The precinct codes should be in a "precincts" field at the race level
  precinct_codes <- all_races |>
    select(race_id = id, precincts = areas) |>
    unnest_longer(precincts) |>
    group_by(race_id) |>
    mutate(precinct_index = row_number()) |>
    ungroup() |>
    select(race_id, precinct_index, precinct_code = precincts)
  
  results_tidy <- races_with_candidates |>
    rowwise() |>
    mutate(
      precinct_data = list(tibble(
        precinct_index = seq_along(votes),
        votes = unlist(votes),
        abs = unlist(abs),
        prov = unlist(prov)
      ))
    ) |>
    ungroup() |>
    select(-votes, -abs, -prov) |>
    unnest(precinct_data) |>
    # Join with precinct codes
    left_join(precinct_codes, by = c("race_id", "precinct_index")) |>
    select(race_id, race_name, race_type, candidate_name, party, precinct_code,
          votes, absentee = abs, provisional = prov,
          overlapVotes, overlapAbs, overlapProv)
  
  return(results_tidy)
}
Show code
library(sf)

# Get updated precinct data with API IDs and election results
precincts <- get_precinct_info()
results <- get_election_results()

# Join results with precincts using the API ID
district7_results <- results |>
  filter(race_id == 18) |>
  left_join(
    precincts |> select(precinct_api_id, precinct_code, full_name, parent_name, poly),
    by = c("precinct_code" = "precinct_api_id")
  )


#### Finding Precincts in District 7
# District 7 Map
# Read the city council districts shapefile
council_districts <- st_read("~/Downloads/Columbus_City_Council_Districts/Columbus_City_Council_Districts.shp", quiet = TRUE)

# Official Precinct Map
precinct_map  <- st_read("~/Downloads/VotingPrecinct.shp", quiet = TRUE)

# Make sure they're in the same CRS
precinct_map <- st_transform(precinct_map, st_crs(council_districts))

# Filter for District 7
district7_boundary <- council_districts |>
  filter(DISTRICT == 7)  # Check column name with glimpse(council_districts)

# Find all precincts that have ANY overlap with District 7
district7_candidates <- st_join(
  precinct_map,
  district7_boundary,
  join = st_intersects
) |>
  filter(!is.na(DISTRICT))

# Now filter out the ones that barely touch (like other municipalities)
district7_precincts <- district7_candidates |>
  rowwise() |>
  mutate(
    overlap_area = tryCatch({
      intersection <- st_intersection(geometry, district7_boundary$geometry)
      st_area(intersection)
    }, error = function(e) units::set_units(0, "m^2")),
    precinct_area = st_area(geometry),
    pct_in_district = as.numeric(overlap_area / precinct_area) * 100
  ) |>
  ungroup() |>
  # Keep precincts that are mostly in District 7 (>5% threshold to catch boundary ones)
  filter(pct_in_district > 5) |>
  # Exclude non-Columbus precincts
  filter(grepl("^COLUMBUS", NAME))

# Get just the precinct names/codes
district7_precinct_codes <- district7_precincts |>
  pull(NAME)  # or whatever column has the precinct code

#### Results for Columbus City Council Race only District 7
# Filter results to only include the 79 precincts that are spatially in District 7

# Function to normalize precinct names
normalize_precinct <- function(name) {
  # Convert to uppercase and remove leading zeros from numbers
  name <- toupper(name)
  # Replace patterns like "01-" with "1-", "02-" with "2-", etc.
  name <- gsub("(COLUMBUS\\s+)0+(\\d)", "\\1\\2", name)
  return(name)
}

# Normalize both datasets
district7_precinct_codes_normalized <- normalize_precinct(district7_precinct_codes)
district7_results_normalized <- district7_results |>
  mutate(full_name_normalized = normalize_precinct(full_name))

# Now filter with normalized names
district7_actual_results <- district7_results_normalized |>
  filter(full_name_normalized %in% district7_precinct_codes_normalized)

# Now summarize
district7_summary <- district7_actual_results |>
  group_by(candidate_name, party) |>
  summarize(
    total_votes = sum(votes, na.rm = TRUE),
    total_absentee = sum(absentee, na.rm = TRUE),
    total_provisional = sum(provisional, na.rm = TRUE),
    total_all = total_votes + total_absentee + total_provisional,
    precincts = n(),
    .groups = "drop"
  ) |>
  mutate(
    pct_of_total = total_all / sum(total_all) * 100
  ) |>
  arrange(desc(total_all))


# Compare to overall results
election_summary <- district7_results |>
  group_by(candidate_name, party) |>
  summarize(
    total_votes = sum(votes, na.rm = TRUE),
    total_all = total_votes,
    precincts = n(),
    .groups = "drop"
  ) |>
  mutate(
    pct_of_total = total_all / sum(total_all) * 100
  ) |>
  arrange(desc(total_all))

library(gt)

# Create separate summaries first
d7_for_table <- district7_summary |>
  select(candidate_name, d7_votes = total_votes, d7_pct = pct_of_total)

city_for_table <- election_summary |>
  select(candidate_name, city_votes = total_votes, city_pct = pct_of_total)

# Join them
comparison <- d7_for_table |>
  left_join(city_for_table, by = "candidate_name")

comparison |>
  gt() |>
  tab_header(title = "District 7 vs. Citywide Results") |>
  cols_label(
    candidate_name = "Candidate",
    d7_votes = "Votes",
    d7_pct = "%",
    city_votes = "Votes",
    city_pct = "%"
  ) |>
  tab_spanner(
    label = "District 7",
    columns = c(d7_votes, d7_pct)
  ) |>
  tab_spanner(
    label = "Citywide",
    columns = c(city_votes, city_pct)
  ) |>
  fmt_number(columns = c(d7_votes, city_votes), decimals = 0) |>
  fmt_percent(columns = c(d7_pct, city_pct), decimals = 1, scale_values = FALSE)
District 7 vs. Citywide Results
Candidate
District 7
Citywide
Votes % Votes %
Jesse Vogel 10,909 61.8% 51,883 49.3%
Tiara Ross 6,624 38.2% 53,401 50.7%
Show code
#### MAPPING
library(leaflet)
library(htmltools)
response <- request("https://vote.franklincountyohio.gov/elections/api/precinct") |>
    req_user_agent("R election data analysis") |>
    req_perform()
data_list <- response |>
    resp_body_string() |>
    parse_json() |>
    pluck("precincts")
  
  # 1. Create a data frame of ALL entities and assign an index
all_entities <- tibble(precinct_index = seq_along(data_list), data = data_list) |>
    unnest_wider(data) 

shp_precincts <- st_read("~/Downloads/VotingPrecinct.shp", quiet = TRUE)

# From shapefile
shp_1a <- shp_precincts |> 
  filter(grepl("1-A|1A", NAME) | grepl("Columbus.*1.*A", NAME)) |>
  slice(1)

# Get its coordinate range
shp_1a_coords <- st_coordinates(shp_1a)

# From API (the first Columbus 1-A precinct)
api_1a <- all_entities |> 
  filter(type == 3, code == "A") |>
  slice(1)

# Shapefile ranges
shp_x_range <- range(shp_1a_coords[,1])  # [1831305, 1834277]
shp_y_range <- range(shp_1a_coords[,2])  # [703290.8, 705702.0]

# API ranges for the same precinct
api_coords <- api_1a$poly[[1]] |> unlist() |> matrix(ncol = 2, byrow = TRUE)
api_x_range <- range(api_coords[,1])
api_y_range <- range(api_coords[,2])

# Calculate scale factors
x_scale <- diff(shp_x_range) / diff(api_x_range)
y_scale <- diff(shp_y_range) / diff(api_y_range)

# Calculate offsets (using the min values)
x_offset <- shp_x_range[1] - (api_x_range[1] * x_scale)
y_offset <- shp_y_range[1] - (api_y_range[1] * y_scale)

# Now apply this transformation to your district 7 data
# Corrected transformation with multipolygon handling
district7_map_data <- district7_results |>
  rowwise() |>
  mutate(
    geometry = list({
      # Handle multipolygons properly
      polys <- lapply(poly, function(p) {
        m <- matrix(unlist(p), ncol = 2, byrow = TRUE)
        # Apply the transformation
        m[, 1] <- (m[, 1] * x_scale) + x_offset
        m[, 2] <- (m[, 2] * y_scale) + y_offset
        # Close the polygon ring if not already closed
        if (!all(m[1, ] == m[nrow(m), ])) {
          m <- rbind(m, m[1, ])
        }
        m
      })
      
      # If there's only one polygon, create POLYGON; else MULTIPOLYGON
      if (length(polys) == 1) {
        st_polygon(list(polys[[1]]))
      } else {
        st_multipolygon(list(polys))
      }
    }) |> st_sfc()
  ) |>
  ungroup() |>
  select(-poly) |>
  st_as_sf() |>
  st_set_crs(3735) |>  # Apply correct CRS
  st_transform(4326)    # Convert to WGS84 for Leaflet


# Now recalculate percentages with the corrected geometry
district7_pct <- district7_map_data |>
  group_by(precinct_code.y, full_name, geometry) |>
  mutate(
    total_votes_precinct = sum(votes),
    pct_votes = votes / total_votes_precinct * 100
  ) |>
  ungroup()

# 1. Define the candidates
candidates_to_show <- c("Jesse Vogel", "Tiara Ross")
map_candidate <- "Jesse Vogel" # Candidate to use for the fill color

# 2. Separate data for the two candidates
vogel_data <- district7_pct |>
  filter(candidate_name == "Jesse Vogel") |>
  select(precinct_code.y, vogel_pct = pct_votes, vogel_votes = votes) |>
  st_set_geometry(NULL)

ross_data <- district7_pct |>
  filter(candidate_name == "Tiara Ross") |>
  select(precinct_code.y, ross_pct = pct_votes, ross_votes = votes) |>
  st_set_geometry(NULL)

# 3. Join the two candidates' results back to the original geometry
district7_joined <- district7_pct |>
  filter(candidate_name == map_candidate) |> # Use Vogel's data for the geometry and primary value
  left_join(vogel_data, by = "precinct_code.y") |>
  left_join(ross_data, by = "precinct_code.y") |>
  # Calculate total votes for all candidates in the precinct for the pop-up
  mutate(total_votes_all = total_votes_precinct) |>
  # Clean up columns used for the map (which are Vogel's values)
  mutate(pct_votes = vogel_pct, votes = vogel_votes) |>
  st_as_sf()


# 4. Re-run the Leaflet map using the joined data

pal_pct <- colorNumeric(
  palette = "PuOr",
  domain = c(0, 100),
  reverse = FALSE
)

# Define the HTML label content once
hover_label_content <- ~HTML(paste0(
  "<b>", full_name, "</b><br>",
  "Jesse Vogel: ", vogel_votes, " (", round(vogel_pct, 1), "%)<br>",
  "Tiara Ross: ", ross_votes, " (", round(ross_pct, 1), "%)<br>",
  "Total Votes in Precinct: ", total_votes_all
))

# Filter for District 7
district7_boundary <- council_districts |>
  filter(DISTRICT == 7) |>  # Adjust column name if different
  st_transform(4326)  # Transform to WGS84 for leaflet

# Add it to your map
leaflet(district7_joined) |>
  addProviderTiles(providers$CartoDB.DarkMatter) |>
  addPolygons(
    fillColor = ~pal_pct(pct_votes),
    fillOpacity = 0.7,
    color = "white",
    weight = 2,
    
    popup = ~paste0(
      "<b>", full_name, "</b><br>",
      "<b>Jesse Vogel:</b> ", round(vogel_pct, 1), "% (", vogel_votes, " votes)<br>",
      "<b>Tiara Ross:</b> ", round(ross_pct, 1), "% (", ross_votes, " votes)<br>",
      "<b>Total Votes:</b> ", total_votes_all
    ),
    
    label = ~full_name,
    
    labelOptions = labelOptions(
      sticky = TRUE,
      direction = "auto",
      opacity = 0.9
    ),
    
    highlightOptions = highlightOptions(
      weight = 3,
      color = "#666",
      fillOpacity = 0.9,
      bringToFront = TRUE
    )
  ) |>
  
  addLegend(
    position = "bottomright",
    pal = pal_pct,
    values = c(0, 100),
    title = paste0(map_candidate, "<br>Vote Share (%)"),
    labFormat = labelFormat(suffix = "%")
  )

I like this map better than the map on the Franklin County elections website. The official map only shows whether a precinct was won by one candidate or the other (or a tie), so it can be a bit misleading if a precinct was only one by a few votes.

You see a few patterns when looking at the map. The downtown neighborhoods and the High Street corridor going up to Worthington turned out for Jesse Vogel. Tiara Ross won heavily in the northeast and far southeast neighborhoods, getting a lot of support from the Black community. You can take a look at the city’s demographics by race and see how closely the voting patterns follow racial demographics.

I really like the dot density map above. I saw a similar plot made by Kieran Healy in his recent blog on the NYC mayoral race. Each dot below represents a single vote, randomly distributed within each precinct. This approach reveals voting patterns more clearly than traditional choropleth maps.

::: {.cell}

Show code
#### Dot Map
library(sf)
library(ggplot2)
library(ggtext)

# Create dot density function
create_dot_density <- function(precinct_sf, votes_col, candidate_name, dots_per_vote = 1) {
 # For each precinct, create random points
 dots_list <- lapply(seq_len(nrow(precinct_sf)), function(i) {
   precinct <- precinct_sf[i, ]
   n_votes <- precinct[[votes_col]]
   n_dots <- n_votes / dots_per_vote
   
   if (n_dots > 0 && !is.na(n_votes)) {
     tryCatch({
       # Generate random points within the polygon
       dots <- st_sample(precinct$geometry, size = round(n_dots), type = "random")
       if (length(dots) > 0) {
         return(st_sf(
           candidate = candidate_name,
           geometry = dots
         ))
       }
     }, error = function(e) {
       return(NULL)
     })
   }
   return(NULL)
 })
 
 # Combine all dots
 valid_dots <- dots_list[!sapply(dots_list, is.null)]
 if (length(valid_dots) > 0) {
   do.call(rbind, valid_dots)
 } else {
   NULL
 }
}

vogel_dots <- create_dot_density(
 district7_joined,
 "vogel_votes",
 "Jesse Vogel",
 dots_per_vote = 1
)

ross_dots <- create_dot_density(
 district7_joined,
 "ross_votes",
 "Tiara Ross",
 dots_per_vote = 1
)

# Combine all dots
all_dots <- rbind(vogel_dots, ross_dots)

table(all_dots$candidate)

district7_dot_map <- ggplot() +
 geom_sf(data = district7_joined |> distinct(geometry), 
         fill = NA, color = "gray20", linewidth = 0.1) +
 geom_sf(data = ross_dots, color = "#ffff00", size = 0.05, alpha = 0.5) +
 geom_sf(data = vogel_dots, color = "#0080ff", size = 0.05, alpha = 0.5) +
 theme_void() +
 theme(
   plot.background = element_rect(fill = "black", color = NA),
   panel.background = element_rect(fill = "black", color = NA),
   legend.position = "none",
   plot.title = element_text(color = "white", size = 36, face = "bold"),
   plot.subtitle = element_markdown(color = "white", size = 24, lineheight = 1.5),  # Use element_markdown!
   plot.margin = margin(20, 20, 20, 20)
 ) +
 labs(
   title = "Vogel vs Ross Votes - District 7",
   subtitle = "Each dot represents 1 vote<br><br><span style='color:#0080ff;'>●</span> Jesse Vogel <span style='color:#ffff00;'>●</span> Tiara Ross<br>Votes are distributed randomly within precincts.<br>Franklin County results only."
 )
 

ggsave("district7_dot_map.png", 
      width = 16, 
      height = 16, 
      dpi = 600,  # Much higher DPI
      bg = "black")

#### with district 7 outline
columbus_boundary <- council_districts |>
 st_union() |>
 st_transform(4326)

district7_dot_map_with_lines <- ggplot() +
 # Add Columbus city boundary (light gray outline)
 geom_sf(data = columbus_boundary,
         fill = NA, color = "gray50", linewidth = 0.5) +
 # Add precinct boundaries (subtle)
 geom_sf(data = district7_joined |> distinct(geometry), 
         fill = NA, color = "gray20", linewidth = 0.1) +
 # Add dots
 geom_sf(data = ross_dots, color = "#ffff00", size = 0.05, alpha = 0.5) +
 geom_sf(data = vogel_dots, color = "#0080ff", size = 0.05, alpha = 0.5) +
 # Add District 7 boundary (bold white outline)
 geom_sf(data = district7_boundary, 
         fill = NA, color = "white", linewidth = 1) +
 theme_void() +
 theme(
   plot.background = element_rect(fill = "black", color = NA),
   panel.background = element_rect(fill = "black", color = NA),
   legend.position = "none",
   plot.title = element_text(color = "white", size = 36, face = "bold"),
   plot.subtitle = element_markdown(color = "white", size = 24, lineheight = 1.5),
   plot.margin = margin(20, 20, 20, 20)
 ) +
 labs(
   title = "Vogel vs Ross Votes - District 7",
   subtitle = "Each dot represents 1 vote<br><br><span style='color:#0080ff;'>●</span> Jesse Vogel <span style='color:#ffff00;'>●</span> Tiara Ross<br>Votes are distributed randomly within precincts.<br>Franklin County results only."
 )

ggsave("district7_dot_map_with_lines.png", 
      width = 16, 
      height = 16, 
      dpi = 600,  # Much higher DPI
      bg = "black")

:::

Vogel vs Ross votes in Columbus (Franklin County)

Vogel vs Ross votes in District 7

The maps and tables confirm my initial analysis and reporting after the election. Vogel won District 7 with nearly 62% of the vote but lost citywide with 49%. It’s frustrating to see a candidate win their district so decisively yet still lost their race (especially since I supported his campaign). The system is designed to support establishment candidates with the backing of the Franklin County Democratic Party, making it extraordinarily difficult for outsiders to break through. But there’s a reason for hope here. Vogel came remarkably close to winning, losing by just 1,600 votes out of over 100,000 cast. His campaign proved that a progressive candidate without party backing can mount a serious challenge in Columbus. I’m hopeful future progressive candidates will be inspired to run and eventually break through.

All code and data for this analysis are available in the expandable code sections above. The analysis uses data from the Franklin County Board of Elections as of November 13, 2024.