6. Intersectional Patterns

How LGBTQ+ Status Interacts with Gender, Race, and Ideology

Show code
source(here::here("code", "00_setup.R"))
df <- readRDS(paths$analysis_full_rds)

# Set ideology category ordering: Left → Center → Right
# Create three-way group for Trans vs LGB vs Non-LGBTQ+ comparison
df <- df %>%
  mutate(
    ideology_category = factor(ideology_category, levels = ideology_levels),
    threeway_group = case_when(
      trans_candidate ~ "Trans",
      lgb_candidate   ~ "LGB",
      TRUE            ~ "Non-LGBTQ+"
    ) %>% factor(levels = c("Non-LGBTQ+", "LGB", "Trans"))
  )

1 Overview

LGBTQ+ candidates are not a monolith. Their experiences are shaped by other dimensions of identity — gender, race, and the ideological environment they compete in. An intersectional lens reveals how these dimensions compound or mitigate the challenges LGBTQ+ candidates face.

This chapter systematically examines two-way and three-way interactions between LGBTQ+ status and gender, race, and ideology. We focus on two key outcomes: candidacy patterns (who runs) and electoral success (who wins), along with the campaign finance dimension explored in the previous chapter.

Results by Position Type

Two-way intersectional analyses (LGBTQ+ x Gender, LGBTQ+ x Race, LGBTQ+ x Ideology) are presented separately for city councilors and mayors/vice-mayors. The triple intersection and trans-specific sections pool across positions because further disaggregation produces cell sizes too small for meaningful comparison.

Methodological Note

Intersectional analysis with small subgroup sizes must be interpreted cautiously. When cell sizes drop below 30, we flag the estimates as imprecise. For trans candidates specifically, the small N makes most intersectional breakdowns unreliable for inferential purposes — we present them descriptively as a starting point.

2 LGBTQ+ x Gender

2.1 Cross-Tabulation

Gender is recorded on TSE registration forms (Female/Male). Election rate is the proportion of candidates who won their race. Total revenue is the sum of all campaign receipts registered with the TSE, including cash donations, party transfers, self-funding, and the estimated monetary value of in-kind contributions.

Show code
render_gender_intersection <- function(data, tab_name) {
  d <- data %>%
    filter(!is.na(female), !is.na(elected)) %>%
    mutate(
      lgbtq_group  = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+"),
      gender_label = if_else(female, "Female", "Male")
    )

  # --- Cross-tabulation table ---
  cat("### Cross-Tabulation\n\n")

  lgbtq_gender <- d %>%
    group_by(lgbtq_group, gender_label) %>%
    summarise(
      N             = n(),
      Elected       = sum(elected),
      `Elect. Rate` = format_pct(mean(elected)),
      `Mean Rev.`   = format_brl(mean(total_revenue, na.rm = TRUE)),
      `Median Rev.` = format_brl(median(total_revenue, na.rm = TRUE)),
      .groups = "drop"
    )

  lgbtq_gender %>%
    rename(`LGBTQ+ Status` = lgbtq_group, Gender = gender_label) %>%
    cat_kable(align = c("l", "l", "r", "r", "r", "r", "r"))

  # --- Election rate chart ---
  cat("### Election Rate\n\n")

  p_rate <- d %>%
    group_by(lgbtq_group, gender_label) %>%
    summarise(rate = mean(elected), n = n(), .groups = "drop") %>%
    ggplot(aes(x = gender_label, y = rate, fill = lgbtq_group)) +
    geom_col(position = "dodge", alpha = 0.9, width = 0.7) +
    geom_text(aes(label = paste0(format_pct(rate), "\n(n=", format_n(n), ")")),
              position = position_dodge(width = 0.7), vjust = -0.3, size = 3.5) +
    scale_fill_manual(values = pal_lgbtq, name = NULL) +
    scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.2))) +
    labs(
      x     = NULL, y = "Election Rate",
      title = paste0("Election Rate: LGBTQ+ x Gender (", tab_name, ")"),
      subtitle = "Does LGBTQ+ status interact with gender in shaping electoral outcomes?"
    )
  cat_plot(p_rate, paste0("06-lgbtq-gender-rate-", pos_suffix(tab_name)))

  # --- Revenue chart ---
  cat("### Median Revenue\n\n")

  rev_data <- data %>%
    filter(!is.na(female), total_revenue > 0) %>%
    mutate(
      lgbtq_group  = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+"),
      gender_label = if_else(female, "Female", "Male")
    ) %>%
    group_by(lgbtq_group, gender_label) %>%
    summarise(median_rev = median(total_revenue), n = n(), .groups = "drop")

  p_rev <- rev_data %>%
    ggplot(aes(x = gender_label, y = median_rev, fill = lgbtq_group)) +
    geom_col(position = "dodge", alpha = 0.9, width = 0.7) +
    geom_text(aes(label = format_brl(median_rev)),
              position = position_dodge(width = 0.7), vjust = -0.3, size = 3.5) +
    scale_fill_manual(values = pal_lgbtq, name = NULL) +
    scale_y_continuous(labels = label_dollar(prefix = "R$", big.mark = ","),
                       expand = expansion(mult = c(0, 0.2))) +
    labs(
      x     = NULL, y = "Median Revenue (R$)",
      title = paste0("Median Revenue: LGBTQ+ x Gender (", tab_name, ")"),
      subtitle = "Among candidates with positive revenue"
    )
  cat_plot(p_rev, paste0("06-lgbtq-gender-revenue-", pos_suffix(tab_name)))

  cat("::: {.callout-important}\n")
  cat("## Double Disadvantage?\n")
  cat("The \"double disadvantage\" hypothesis predicts that marginalized identities compound ",
      "rather than substitute for each other --- so LGBTQ+ women would face the lowest ",
      "election rates and revenue. The data above allow us to assess whether this pattern holds.\n")
  cat(":::\n\n")
}

render_position_tabset(render_gender_intersection, df)

2.1.1 Cross-Tabulation

LGBTQ+ Status Gender N Elected Elect. Rate Mean Rev.  Median Rev. 
LGBTQ+ Female 1473 88 6.0% R$22,564 R$4,980
LGBTQ+ Male 1360 129 9.5% R$11,045 R$3,019
Non-LGBTQ+ Female 142819 10403 7.3% R$6,741 R$1,800
Non-LGBTQ+ Male 262817 46813 17.8% R$6,795 R$1,877

2.1.2 Election Rate

2.1.3 Median Revenue

Double Disadvantage?

The “double disadvantage” hypothesis predicts that marginalized identities compound rather than substitute for each other — so LGBTQ+ women would face the lowest election rates and revenue. The data above allow us to assess whether this pattern holds.

2.1.4 Cross-Tabulation

LGBTQ+ Status Gender N Elected Elect. Rate Mean Rev.  Median Rev. 
LGBTQ+ Female 31 2 6.5% R$296,722 R$0
LGBTQ+ Male 56 8 14.3% R$164,295 R$0
Non-LGBTQ+ Female 5776 1769 30.6% R$97,093 R$0
Non-LGBTQ+ Male 24249 9161 37.8% R$105,379 R$7,442

2.1.5 Election Rate

2.1.6 Median Revenue

Double Disadvantage?

The “double disadvantage” hypothesis predicts that marginalized identities compound rather than substitute for each other — so LGBTQ+ women would face the lowest election rates and revenue. The data above allow us to assess whether this pattern holds.

Note

This tab pools city councilors (proportional representation) and mayors/vice-mayors (plurality). Position-specific results in the other tabs may be more informative.

2.1.7 Cross-Tabulation

LGBTQ+ Status Gender N Elected Elect. Rate Mean Rev.  Median Rev. 
LGBTQ+ Female 1504 90 6.0% R$28,214 R$4,950
LGBTQ+ Male 1416 137 9.7% R$17,106 R$3,000
Non-LGBTQ+ Female 148595 12172 8.2% R$10,253 R$1,753
Non-LGBTQ+ Male 287066 55974 19.5% R$15,122 R$1,890

2.1.8 Election Rate

2.1.9 Median Revenue

Double Disadvantage?

The “double disadvantage” hypothesis predicts that marginalized identities compound rather than substitute for each other — so LGBTQ+ women would face the lowest election rates and revenue. The data above allow us to assess whether this pattern holds.

3 LGBTQ+ x Race

3.1 Cross-Tabulation

Race is simplified into White and Nonwhite (Black, Brown, and Other combined) for the intersectional analysis.

Show code
render_race_intersection <- function(data, tab_name) {
  d <- data %>%
    filter(!is.na(nonwhite), !is.na(elected)) %>%
    mutate(
      lgbtq_group = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+"),
      race_label  = if_else(nonwhite, "Nonwhite", "White")
    )

  # --- Cross-tabulation table ---
  cat("### Cross-Tabulation\n\n")

  lgbtq_race <- d %>%
    group_by(lgbtq_group, race_label) %>%
    summarise(
      N             = n(),
      Elected       = sum(elected),
      `Elect. Rate` = format_pct(mean(elected)),
      `Mean Rev.`   = format_brl(mean(total_revenue, na.rm = TRUE)),
      `Median Rev.` = format_brl(median(total_revenue, na.rm = TRUE)),
      .groups = "drop"
    )

  lgbtq_race %>%
    rename(`LGBTQ+ Status` = lgbtq_group, Race = race_label) %>%
    cat_kable(align = c("l", "l", "r", "r", "r", "r", "r"))

  # --- Election rate chart ---
  cat("### Election Rate\n\n")

  p_rate <- d %>%
    group_by(lgbtq_group, race_label) %>%
    summarise(rate = mean(elected), n = n(), .groups = "drop") %>%
    ggplot(aes(x = race_label, y = rate, fill = lgbtq_group)) +
    geom_col(position = "dodge", alpha = 0.9, width = 0.7) +
    geom_text(aes(label = paste0(format_pct(rate), "\n(n=", format_n(n), ")")),
              position = position_dodge(width = 0.7), vjust = -0.3, size = 3.5) +
    scale_fill_manual(values = pal_lgbtq, name = NULL) +
    scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.2))) +
    labs(
      x     = NULL, y = "Election Rate",
      title = paste0("Election Rate: LGBTQ+ x Race (", tab_name, ")"),
      subtitle = "Examining how racial identity interacts with LGBTQ+ status"
    )
  cat_plot(p_rate, paste0("06-lgbtq-race-rate-", pos_suffix(tab_name)))

  # --- Revenue chart ---
  cat("### Median Revenue\n\n")

  rev_data <- data %>%
    filter(!is.na(nonwhite), total_revenue > 0) %>%
    mutate(
      lgbtq_group = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+"),
      race_label  = if_else(nonwhite, "Nonwhite", "White")
    ) %>%
    group_by(lgbtq_group, race_label) %>%
    summarise(median_rev = median(total_revenue), n = n(), .groups = "drop")

  p_rev <- rev_data %>%
    ggplot(aes(x = race_label, y = median_rev, fill = lgbtq_group)) +
    geom_col(position = "dodge", alpha = 0.9, width = 0.7) +
    geom_text(aes(label = format_brl(median_rev)),
              position = position_dodge(width = 0.7), vjust = -0.3, size = 3.5) +
    scale_fill_manual(values = pal_lgbtq, name = NULL) +
    scale_y_continuous(labels = label_dollar(prefix = "R$", big.mark = ","),
                       expand = expansion(mult = c(0, 0.2))) +
    labs(
      x     = NULL, y = "Median Revenue (R$)",
      title = paste0("Median Revenue: LGBTQ+ x Race (", tab_name, ")"),
      subtitle = "Among candidates with positive revenue"
    )
  cat_plot(p_rev, paste0("06-lgbtq-race-revenue-", pos_suffix(tab_name)))
}

render_position_tabset(render_race_intersection, df)

3.1.1 Cross-Tabulation

LGBTQ+ Status Race N Elected Elect. Rate Mean Rev.  Median Rev. 
LGBTQ+ Nonwhite 1723 112 6.5% R$18,302 R$3,963
LGBTQ+ White 1110 105 9.5% R$15,066 R$3,710
Non-LGBTQ+ Nonwhite 217917 26881 12.3% R$6,709 R$1,800
Non-LGBTQ+ White 187719 30335 16.2% R$6,853 R$1,908

3.1.2 Election Rate

3.1.3 Median Revenue

3.1.4 Cross-Tabulation

LGBTQ+ Status Race N Elected Elect. Rate Mean Rev.  Median Rev. 
LGBTQ+ Nonwhite 37 3 8.1% R$18,764 R$0
LGBTQ+ White 50 7 14.0% R$354,092 R$3,500
Non-LGBTQ+ Nonwhite 11585 3965 34.2% R$91,746 R$0
Non-LGBTQ+ White 18440 6965 37.8% R$111,349 R$8,500

3.1.5 Election Rate

3.1.6 Median Revenue

Note

This tab pools city councilors (proportional representation) and mayors/vice-mayors (plurality). Position-specific results in the other tabs may be more informative.

3.1.7 Cross-Tabulation

LGBTQ+ Status Race N Elected Elect. Rate Mean Rev.  Median Rev. 
LGBTQ+ Nonwhite 1760 115 6.5% R$18,312 R$3,890
LGBTQ+ White 1160 112 9.7% R$29,679 R$3,710
Non-LGBTQ+ Nonwhite 229502 30846 13.4% R$11,002 R$1,780
Non-LGBTQ+ White 206159 37300 18.1% R$16,200 R$1,919

3.1.8 Election Rate

3.1.9 Median Revenue

4 LGBTQ+ x Ideology

4.1 Distribution Across the Ideological Spectrum

Party ideology scores are drawn from Bolognesi et al.’s expert survey (0–10 left-right scale; Left < 4.0, Center 4.0–7.1, Right > 7.1).

Show code
render_ideology_intersection <- function(data, tab_name) {
  simplified <- use_simplified(data)

  # --- Ideology distribution ---
  cat("### Ideological Composition\n\n")

  p_mosaic <- data %>%
    filter(!is.na(ideology_category)) %>%
    mutate(lgbtq_group = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+")) %>%
    count(lgbtq_group, ideology_category) %>%
    group_by(lgbtq_group) %>%
    mutate(pct = n / sum(n)) %>%
    ungroup() %>%
    ggplot(aes(x = lgbtq_group, y = pct, fill = ideology_category)) +
    geom_col(alpha = 0.9, width = 0.6) +
    geom_text(aes(label = format_pct(pct)),
              position = position_stack(vjust = 0.5), size = 3.5, color = "white",
              fontface = "bold") +
    scale_fill_manual(values = pal_ideology, name = "Ideology") +
    scale_y_continuous(labels = percent) +
    labs(
      x     = NULL, y = "Share of Candidates",
      title = paste0("Ideological Composition (", tab_name, ")"),
      subtitle = "Based on party ideology scores (Bolognesi et al.)"
    )
  cat_plot(p_mosaic, paste0("06-lgbtq-ideology-mosaic-", pos_suffix(tab_name)))

  # --- Election rates by ideology ---
  cat("### Election Rates by Ideology\n\n")

  d_ideo <- data %>%
    filter(!is.na(ideology_category), !is.na(elected)) %>%
    mutate(lgbtq_group = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+"))

  ideo_rates <- d_ideo %>%
    group_by(ideology_category, lgbtq_group) %>%
    summarise(N = n(), Rate = format_pct(mean(elected)), .groups = "drop") %>%
    pivot_wider(names_from = lgbtq_group, values_from = c(N, Rate)) %>%
    select(
      Ideology          = ideology_category,
      `N LGBTQ+`        = `N_LGBTQ+`,
      `N Non-LGBTQ+`    = `N_Non-LGBTQ+`,
      `Rate LGBTQ+`     = `Rate_LGBTQ+`,
      `Rate Non-LGBTQ+` = `Rate_Non-LGBTQ+`
    )
  cat_kable(ideo_rates, align = c("l", "r", "r", "r", "r"))

  # --- Interaction plot ---
  cat("### Interaction Plot\n\n")

  p_interaction <- d_ideo %>%
    group_by(ideology_category, lgbtq_group) %>%
    summarise(
      rate = mean(elected), n = n(),
      se = sqrt(rate * (1 - rate) / n),
      .groups = "drop"
    ) %>%
    ggplot(aes(x = ideology_category, y = rate,
               color = lgbtq_group, group = lgbtq_group)) +
    geom_line(linewidth = 1.2) +
    geom_point(aes(size = n), alpha = 0.8) +
    geom_errorbar(aes(ymin = pmax(rate - 1.96 * se, 0),
                      ymax = pmin(rate + 1.96 * se, 1)),
                  width = 0.15, linewidth = 0.6) +
    scale_color_manual(values = pal_lgbtq, name = NULL) +
    scale_size_continuous(range = c(2, 6), name = "N candidates",
                          labels = comma) +
    scale_y_continuous(labels = percent) +
    labs(
      x     = "Party Ideology", y = "Election Rate",
      title = paste0("LGBTQ+ Electoral Gap by Ideology (", tab_name, ")"),
      subtitle = "Lines show election rates with 95% CIs; point size = N",
      caption = "Ideology based on party-level expert survey scores."
    )
  cat_plot(p_interaction, paste0("06-lgbtq-ideology-interaction-", pos_suffix(tab_name)))

  cat("::: {.callout-note}\n")
  cat("## Ideology as Context\n")
  cat("The interaction between LGBTQ+ status and ideology is substantively important. ",
      "If the gap differs by ideology, this suggests that the partisan environment ",
      "moderates the relationship between LGBTQ+ identity and electoral outcomes.\n")
  cat(":::\n\n")
}

render_position_tabset(render_ideology_intersection, df)

4.1.1 Ideological Composition

4.1.2 Election Rates by Ideology

Ideology N LGBTQ+ N Non-LGBTQ+ Rate LGBTQ+ Rate Non-LGBTQ+
Left 1113 51292 8.1% 11.4%
Center 905 145940 7.6% 15.2%
Right 815 208404 7.1% 14.0%

4.1.3 Interaction Plot

Ideology as Context

The interaction between LGBTQ+ status and ideology is substantively important. If the gap differs by ideology, this suggests that the partisan environment moderates the relationship between LGBTQ+ identity and electoral outcomes.

4.1.4 Ideological Composition

4.1.5 Election Rates by Ideology

Ideology N LGBTQ+ N Non-LGBTQ+ Rate LGBTQ+ Rate Non-LGBTQ+
Left 51 4660 0.0% 19.5%
Center 21 10671 28.6% 41.5%
Right 15 14694 26.7% 38.1%

4.1.6 Interaction Plot

Ideology as Context

The interaction between LGBTQ+ status and ideology is substantively important. If the gap differs by ideology, this suggests that the partisan environment moderates the relationship between LGBTQ+ identity and electoral outcomes.

Note

This tab pools city councilors (proportional representation) and mayors/vice-mayors (plurality). Position-specific results in the other tabs may be more informative.

4.1.7 Ideological Composition

4.1.8 Election Rates by Ideology

Ideology N LGBTQ+ N Non-LGBTQ+ Rate LGBTQ+ Rate Non-LGBTQ+
Left 1164 55952 7.7% 12.1%
Center 926 156611 8.1% 17.0%
Right 830 223098 7.5% 15.6%

4.1.9 Interaction Plot

Ideology as Context

The interaction between LGBTQ+ status and ideology is substantively important. If the gap differs by ideology, this suggests that the partisan environment moderates the relationship between LGBTQ+ identity and electoral outcomes.

5 Triple Intersection: LGBTQ+ x Gender x Race

5.1 Eight-Cell Table

The table below cross-tabulates LGBTQ+ status, gender (Female/Male), and race (White/Nonwhite) to produce eight intersectional cells. For each cell, we report the count, number elected, election rate with 95% confidence intervals, and mean campaign revenue. Cells with fewer than 30 observations are flagged with an asterisk.

Pooled Across Positions

The triple intersection analysis pools across city councilors and mayors/vice-mayors. With 8 intersectional cells (2 LGBTQ+ statuses x 2 genders x 2 races), further disaggregation by position would produce 24 cells. Given that the LGBTQ+ executive candidate pool contains only ~91 individuals, many cells would have fewer than 5 observations, rendering statistical comparisons unreliable. Position-specific two-way intersections are available in the tabs above.

Show code
triple <- df %>%
  filter(!is.na(female), !is.na(nonwhite), !is.na(elected)) %>%
  mutate(
    lgbtq_group  = if_else(lgbtq_candidate, "LGBTQ+", "Non-LGBTQ+"),
    gender_label = if_else(female, "Female", "Male"),
    race_label   = if_else(nonwhite, "Nonwhite", "White")
  ) %>%
  group_by(lgbtq_group, gender_label, race_label) %>%
  summarise(
    N             = n(),
    Elected       = sum(elected),
    `Elect. Rate` = mean(elected),
    `Mean Rev.`   = mean(total_revenue, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(lgbtq_group, gender_label, race_label) %>%
  rowwise() %>%
  mutate(
    ci = list(binom.test(Elected, N)$conf.int),
    CI_low = ci[1],
    CI_high = ci[2],
    small_n = N < 30
  ) %>%
  ungroup() %>%
  select(-ci)

triple %>%
  mutate(
    `Elect. Rate` = paste0(format_pct(`Elect. Rate`),
                           ifelse(small_n, " *", "")),
    `95% CI` = paste0("[", format_pct(CI_low), ", ", format_pct(CI_high), "]"),
    `Mean Rev.`   = format_brl(`Mean Rev.`)
  ) %>%
  rename(`LGBTQ+ Status` = lgbtq_group, Gender = gender_label, Race = race_label) %>%
  select(-CI_low, -CI_high, -small_n) %>%
  kable(align = c("l", "l", "l", "r", "r", "r", "r", "r"))
Table 1: Triple Intersection: LGBTQ+ Status x Gender x Race
LGBTQ+ Status Gender Race N Elected Elect. Rate Mean Rev. 95% CI
LGBTQ+ Female Nonwhite 924 47 5.1% R$24,250 [3.8%, 6.7%]
LGBTQ+ Female White 580 43 7.4% R$34,530 [5.4%, 9.9%]
LGBTQ+ Male Nonwhite 836 68 8.1% R$11,748 [6.4%, 10.2%]
LGBTQ+ Male White 580 69 11.9% R$24,829 [9.4%, 14.8%]
Non-LGBTQ+ Female Nonwhite 78043 5008 6.4% R$ 9,280 [6.2%, 6.6%]
Non-LGBTQ+ Female White 70552 7164 10.2% R$11,329 [9.9%, 10.4%]
Non-LGBTQ+ Male Nonwhite 151459 25838 17.1% R$11,889 [16.9%, 17.2%]
Non-LGBTQ+ Male White 135607 30136 22.2% R$18,734 [22.0%, 22.4%]

* indicates N < 30; interpret with caution.

Show code
triple %>%
  mutate(
    cell_label = paste(gender_label, race_label, sep = " / "),
    rate = `Elect. Rate`
  ) %>%
  ggplot(aes(x = rate, y = reorder(cell_label, rate),
             color = lgbtq_group, shape = lgbtq_group)) +
  geom_point(aes(size = N), alpha = 0.8,
             position = position_dodge(width = 0.5)) +
  geom_text(aes(label = paste0(format_pct(rate), " (n=", format_n(N), ")")),
            position = position_dodge(width = 0.5),
            hjust = -0.15, size = 3, show.legend = FALSE) +
  scale_color_manual(values = pal_lgbtq, name = NULL) +
  scale_shape_manual(values = c("LGBTQ+" = 16, "Non-LGBTQ+" = 17), name = NULL) +
  scale_size_continuous(range = c(2, 8), name = "N", labels = comma) +
  scale_x_continuous(labels = percent, expand = expansion(mult = c(0.05, 0.35))) +
  labs(
    x        = "Election Rate",
    y        = NULL,
    title    = "Intersectional Election Rates",
    subtitle = "LGBTQ+ status x Gender x Race (8 cells)",
    caption  = "Point size proportional to cell N. Small LGBTQ+ cells should be interpreted with caution."
  )

save_figure(last_plot(), "06_triple_dot", height = 8)
Figure 1: Election Rate by LGBTQ+ Status, Gender, and Race (Dot Plot)
Show code
triple %>%
  mutate(cell_label = paste(gender_label, race_label, sep = " / ")) %>%
  ggplot(aes(x = `Mean Rev.`, y = reorder(cell_label, `Mean Rev.`),
             color = lgbtq_group, shape = lgbtq_group)) +
  geom_point(aes(size = N), alpha = 0.8,
             position = position_dodge(width = 0.5)) +
  geom_text(aes(label = format_brl(`Mean Rev.`)),
            position = position_dodge(width = 0.5),
            hjust = -0.15, size = 3, show.legend = FALSE) +
  scale_color_manual(values = pal_lgbtq, name = NULL) +
  scale_shape_manual(values = c("LGBTQ+" = 16, "Non-LGBTQ+" = 17), name = NULL) +
  scale_size_continuous(range = c(2, 8), name = "N", labels = comma) +
  scale_x_continuous(labels = label_dollar(prefix = "R$", big.mark = ","),
                     expand = expansion(mult = c(0.05, 0.35))) +
  labs(
    x        = "Mean Revenue (R$)",
    y        = NULL,
    title    = "Intersectional Revenue Patterns",
    subtitle = "LGBTQ+ status x Gender x Race",
    caption  = "Point size proportional to cell N."
  )

save_figure(last_plot(), "06_triple_revenue", height = 8)
Figure 2: Mean Revenue by LGBTQ+ Status, Gender, and Race
Compounding Disadvantage

The triple intersection tests whether multiple marginalized identities (LGBTQ+, female, nonwhite) produce compounding disadvantage that is greater than the sum of its parts, or whether the effects are merely additive. The dot plots and table above allow direct comparison of the most and least privileged intersectional cells.

6 Trans vs LGB vs Non-LGBTQ+

The analyses above compare LGBTQ+ candidates as a single group against non-LGBTQ+ candidates. But the LGBTQ+ umbrella encompasses distinct experiences: trans candidates face identity-specific barriers (documentation, social stigma) that differ from those faced by cisgender LGB candidates. This section uses a three-way comparison — Trans, LGB (cisgender), and Non-LGBTQ+ — to reveal whether the “LGBTQ+ effect” is driven primarily by one subgroup.

Pooled Across Positions

This three-way comparison pools across positions because the Trans group contains too few executive candidates (mayors/vice-mayors) to sustain position-specific breakdowns. Among LGBTQ+ executive candidates, the trans subgroup has very small cell sizes that would make separate estimates unreliable.

6.1 Demographic Profile

Show code
threeway_demo <- df %>%
  group_by(threeway_group) %>%
  summarise(
    N = n(),
    `% Female` = round(mean(female, na.rm = TRUE) * 100, 1),
    `% Nonwhite` = round(mean(nonwhite, na.rm = TRUE) * 100, 1),
    `Mean Age` = round(mean(age, na.rm = TRUE), 1),
    `% College+` = round(mean(education_simple == "College+", na.rm = TRUE) * 100, 1),
    `Median Revenue` = format_brl(median(total_revenue, na.rm = TRUE)),
    `Election Rate (%)` = round(mean(elected, na.rm = TRUE) * 100, 1),
    .groups = "drop"
  )

threeway_demo %>%
  rename(Group = threeway_group) %>%
  kable(align = c("l", rep("r", 7)), format.args = list(big.mark = ","))
Table 2: Demographic Profile: Trans vs LGB vs Non-LGBTQ+
Group N % Female % Nonwhite Mean Age % College+ Median Revenue Election Rate (%)
Non-LGBTQ+ 461,042 34.2 53.1 47.3 32.8 R$1,749 15.6
LGB 1,945 45.6 60.3 38.1 56.0 R$4,273 8.3
Trans 614 77.0 65.8 39.6 31.4 R$3,018 5.7

6.2 Ideology Comparison

Show code
ggplot(df %>% filter(!is.na(ideology_score)),
       aes(x = ideology_score, fill = threeway_group)) +
  geom_density(alpha = 0.5) +
  scale_fill_manual(values = c("Non-LGBTQ+" = "#BDC3C7", "LGB" = "#3498DB", "Trans" = "#F39C12")) +
  labs(
    x = "Party Ideology Score (0 = Left, 10 = Right)",
    y = "Density",
    fill = NULL,
    title = "Ideological Distribution: Trans vs LGB vs Non-LGBTQ+"
  )

save_figure(last_plot(), "06_threeway_ideology")
Figure 3: Ideology Score Distribution: Trans vs LGB vs Non-LGBTQ+

6.3 Election Rates

Show code
threeway_rates <- df %>%
  filter(!is.na(elected)) %>%
  group_by(threeway_group) %>%
  summarise(
    n = n(),
    elected = sum(elected),
    rate = elected / n,
    ci_lo = binom.test(elected, n)$conf.int[1],
    ci_hi = binom.test(elected, n)$conf.int[2],
    .groups = "drop"
  )

ggplot(threeway_rates, aes(x = threeway_group, y = rate * 100,
                            fill = threeway_group)) +
  geom_col(alpha = 0.85) +
  geom_errorbar(aes(ymin = ci_lo * 100, ymax = ci_hi * 100), width = 0.2) +
  geom_text(aes(label = paste0(round(rate * 100, 1), "%\n(N=", format_n(n), ")")),
            vjust = -0.5, size = 3.5) +
  scale_fill_manual(values = c("Non-LGBTQ+" = "#BDC3C7", "LGB" = "#3498DB", "Trans" = "#F39C12"),
                    guide = "none") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
  labs(
    x = NULL, y = "Election Rate (%)",
    title = "Election Rates by Group",
    subtitle = "With 95% confidence intervals"
  )

save_figure(last_plot(), "06_threeway_election")
Figure 4: Election Rates: Trans vs LGB vs Non-LGBTQ+

6.4 Revenue Comparison

Show code
threeway_rev <- df %>%
  filter(total_revenue > 0) %>%
  group_by(threeway_group) %>%
  summarise(
    N = n(),
    `Median Revenue` = format_brl(median(total_revenue)),
    `Mean Revenue` = format_brl(mean(total_revenue)),
    `% Self-Funded` = round(mean(pct_self, na.rm = TRUE), 1),
    `% Party` = round(mean(pct_party, na.rm = TRUE), 1),
    `% Individual` = round(mean(pct_individual, na.rm = TRUE), 1),
    `Median Donors` = round(median(n_unique_donors, na.rm = TRUE), 1),
    .groups = "drop"
  )

threeway_rev %>%
  rename(Group = threeway_group) %>%
  kable(align = c("l", rep("r", 7)), format.args = list(big.mark = ","))
Table 3: Revenue and Funding Sources: Trans vs LGB vs Non-LGBTQ+
Group N Median Revenue Mean Revenue % Self-Funded % Party % Individual Median Donors
Non-LGBTQ+ 375,723 R$2,733 R$20,269 17.9 33.4 21.2 2
LGB 1,707 R$5,456 R$28,619 12.6 55.2 15.6 2
Trans 518 R$4,583 R$35,908 9.4 56.0 11.6 2
Trans vs LGB

The three-way comparison reveals whether the aggregate LGBTQ+ patterns are driven by one subgroup or represent a shared experience. Trans candidates may face distinct barriers — reflected in different election rates, revenue levels, and funding source composition — that are masked when the LGBTQ+ category is treated as monolithic.

7 Trans-Specific Intersectional Profile

Trans candidates constitute a small but highly visible subgroup. Given the small N, disaggregating trans candidates across multiple dimensions simultaneously yields very small cell sizes. Rather than producing unreliable cross-tabulations, we present a descriptive profile.

7.1 Trans Candidate Demographics

The tables below provide a descriptive profile of trans candidates across key intersectional dimensions: gender and race, education and region, party affiliation, and ideology. Given the small sample size, these should be read as a descriptive inventory rather than as evidence of statistical patterns.

Show code
trans_profile <- df %>%
  filter(trans_candidate) %>%
  mutate(
    gender_label = if_else(female, "Female", "Male"),
    race_label   = if_else(nonwhite, "Nonwhite", "White"),
    elected_label = if_else(elected, "Elected", "Not elected", missing = "Unknown")
  )

# Summary table by key intersections
trans_cross <- trans_profile %>%
  filter(!is.na(female), !is.na(nonwhite)) %>%
  group_by(gender_label, race_label) %>%
  summarise(
    N            = n(),
    `Mean Age`   = round(mean(age, na.rm = TRUE), 1),
    Elected      = sum(elected, na.rm = TRUE),
    `Mean Rev.`  = format_brl(mean(total_revenue, na.rm = TRUE)),
    .groups = "drop"
  ) %>%
  rename(Gender = gender_label, Race = race_label)

trans_cross %>%
  kable(align = c("l", "l", "r", "r", "r", "r"))
Table 4: Trans Candidates: Intersectional Profile
Gender Race N Mean Age Elected Mean Rev.
Female Nonwhite 306 38.6 13 R$20,635
Female White 167 38.7 8 R$70,311
Male Nonwhite 98 42.1 9 R$3,796
Male White 43 44.3 2 R$4,006
Show code
trans_edu_region <- trans_profile %>%
  filter(!is.na(education_simple), !is.na(region)) %>%
  count(education_simple, region) %>%
  pivot_wider(names_from = region, values_from = n, values_fill = 0) %>%
  rename(Education = education_simple)

trans_edu_region %>%
  kable(align = c("l", rep("r", ncol(trans_edu_region) - 1)))
Table 5: Trans Candidates by Education and Region
Education North Northeast Center-West Southeast South
Less than HS 11 19 4 21 10
High School 27 119 16 130 64
College+ 17 54 13 73 36
Show code
trans_profile %>%
  count(party_abbrev, sort = TRUE) %>%
  head(15) %>%
  left_join(
    trans_profile %>%
      group_by(party_abbrev) %>%
      summarise(
        elected = sum(elected, na.rm = TRUE),
        mean_rev = format_brl(mean(total_revenue, na.rm = TRUE)),
        .groups = "drop"
      ),
    by = "party_abbrev"
  ) %>%
  rename(
    Party     = party_abbrev,
    `N Trans` = n,
    Elected   = elected,
    `Mean Rev.` = mean_rev
  ) %>%
  kable(align = c("l", "r", "r", "r"))
Table 6: Trans Candidates by Party (Top Parties)
Party N Trans Elected Mean Rev.
PT 96 8 R$29,411
PSOL 59 4 R$58,589
PSD 53 4 R$8,593
PSB 52 3 R$7,801
MDB 51 3 R$4,708
PDT 43 2 R$209,841
PP 26 0 R$8,781
PSDB 26 1 R$5,111
SOLIDARIEDADE 25 1 R$10,670
REPUBLICANOS 22 0 R$1,909
PODE 21 2 R$9,443
UNIÃO 21 2 R$19,259
REDE 18 0 R$7,684
PL 14 0 R$18,901
PRD 11 0 R$2,958
Show code
trans_profile %>%
  filter(!is.na(ideology_category)) %>%
  count(ideology_category) %>%
  mutate(pct = format_pct(n / sum(n))) %>%
  rename(Ideology = ideology_category, N = n, `%` = pct) %>%
  kable(align = c("l", "r", "r"))
Table 7: Trans Candidates by Ideology Category
Ideology N %
Left 208 33.9%
Center 241 39.3%
Right 165 26.9%
Small-N Limitations

With 614 trans candidates total, intersectional breakdowns produce very small cell sizes. The numbers above should be read as a descriptive inventory, not as evidence of statistical patterns. Any future regression analysis involving trans candidates should consider pooling strategies or Bayesian approaches that handle sparse data appropriately.

7.2 Trans Candidate Enumeration

For maximum transparency with the small trans sample, we list the full distribution across key variable combinations.

Show code
trans_enum <- df %>%
  filter(trans_candidate) %>%
  filter(!is.na(female), !is.na(nonwhite), !is.na(education_simple), !is.na(region)) %>%
  count(
    Gender    = if_else(female, "Female", "Male"),
    Race      = if_else(nonwhite, "Nonwhite", "White"),
    Education = education_simple,
    Region    = region,
    name      = "N"
  ) %>%
  arrange(desc(N))

trans_enum %>%
  kable(align = c("l", "l", "l", "l", "r"))
Table 8: Trans Candidates: Full Cross-Tabulation of Key Variables
Gender Race Education Region N
Female Nonwhite High School Northeast 75
Female Nonwhite High School Southeast 66
Female White High School Southeast 38
Female Nonwhite College+ Southeast 29
Female White College+ Southeast 28
Female Nonwhite College+ Northeast 26
Female White High School South 23
Female Nonwhite High School South 22
Male Nonwhite High School Northeast 22
Female White High School Northeast 20
Female White College+ Northeast 18
Female Nonwhite High School North 17
Male Nonwhite High School Southeast 16
Female White College+ South 15
Female Nonwhite College+ South 13
Male Nonwhite High School South 13
Female Nonwhite Less than HS Northeast 12
Female Nonwhite Less than HS Southeast 11
Female Nonwhite High School Center-West 10
Male White High School Southeast 10
Male White College+ Southeast 10
Female Nonwhite College+ Center-West 8
Male Nonwhite College+ Northeast 8
Female Nonwhite College+ North 7
Male Nonwhite College+ North 7
Male Nonwhite High School North 6
Male Nonwhite College+ Southeast 6
Male White High School South 6
Female Nonwhite Less than HS North 5
Female White Less than HS Southeast 5
Female White High School Center-West 5
Male White College+ South 5
Female White Less than HS Northeast 4
Female White Less than HS South 4
Male Nonwhite College+ Center-West 4
Female Nonwhite Less than HS Center-West 3
Female White High School North 3
Male Nonwhite Less than HS North 3
Male Nonwhite Less than HS Northeast 3
Male Nonwhite Less than HS Southeast 3
Male Nonwhite College+ South 3
Female Nonwhite Less than HS South 2
Female White Less than HS North 2
Male Nonwhite Less than HS South 2
Male White Less than HS Southeast 2
Male White Less than HS South 2
Male White High School Northeast 2
Male White College+ North 2
Male White College+ Northeast 2
Female White College+ North 1
Female White College+ Center-West 1
Male Nonwhite Less than HS Center-West 1
Male Nonwhite High School Center-West 1
Male White Less than HS North 1
Male White High School North 1

8 Summary

This intersectional analysis examines the layered nature of political marginalization in Brazil’s 2024 municipal elections:

  1. Gender x LGBTQ+: The interaction between LGBTQ+ status and gender is examined through election rates and campaign revenue, revealing whether the LGBTQ+ gap differs for male and female candidates.

  2. Race x LGBTQ+: The interaction between LGBTQ+ status and race tests whether nonwhite LGBTQ+ candidates face compounding disadvantage, and whether the effects are additive or multiplicative.

  3. Ideology x LGBTQ+: The interaction plot reveals the degree to which the partisan environment moderates the relationship between LGBTQ+ status and electoral outcomes across left, center, and right parties.

  4. Triple intersection: The eight-cell analysis (LGBTQ+ x gender x race) identifies the most and least advantaged candidate profiles, providing a map of intersectional privilege and disadvantage.

  5. Trans specificity: Trans candidates, despite their small numbers, are an essential part of the story. Their intersectional profiles — distributed across specific parties, regions, and demographic groups — warrant dedicated attention in both descriptive and inferential work.

These patterns motivate the regression analysis in subsequent work, where we can test whether the observed intersectional gaps survive controls for municipality size, position type, incumbency, and campaign spending.