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.
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 Ohiolibrary(httr2)library(jsonlite)library(dplyr)library(tidyr)library(purrr)library(tibble)# Step 1: Get precinct geography/namesget_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 linksselect( 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 resultsget_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 codesleft_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 resultsprecincts <-get_precinct_info()results <-get_election_results()# Join results with precincts using the API IDdistrict7_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 shapefilecouncil_districts <-st_read("~/Downloads/Columbus_City_Council_Districts/Columbus_City_Council_Districts.shp", quiet =TRUE)# Official Precinct Mapprecinct_map <-st_read("~/Downloads/VotingPrecinct.shp", quiet =TRUE)# Make sure they're in the same CRSprecinct_map <-st_transform(precinct_map, st_crs(council_districts))# Filter for District 7district7_boundary <- council_districts |>filter(DISTRICT ==7) # Check column name with glimpse(council_districts)# Find all precincts that have ANY overlap with District 7district7_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 precinctsfilter(grepl("^COLUMBUS", NAME))# Get just the precinct names/codesdistrict7_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 namesnormalize_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 datasetsdistrict7_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 namesdistrict7_actual_results <- district7_results_normalized |>filter(full_name_normalized %in% district7_precinct_codes_normalized)# Now summarizedistrict7_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 resultselection_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 firstd7_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 themcomparison <- 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
#### MAPPINGlibrary(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 indexall_entities <-tibble(precinct_index =seq_along(data_list), data = data_list) |>unnest_wider(data) shp_precincts <-st_read("~/Downloads/VotingPrecinct.shp", quiet =TRUE)# From shapefileshp_1a <- shp_precincts |>filter(grepl("1-A|1A", NAME) |grepl("Columbus.*1.*A", NAME)) |>slice(1)# Get its coordinate rangeshp_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 rangesshp_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 precinctapi_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 factorsx_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 handlingdistrict7_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 closedif (!all(m[1, ] == m[nrow(m), ])) { m <-rbind(m, m[1, ]) } m })# If there's only one polygon, create POLYGON; else MULTIPOLYGONif (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 CRSst_transform(4326) # Convert to WGS84 for Leaflet# Now recalculate percentages with the corrected geometrydistrict7_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 candidatescandidates_to_show <-c("Jesse Vogel", "Tiara Ross")map_candidate <-"Jesse Vogel"# Candidate to use for the fill color# 2. Separate data for the two candidatesvogel_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 geometrydistrict7_joined <- district7_pct |>filter(candidate_name == map_candidate) |># Use Vogel's data for the geometry and primary valueleft_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-upmutate(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 datapal_pct <-colorNumeric(palette ="PuOr",domain =c(0, 100),reverse =FALSE)# Define the HTML label content oncehover_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 7district7_boundary <- council_districts |>filter(DISTRICT ==7) |># Adjust column name if differentst_transform(4326) # Transform to WGS84 for leaflet# Add it to your mapleaflet(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 Maplibrary(sf)library(ggplot2)library(ggtext)# Create dot density functioncreate_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_voteif (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 dotsall_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 DPIbg ="black")#### with district 7 outlinecolumbus_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 dotsgeom_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 DPIbg ="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.