| Table 1. Wealth Growth of California Billionaires | |||||||
| Year | # | Wealth | Growth | % public | Top 4 wealth | CA AGI | Wealth / CA AGI |
|---|---|---|---|---|---|---|---|
| A. Recent nominal wealth growth of CA billionaires | |||||||
| 2022 | 175 | 843 | – | 46.8% | 209 | 1,898 | 44.4% |
| 2023 | 187 | 1,157 | 37.4% | 58.1% | 399 | 1,946 | 59.5% |
| 2024 | 197 | 1,570 | 35.6% | 64.5% | 625 | 2,117 | 74.2% |
| 2025 | 240 | 2,055 | 30.9% | 65.2% | 882 | 2,230 | 92.1% |
| 2026 (July 1st) | 250 | 2,307 | 24.5% | 60.0% | 948 | 2,299 | 100.3% |
| Growth during 2023-2025 | – | 143.9% | – | – | 321.9% | 17.5% | – |
| B. Long-term real wealth growth: top .001% wealthiest CA families (2026 $). Columns: # families in the top .001%, their wealth ($B), wealth per family ($B), # CA families (millions), CA AGI ($B), CA AGI per family ($) | |||||||
| 1982 | 118 | 48.9 | 0.41 | 11.8 | 614 | 51,900 | – |
| 2026 | 225 | 2,290.6 | 10.19 | 22.5 | 2,299 | 102,000 | – |
| Ratio 2026 to 1982 | 1.9 | 46.9 | 24.7 | 1.9 | 3.7 | 1.97 | – |
| Annualized growth | 1.5% | 9.1% | 7.6% | 1.5% | 3.0% | 1.5% | – |
| Notes: Panel A illustrates the wealth growth of CA billionaires from 2022 to 1 July 2026. Panel B compares the top .001% wealthiest CA families’ wealth in 1982 vs 2026, all in 2026 dollars. Source: Forbes RTB snapshots + SEC EDGAR. | |||||||
Prop 40, BSZ analysis: the reproduction AI generated, partially reviewed
Boll, Saez and Zucman (BSZ, NBER, August 2026) and the Galle, Gamage, Saez and Shanske (GGSS) expert report, reproduced step by step from the authors’ workbook (August 2026 revision, data appendix of 8 October 2026)
BSZ analysis of California Proposition 40: Overview · Open Output: Explorer · Open Analysis: Report (here) and Slides · Open Materials
Also in this project: Comparison overview · JR analysis overview
R or Python code for the setup
library(targets)
library(gt)
library(ggplot2)
# The report never recomputes or retypes a result: it reads the targets that
# the pipeline built (bsz-analysis/_targets.R). The R/ functions are
# sourced only to compare against the workbook's cached cells below.
options(keep.source = TRUE) # so the code listings below show each function as written
invisible(lapply(list.files("../R", pattern = "[.]R$", full.names = TRUE), source))
# The code a reader sees when unfolding a table or figure: the pipeline command that builds
# the target, the commands of the targets it reads, and the source of the function it calls.
# Pulled from _targets.R and R/ at render time, so it cannot drift; the output itself is
# still read from the store, never recomputed.
MAN <- withr::with_dir("..", targets::tar_manifest())
tcode <- function(name) {
one <- function(x) gsub("\\s*\n\\s*", " ", x)
cmd <- MAN$command[MAN$name == name]; ex <- str2lang(cmd)
fns <- Filter(function(f) exists(f, envir = globalenv()) && is.function(get(f, envir = globalenv())),
setdiff(all.names(ex), all.vars(ex)))
ups <- intersect(all.vars(ex), MAN$name)
c("# How the pipeline builds this result (bsz-analysis/_targets.R):",
paste0(name, " <- ", one(cmd)), "",
if (length(ups)) c("# reading these targets:",
paste0(ups, " <- ", vapply(ups, function(u) one(MAN$command[MAN$name == u]), "")), ""),
unlist(lapply(fns, function(f) { l <- deparse_function(f); l[1] <- paste0(f, " <- ", l[1]); c(l, "") })))
}
# The Python twin (py/, same function names): the source of a top-level def, read from disk.
pydef <- function(fn) {
for (f in list.files("../py", pattern = "[.]py$", full.names = TRUE)) {
l <- readLines(f, warn = FALSE); i <- grep(paste0("^def ", fn, "\\("), l)
if (length(i) != 1) next
nxt <- which(seq_along(l) > i & grepl("^[^[:space:]#]", l))
j <- if (length(nxt)) nxt[1] - 1 else length(l)
while (j > i && !nzchar(trimws(l[j]))) j <- j - 1
return(c(l[i:j], "", paste0("# Read-only display. Edit py/", basename(f), ":", i, "-", j, ".")))
}
NULL
}
# One fold, one line: "R or Python code for <exhibit>"; clicking a language opens it.
twin_fold <- function(exhibit, name) {
ex <- str2lang(MAN$command[MAN$name == name])
fns <- setdiff(all.names(ex), all.vars(ex))
ups <- intersect(all.vars(ex), MAN$name)
upfn <- unique(unlist(lapply(ups, function(u) { e <- str2lang(MAN$command[MAN$name == u]); setdiff(all.names(e), all.vars(e)) })))
py <- unlist(lapply(fns, pydef))
if (is.null(py)) { cat("\n```{.r .cell-code}\n", paste(tcode(name), collapse = "\n"), "\n```\n", sep = ""); return(invisible()) }
py <- c("# The Python twin (bsz-analysis/py/); py/run_export.py runs it.",
if (length(upfn)) paste0("# reads the output of: ", paste0(upfn, "()", collapse = ", ")), "", py)
twin_html(exhibit, tcode(name), py)
}
twin_html <- function(what, r, py) {
cat(sprintf('\n<details class="twin code-fold"><summary><a href="#" class="lang on" data-lang="r">R</a> or <a href="#" class="lang" data-lang="py">Python</a> code for %s</summary>\n\n', what),
"::: {.lang-r}\n```r\n", paste(r, collapse = "\n"), "\n```\n:::\n\n",
"::: {.lang-py}\n```python\n", paste(py, collapse = "\n"), "\n```\n:::\n\n</details>\n\n", sep = "")
}
STORE <- normalizePath("../_targets")
tr <- function(name) targets::tar_read_raw(name, store = STORE)
t5 <- tr("tab5_r")
inp <- tr("site_scoring_inputs")
grid <- tr("site_grid")
par <- tr("pareto_summary")
agg <- tr("data_sec_agg_r")
laf <- tr("fig8_laffer_r")
ins <- tr("site_inputs")
t5pr <- tr("site_tab5_printed") # each Table 5 cell beside the digit the paper prints
wt_printed <- t5pr$printed[t5pr$column == "wealth_tax_revenue" & t5pr$row == 1] # row 1, PDF p.39
stopifnot(length(wt_printed) == 1)
stopifnot(identical(inp$vintage, "august"))
bn <- function(x, d = 1) formatC(x, format = "f", digits = d, big.mark = ",")
usd <- function(x, d = 1) paste0("\\$", bn(x, d), " billion")
pv_r <- site_pv_rate; pv_H <- site_pv_years # the rate and the years behind pvf (R/site_exports.R)
pvf <- site_pv_factor(pv_r, pv_H) # 5 years at 3%; same definition as the deck and explorer
main1 <- t5$wealth_tax_revenue[1] + t5$extra_ca_inctax_sales[1] + t5$annual_ca_inctax_loss[1] * pvf
pct <- function(x, d = 1) paste0(formatC(100 * x, format = "f", digits = d), "%")
show <- function(df) knitr::kable(df, format = "html", escape = TRUE,
table.attr = 'class="table table-sm"')# The page is built with R, so the Python twin has no setup here: it runs on its own.
# From bsz-analysis/, with the August workbook in original-materials/ (see the README):
#
# pip install -r requirements.txt # numpy, openpyxl, pandas, pinned (Python 3.11)
# python py/run_export.py # about 2 s; writes export/py/
#
# export/py/ holds the same results the R setup reads from the targets store, and
# tests/testthat/test-py-parity.R checks every file against R's.
import pandas as pd
outputs = pd.read_csv("export/py/outputs.csv") # every exported number, beside the paper's printed valueIntroduction
California’s Proposition 40, the Billionaire Tax Act, would levy, once, a 5% tax on the whole net worth of people who lived in California on 1 January 2026 and are worth $1 billion or more, valued on 31 December 2026 and payable at once with the 2026 income tax or in five yearly installments, each later one with a deferral charge of 7.5% a year on the unpaid balance (§50301(c)). BSZ’s paper and the GGSS expert report carry the same number. Reproducing one reproduces the other.
- Boll, Saez and Zucman (BSZ), California Billionaires: Wealth, Taxes, and Wealth Tax Revenue Estimates, NBER (the National Bureau of Economic Research, which circulates working papers) Working Paper 35218, August 2026 revision, and its supplementary Excel workbook. Table 5, row 1 prints the tax at $104 billion ($103.8 billion in the workbook; the abstract says about $100 billion).
- Galle, Gamage, Saez and Shanske (GGSS), Expert Report on Proposition 40, updated 20 July 2026. It cites $100 billion, and says on p.4 where that comes from: “Factoring in 10% of tax avoidance and evasion leads to a scoring of $104 billion that we round to $100 billion for simplicity.” So the GGSS number is the BSZ row-1 computation, rounded. Reproducing one reproduces the other.
This report is part of an Open Policy Analysis of the BSZ analysis:
- Open Output: the explorer, where the assumptions move and the revenue follows
- Open Analysis: this document, and the slide deck
- Open Materials: the repository, every script, test and data file (guide: the materials page)
The main estimate is the base scenario (Table 5, row 1) net of its income tax effects: $103.8 billion of one-time wealth tax, plus $3.68 billion of extra state income tax from asset sales, minus $0.70 billion of income tax lost to leavers ($0.15 billion a year, over 5 years at 3%): $106.8 billion. The arithmetic is in The main estimate. Section IV.A builds Table 5 step by step. Sections II and III reproduce the paper’s other tables first. The code reproduces the paper’s published results. All 28 cells of Table 5 match the workbook, and 26 round to the digit the paper prints. The paper prints the other 2 one unit lower in the last digit (see Reproduction notes).
Not the authors’ choice. BSZ’s own headline is the wealth tax alone: about $100 billion in the abstract and in GGSS, $104 billion in Table 5 (p.39), reproduced here as $103.8 billion. This OPA uses the total because it compares more directly with the JR analysis’s net present value; the comparison overview puts both on one definition. Rows 2 to 4 of Table 5 are the authors’ own sensitivity checks. This report reproduces them, and the explorer shows them next to the base scenario.
The project’s pre-specified output (PLAN.md, “Pre-specified output”), fixed before any result was seen, named the wealth tax. The switch to the total was made on 7 October 2026 for comparability, and the wealth tax is still reported throughout.
What “reproduced” means here. The pipeline re-derives every formula cell of the authors’ workbook in R and checks each against the workbook’s own cached value. It does not re-collect the raw data: the Forbes wealth figures, the tax statistics and the SEC filings enter as the workbook records them. So the reproduction shows that the arithmetic from those inputs to the published tables is what the paper says it is. It does not show that the inputs are right.
How to read this report
Text in blockquotes is quoted from the paper. Everything else is this OPA’s text. Page numbers are PDF pages of the August 2026 working paper; the printed folio runs two lower in the main text. Every number in running text is computed when this page is rendered, from the pipeline’s stored results (open any “code” fold to see which one). Tables and figures are the pipeline’s own gt and ggplot2 objects (the R packages that draw the tables and the figures). Where the pipeline numbers a figure differently from the paper, the text says so.
Notation, in one place
| Symbol | Meaning | Value in row 1 |
|---|---|---|
| \(\tau\) | wealth tax rate (one time) | 5% |
| \(W_0\) | wealth of California billionaires on 1 July 2026 | $2,307 billion |
| \(n_0\) | number of those billionaires | 250 |
| \(\alpha\) | share of wealth lost to avoidance and evasion | 10% |
| \(m\) | share of that avoidance that is people leaving the state | 50% |
| \(s\) | share of the tax paid by selling assets | 1/3 |
| \(g\) | share of a sale’s price that is a taxable gain | 80% |
| \(t_{cg}\) | top California tax rate on gains | 13.3% |
| \(C\) | California income tax billionaires pay per year, 2019-2025 average | $3.06 billion |
Two letters are reused locally, following the paper: in Section II, \(g_t\) is the yearly growth of billionaire wealth (not the gain share \(g\) above), and in the Laffer-curve section \(e\) is a semi-elasticity.
II. The wealth of California billionaires
A. Short-run growth: Table 1 Panel A and Figure 1
Table 1 counts California’s billionaires and their Forbes wealth from 2022 to 1 July 2026. Figure 1 plots that wealth from 2019.
Table 1 Panel A provides basic statistics on California billionaires in recent years. (BSZ PDF p.5)
What is computed. For each year, total billionaire wealth is the sum of the Forbes wealth of every California resident on the list, \[W_t = \sum_{i \in \text{CA}_t} w_{i,t},\] with Larry Ellison excluded throughout, because he left California before 2026. Growth is \(g_t = W_t / W_{t-1} - 1\), and the “2023-2025” row is the cumulative \(W_{2025}/W_{2022} - 1\). In plain words: add up the list, then compare years. The top-4 column adds Page, Brin, Zuckerberg and Huang from the workbook’s SEC-based panel. The table ends with a “1 July 2026” row, and its denominator is California’s total adjusted gross income (AGI), the income reported on tax returns.
The 2025 count is 240 billionaires holding $2,055 billion, which is the paper’s own figure (“240 billionaires with total wealth of $2055 billion”, Appendix A), and the workbook’s own aggregate gives the same.
R or Python code for Table 1
# How the pipeline builds this result (bsz-analysis/_targets.R):
tab1_gt <- build_tab1(data_sec_agg_r, shortrunseries_r, longrunseries, shortrunseries)
# reading these targets:
data_sec_agg_r <- read_data_sec_agg(workbook_db)
shortrunseries_r <- compute_shortrunseries(data_sec_agg_r, data_sec_top4, billionaires_ca_inctax_r, shortrunseries)
longrunseries <- extract_longrunseries(xlsx_path)
shortrunseries <- extract_shortrunseries(xlsx_path)
build_tab1 <- function(data_sec_agg_r, shortrunseries_r, longrunseries,
shortrunseries, vintage = bsz_vintage()) {
# Table 1: Wealth Growth of California Billionaires.
# Panel A: 2022-2025(+2026 in August) nominal wealth + growth + CA GDP/AGI.
# Panel B: 1982 vs 2025(2026 in August) long-term real-wealth growth of the
# top .0002% (May) / .001% (August) wealthiest CA families.
#
# RC4 (class b): August redefined the Panel A/B denominator from CA GDP to
# CA total AGI (Tab1!G5 header + formula both changed; new source sheet
# `2023-b-1__adjusted_gross_income`). RC5: August inserted a "2026 (July
# 1st)" row into Panel A (and pushed the growth row down one). RC6 (class
# b): Panel B's percentile widened .0002% -> .001% and its "current year"
# endpoint moved 2025 -> 2026. All ported directly from both workbooks'
# cell formulas (openpyxl, data_only=False) - see VERIFY-AUGUST.md.
# ---- Panel A inputs --------------------------------------------------------
agg <- data_sec_agg_r[data_sec_agg_r$year %in% 2022:2025, ]
srs <- shortrunseries_r$panel
srs_a <- srs[srs$year %in% 2022:2025, c("year", "top5_total_b")]
denom_col <- if (identical(vintage, "may")) "AT" else "AY" # GDP (May) / AGI (August)
denom_1 <- suppressWarnings(as.numeric(longrunseries[[denom_col]][48:51]))
panel_a <- tibble::tibble(
year = as.character(2022:2025),
n_billionaires = agg$n,
wealth_b = agg$forbes_worth,
annual_growth = c(NA_real_, agg$forbes_worth[-1] / agg$forbes_worth[-4] - 1),
fraction_public = agg$forbes_public_worth / agg$forbes_worth,
top4_wealth_b = srs_a$top5_total_b,
ca_gdp_b = denom_1,
wealth_per_gdp = agg$forbes_worth / denom_1
)
if (!identical(vintage, "may")) {
# August's new "2026 (July 1st)" row (Tab1 row 10): n and the 60%
# public-wealth fraction are hand-entered literals; wealth and top-4
# wealth are literal reads from shortrunseries (row 14, the 2026 row -
# outside compute_shortrunseries()'s 2018-2025 panel, so read directly
# from the raw sheet); D10 doubles the (partial-year) growth rate; the
# denominator is longrunseries!AY52.
cell_2026 <- function(col) xls_cell(shortrunseries, paste0(col, 14))
wealth_2026 <- cell_2026("C")
top4_2026 <- cell_2026(srs_col("top5_total"))
denom_2026 <- suppressWarnings(as.numeric(longrunseries[[denom_col]][52]))
row_2026 <- tibble::tibble(
year = "2026 (July 1st)",
n_billionaires = 250L,
wealth_b = wealth_2026,
annual_growth = 2 * (wealth_2026 / panel_a$wealth_b[4] - 1),
fraction_public = 0.6,
top4_wealth_b = top4_2026,
ca_gdp_b = denom_2026,
wealth_per_gdp = wealth_2026 / denom_2026
)
panel_a <- dplyr::bind_rows(panel_a, row_2026)
}
# Append "Growth during 2023-2025" row (Tab1 row 10 May / 11 August) - this
# stays anchored to the 2022 -> 2025 window in BOTH vintages (indices 1 and
# 4 of `panel_a`, which are always 2022 and 2025 regardless of whether the
# 2026 row above was appended after them).
growth_row <- tibble::tibble(
year = "Growth during 2023-2025",
n_billionaires = NA_integer_,
wealth_b = panel_a$wealth_b[4] / panel_a$wealth_b[1] - 1,
annual_growth = NA_real_,
fraction_public = NA_real_,
top4_wealth_b = panel_a$top4_wealth_b[4] / panel_a$top4_wealth_b[1] - 1,
ca_gdp_b = panel_a$ca_gdp_b[4] / panel_a$ca_gdp_b[1] - 1,
wealth_per_gdp = NA_real_
)
panel_a_full <- dplyr::bind_rows(panel_a, growth_row)
panel_a_full$panel <- "A. Recent nominal wealth growth of CA billionaires"
# ---- Panel B inputs --------------------------------------------------------
lrs <- longrunseries
num <- function(col, row) suppressWarnings(as.numeric(lrs[[col]][row]))
W8 <- num("W", 8)
cur_row <- lrs_current_row(vintage) # 51 (May, 2025) / 52 (August, 2026)
W_cur <- num("W", cur_row)
defl_1982 <- W8 / W_cur
if (identical(vintage, "may")) {
AL_1982 <- num("AL", 8); AL_cur <- num("AL", cur_row)
wealth_1982 <- num("AQ", 8) * defl_1982
wealth_cur <- num("AQ", cur_row) # already 2025$
gdp_1982 <- num("AT", 8) * defl_1982
gdp_cur <- num("AT", cur_row) # already 2025$
} else {
# Families: top .001% = 5x the top .0002% count (longrunseries!AL, same
# column both vintages). Wealth: longrunseries!BT is ALREADY real (2026
# $), no deflation needed. AGI: longrunseries!BA (nominal, "CA total AGI
# consistent KG") deflated the same way GDP used to be.
AL_1982 <- num("AL", 8) * 5; AL_cur <- num("AL", cur_row) * 5
wealth_1982 <- num("BT", 8)
wealth_cur <- num("BT", cur_row)
gdp_1982 <- num("BA", 8) * defl_1982
gdp_cur <- num("BA", cur_row) * (W_cur / W_cur) # = num("BA", cur_row)
}
AI_1982 <- num("AI", 8); AI_cur <- num("AI", cur_row)
make_year_row <- function(label, AL, wealth_b, AI, gdp_b, round_gdp_per_family) {
n_fam_m <- AI / 1000
gdp_per_family <- 1000 * gdp_b / n_fam_m
if (round_gdp_per_family) gdp_per_family <- round(gdp_per_family, -2)
tibble::tibble(
year = label,
families_top0002_k = AL,
wealth_top0002_b = wealth_b,
wealth_per_family_b = wealth_b / AL,
n_ca_families_m = n_fam_m,
ca_gdp_2025dollars_b = gdp_b,
gdp_per_family_k = gdp_per_family
)
}
cur_label <- if (identical(vintage, "may")) "2025" else "2026"
n_years <- if (identical(vintage, "may")) 43 else 44
round_gpf <- !identical(vintage, "may") # August's H column uses ROUND(.,-2)
row_1982 <- make_year_row("1982", AL_1982, wealth_1982, AI_1982, gdp_1982, round_gpf)
row_cur <- make_year_row(cur_label, AL_cur, wealth_cur, AI_cur, gdp_cur, round_gpf)
lr <- .long_run_compare(row_1982, row_cur, n_years = n_years)
panel_b <- dplyr::bind_rows(row_1982, row_cur, lr$ratio, lr$annualized)
# ---- Render with gt (display only) ---------------------------------------
# Two stacked panels rendered as one gt; row groups give the panel headers.
# Nothing below changes a value: the panel_a / panel_b attributes are what the
# tests and snapshots read. A gt has one header row, so the column headers
# name Panel A's columns and Panel B's row-group label lists its own; each
# panel's rows get their own formats, as the paper prints Table 1 (PDF p.35).
denom_lbl <- if (identical(vintage, "may")) "GDP" else "AGI" # the column holds AGI from August on
ratio_label <- paste0("Ratio ", cur_label, " to 1982")
sec_a <- "A. Recent nominal wealth growth of CA billionaires"
panel_a_render <- tibble::tibble(
section = sec_a,
year = panel_a_full$year,
col1 = panel_a_full$n_billionaires,
col2 = panel_a_full$wealth_b,
col3 = panel_a_full$annual_growth,
col4 = panel_a_full$fraction_public,
col5 = panel_a_full$top4_wealth_b,
col6 = panel_a_full$ca_gdp_b,
col7 = panel_a_full$wealth_per_gdp
)
pctile_label <- if (identical(vintage, "may")) "top .0002%" else "top .001%"
sec_b <- paste0("B. Long-term real wealth growth: ", pctile_label,
" wealthiest CA families (", cur_label, " $). Columns: # families in the ",
pctile_label, ", their wealth ($B), wealth per family ($B), ",
"# CA families (millions), CA ", denom_lbl, " ($B), CA ", denom_lbl,
" per family ($)")
panel_b_render <- tibble::tibble(
section = sec_b,
year = ifelse(grepl("^Ratio ", panel_b$year), ratio_label, panel_b$year),
col1 = panel_b$families_top0002_k,
col2 = panel_b$wealth_top0002_b,
col3 = panel_b$wealth_per_family_b,
col4 = panel_b$n_ca_families_m,
col5 = panel_b$ca_gdp_2025dollars_b,
col6 = panel_b$gdp_per_family_k,
col7 = NA_real_
)
combined <- dplyr::bind_rows(panel_a_render, panel_b_render)
in_a <- combined$section == sec_a
r_a <- which(in_a & !grepl("^Growth", combined$year)) # Panel A's year rows
r_agr <- which(in_a & grepl("^Growth", combined$year)) # its 2023-2025 growth row
r_blev <- which(!in_a & combined$year %in% c("1982", cur_label))
r_brat <- which(!in_a & combined$year == ratio_label)
r_bann <- which(!in_a & combined$year == "Annualized growth")
a_years <- if (identical(vintage, "may")) "2022 to 2025" else "2022 to 1 July 2026"
tab <- gt::gt(combined, groupname_col = "section") |>
gt::tab_header(title = "Table 1. Wealth Growth of California Billionaires") |>
gt::cols_label(
year = "Year",
col1 = "#",
col2 = "Wealth",
col3 = "Growth",
col4 = "% public",
col5 = "Top 4 wealth",
col6 = paste("CA", denom_lbl),
col7 = paste("Wealth / CA", denom_lbl)
) |>
# Panel A: counts and $B as whole numbers, rates as percentages; the growth
# row is a percentage in each column it fills.
gt::fmt_number(columns = c(col1, col2, col5, col6), rows = r_a, decimals = 0, use_seps = TRUE) |>
gt::fmt_percent(columns = c(col3, col4, col7), rows = r_a, decimals = 1) |>
gt::fmt_percent(columns = c(col2, col5, col6), rows = r_agr, decimals = 1) |>
# Panel B: families, wealth ($B), wealth per family ($B), families (millions),
# income ($B), income per family ($); then the ratio and annualized rows.
gt::fmt_number(columns = c(col1, col5, col6), rows = r_blev, decimals = 0, use_seps = TRUE) |>
gt::fmt_number(columns = c(col2, col4), rows = r_blev, decimals = 1, use_seps = TRUE) |>
gt::fmt_number(columns = col3, rows = r_blev, decimals = 2) |>
gt::fmt_number(columns = c(col1, col2, col3, col4, col5), rows = r_brat, decimals = 1) |>
gt::fmt_number(columns = col6, rows = r_brat, decimals = 2) |>
gt::fmt_percent(columns = c(col1, col2, col3, col4, col5, col6), rows = r_bann, decimals = 1) |>
gt::sub_missing(missing_text = "–") |>
gt::tab_source_note(source_note = gt::md(paste0(
"**Notes:** Panel A illustrates the wealth growth of CA billionaires from ", a_years, ". ",
"Panel B compares the ", pctile_label, " wealthiest CA families' wealth in 1982 vs ",
cur_label, ", all in ", cur_label, " dollars. Source: Forbes RTB snapshots + SEC EDGAR."
)))
# Attach a tidy version for downstream use / tests.
attr(tab, "panel_a") <- panel_a_full
attr(tab, "panel_b") <- panel_b
tab
}
# Read-only display. Edit R/tables.R:513-718.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: read_data_sec_agg(), compute_shortrunseries(), extract_longrunseries(), extract_shortrunseries()
def build_tab1(data_sec_agg_r, shortrunseries_r, longrunseries, shortrunseries, vintage=None):
vintage = vintage or bsz_vintage()
may = vintage == "may"
agg = data_sec_agg_r[data_sec_agg_r["year"].isin(range(2022, 2026))]
srs = shortrunseries_r["panel"]
srs_a = srs[srs["year"].isin(range(2022, 2026))]
denom_col = "AT" if may else "AY" # CA GDP (May) / CA AGI (August)
denom_1 = col_num(longrunseries, denom_col, range(48, 52))
w = agg["forbes_worth"].to_numpy(float)
panel_a = pd.DataFrame({
"year": [str(y) for y in range(2022, 2026)],
"n_billionaires": agg["n"].to_numpy(),
"wealth_b": w,
"annual_growth": np.concatenate([[NA], w[1:] / w[:-1] - 1]),
"fraction_public": agg["forbes_public_worth"].to_numpy(float) / w,
"top4_wealth_b": srs_a["top5_total_b"].to_numpy(float),
"ca_gdp_b": denom_1,
"wealth_per_gdp": w / denom_1,
})
if not may:
# August's "2026 (July 1st)" row: literals and raw shortrunseries row 14.
wealth_2026 = xls_cell(shortrunseries, "C14")
top4_2026 = xls_cell(shortrunseries, f"{srs_col('top5_total', vintage)}14")
denom_2026 = xls_cell(longrunseries, f"{denom_col}52")
row_2026 = {
"year": "2026 (July 1st)", "n_billionaires": 250, "wealth_b": wealth_2026,
"annual_growth": 2 * (wealth_2026 / panel_a["wealth_b"].iloc[3] - 1),
"fraction_public": 0.6, "top4_wealth_b": top4_2026,
"ca_gdp_b": denom_2026, "wealth_per_gdp": wealth_2026 / denom_2026,
}
panel_a = pd.concat([panel_a, pd.DataFrame([row_2026])], ignore_index=True)
growth_row = {
"year": "Growth during 2023-2025", "n_billionaires": NA,
"wealth_b": panel_a["wealth_b"].iloc[3] / panel_a["wealth_b"].iloc[0] - 1,
"annual_growth": NA, "fraction_public": NA,
"top4_wealth_b": panel_a["top4_wealth_b"].iloc[3] / panel_a["top4_wealth_b"].iloc[0] - 1,
"ca_gdp_b": panel_a["ca_gdp_b"].iloc[3] / panel_a["ca_gdp_b"].iloc[0] - 1,
"wealth_per_gdp": NA,
}
panel_a_full = pd.concat([panel_a, pd.DataFrame([growth_row])], ignore_index=True)
panel_a_full["panel"] = "A. Recent nominal wealth growth of CA billionaires"
lrs = longrunseries
def num(col, row):
return xls_cell(lrs, f"{col}{row}")
W8 = num("W", 8)
cur = lrs_current_row(vintage)
W_cur = num("W", cur)
defl_1982 = W8 / W_cur
if may:
AL_1982, AL_cur = num("AL", 8), num("AL", cur)
wealth_1982 = num("AQ", 8) * defl_1982
wealth_cur = num("AQ", cur)
gdp_1982 = num("AT", 8) * defl_1982
gdp_cur = num("AT", cur)
else:
AL_1982, AL_cur = num("AL", 8) * 5, num("AL", cur) * 5
wealth_1982 = num("BT", 8)
wealth_cur = num("BT", cur)
gdp_1982 = num("BA", 8) * defl_1982
gdp_cur = num("BA", cur) * (W_cur / W_cur)
AI_1982, AI_cur = num("AI", 8), num("AI", cur)
def make_year_row(label, AL, wealth_b, AI, gdp_b, round_gpf):
n_fam_m = AI / 1000
gpf = 1000 * gdp_b / n_fam_m
if round_gpf:
gpf = round(gpf, -2) # R's round(x, -2); both round half to even
return {"year": label, "families_top0002_k": AL, "wealth_top0002_b": wealth_b,
"wealth_per_family_b": wealth_b / AL, "n_ca_families_m": n_fam_m,
"ca_gdp_2025dollars_b": gdp_b, "gdp_per_family_k": gpf}
cur_label = "2025" if may else "2026"
n_years = 43 if may else 44
round_gpf = not may
r82 = make_year_row("1982", AL_1982, wealth_1982, AI_1982, gdp_1982, round_gpf)
rcur = make_year_row(cur_label, AL_cur, wealth_cur, AI_cur, gdp_cur, round_gpf)
ratio, ann = _long_run_compare(r82, rcur, n_years)
panel_b = pd.DataFrame([r82, rcur, ratio, ann])
return {"panel_a": panel_a_full, "panel_b": panel_b}
# Read-only display. Edit py/tables.py:248-331.The values in this table match the workbook cell for cell. The table has one header row, so the headers name Panel A’s columns and Panel B lists its own columns in its heading. Appendix Table A1 is laid out the same way. Until 15 October 2026 the display showed Panel B under Panel A’s headers and formats (see What reproduces).
Figure 1 plots the trajectory of California’s billionaires’ total wealth (in nominal current dollars) at the end of years 2019 to 2025 and for July 1st, 2026. (BSZ PDF p.5)
R or Python code for Figure 1
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig1 <- build_fig1(shortrunseries_r)
# reading these targets:
shortrunseries_r <- compute_shortrunseries(data_sec_agg_r, data_sec_top4, billionaires_ca_inctax_r, shortrunseries)
build_fig1 <- function(shortrunseries_r) {
# Figure 1: The Rise of California Billionaires Wealth in Recent Years.
# Two solid lines (2019-2025): total CA billionaire wealth (excl. Ellison) +
# top-4 wealth. Two dashed segments 2024->2025 showing the 5% wealth-tax
# counterfactual (95% of 2025 actual).
panel <- shortrunseries_r$panel
yrs <- 2019:2025
d <- panel[panel$year %in% yrs, ]
series <- tibble::tibble(
year = rep(d$year, 2),
wealth = c(d$wealth_us_citizens_b, d$top5_total_b),
series = factor(rep(c("All CA billionaires", "Top 4 (Page, Brin, Zuck, Huang)"),
each = nrow(d)),
levels = c("All CA billionaires",
"Top 4 (Page, Brin, Zuck, Huang)"))
)
# Dashed-line segments: from 2024 actual to 2025-after-5%-tax.
d24 <- d[d$year == 2024, ]
d25 <- d[d$year == 2025, ]
dashed <- tibble::tibble(
x = c(2024, 2024),
xend = c(2025, 2025),
y = c(d24$wealth_us_citizens_b, d24$top5_total_b),
yend = c(d25$wealth_w_avoid_b, d25$top5_w_avoid_b),
series = factor(c("All CA billionaires",
"Top 4 (Page, Brin, Zuck, Huang)"),
levels = levels(series$series))
)
ggplot2::ggplot(series, ggplot2::aes(x = year, y = wealth,
color = series, shape = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2.5) +
ggplot2::geom_segment(
data = dashed,
ggplot2::aes(x = x, xend = xend, y = y, yend = yend, color = series),
linetype = "dashed", inherit.aes = FALSE
) +
ggplot2::scale_x_continuous(breaks = yrs) +
ggplot2::scale_y_continuous(
labels = function(v) format(v, big.mark = ",", scientific = FALSE),
limits = c(0, NA)
) +
ggplot2::scale_color_manual(values = c("All CA billionaires" = "#d62728",
"Top 4 (Page, Brin, Zuck, Huang)" = "#1f77b4")) +
ggplot2::labs(
title = "Figure 1: The Rise of California Billionaires' Wealth",
subtitle = "Wealth of CA billionaires ($B, end of year). Dashed: 5% wealth-tax counterfactual.",
x = NULL,
y = "Wealth ($B)",
color = NULL,
shape = NULL
) +
ggplot2::theme_minimal(base_size = 12) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor.x = ggplot2::element_blank()
)
}
# Read-only display. Edit R/figures.R:663-725.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_shortrunseries()
def build_fig1(shortrunseries_r):
p = shortrunseries_r["panel"]
d = p[p["year"].isin(range(2019, 2026))]
return _long(d["year"], "year", [d["wealth_us_citizens_b"], d["top5_total_b"]], "wealth",
["All CA billionaires", "Top 4 (Page, Brin, Zuck, Huang)"], "series")
# Read-only display. Edit py/figures.py:185-189.The paper’s Figure 1 has a Panel B, wealth by industry. The pipeline builds that chart under the target fig_a1 (its historical name; the paper’s Appendix Figure A1, wealth relative to AGI, is not built, see “What is not reproduced” under What reproduces):
R or Python code for Figure 1, Panel B
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig_a1 <- build_fig_a1(xlsx_path)
# reading these targets:
xlsx_path <- xlsx_path_default()
build_fig_a1 <- function(xlsx_path) {
# Figure 1, Panel B: California Billionaires Wealth by Industry (the target keeps its
# historical name fig_a1; the paper's Appendix Figure A1, wealth relative to AGI, is not built).
# Stacked horizontal bar, 13 industries × 3 components: Public stock held
# by Top 4 (col I), other public stock (col G), private stock (col H).
# Block 2 of rtb_2026_industry: rows 22-34, cols A and G-I.
raw <- read_sheet("rtb_2026_industry", path = xlsx_path,
range = cellranger::cell_limits(ul = c(22, 1), lr = c(34, 9)))
names(raw) <- c("industry", paste0("c", 2:ncol(raw)))
d <- raw
d$top4_public <- suppressWarnings(as.numeric(d$c9)) # col I
d$other_public <- suppressWarnings(as.numeric(d$c7)) # col G
d$private <- suppressWarnings(as.numeric(d$c8)) # col H
d$total <- d$top4_public + d$other_public + d$private
d <- d[!is.na(d$total) & d$total > 0, ]
d <- d[order(d$total, decreasing = TRUE), ]
d$industry <- factor(d$industry, levels = rev(d$industry))
long <- tibble::tibble(
industry = rep(d$industry, 3),
share = c(d$top4_public, d$other_public, d$private),
component = factor(rep(c("Public stock (Top 4)",
"Public stock (other)",
"Private stock"), each = nrow(d)),
levels = c("Public stock (Top 4)",
"Public stock (other)",
"Private stock"))
)
ggplot2::ggplot(long, ggplot2::aes(y = industry, x = share, fill = component)) +
ggplot2::geom_col() +
ggplot2::scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
ggplot2::scale_fill_manual(values = c(
"Public stock (Top 4)" = "#d62728",
"Public stock (other)" = "#1f77b4",
"Private stock" = "#999999"
)) +
ggplot2::labs(
title = "Figure 1, Panel B: CA Billionaire Wealth by Industry",
subtitle = "% of total CA billionaire wealth, end of 2025 (Forbes 2026/01/01)",
x = NULL, y = NULL, fill = NULL
) +
ggplot2::theme_minimal(base_size = 12) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.major.y = ggplot2::element_blank()
)
}
# Read-only display. Edit R/figures.R:167-215.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: xlsx_path_default()
def build_fig_a1(xlsx_path=None):
# Block 2 of rtb_2026_industry: rows 22-34, columns A and G-I.
raw = read_sheet("rtb_2026_industry", path=xlsx_path, range=(22, 1, 34, 9))
raw.columns = ["industry"] + [f"c{i}" for i in range(2, raw.shape[1] + 1)]
d = raw.copy()
d["top4_public"] = to_num_vec(d["c9"]) # col I
d["other_public"] = to_num_vec(d["c7"]) # col G
d["private"] = to_num_vec(d["c8"]) # col H
d["total"] = d["top4_public"] + d["other_public"] + d["private"]
d = d[d["total"].notna() & (d["total"] > 0)]
# R: d[order(d$total, decreasing = TRUE), ] (stable for ties)
d = d.sort_values("total", ascending=False, kind="stable")
n = len(d)
return pd.DataFrame({
"industry": list(d["industry"]) * 3,
"share": np.concatenate([d["top4_public"], d["other_public"], d["private"]]),
"component": _rep_each(["Public stock (Top 4)", "Public stock (other)", "Private stock"], n),
})
# Read-only display. Edit py/figures.py:59-76.B. The long run: Table 1 Panel B and Figure 2
Table 1 Panel B and Figure 2 follow the wealth of California’s top .001% of families from 1982 to 2026. It grows much faster than average income.
Figure 2 depicts the longer time series. Panel A depicts the trajectory of top .001% wealth. (BSZ PDF p.8)
What is computed. Over forty years the number of billionaires grows with the population, so the paper follows a fixed share of families instead: the top .001%. Wealth is in real 2026 dollars. The comparison is with average income per family. The “annualized growth” row is the constant yearly rate that turns the 1982 value into the 2026 value: \[\bar g = \left(\frac{X_{2026}}{X_{1982}}\right)^{1/44} - 1.\]
R or Python code for Figure 2
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig2 <- build_fig2(longrunseries)
# reading these targets:
longrunseries <- extract_longrunseries(xlsx_path)
build_fig2 <- function(longrunseries, vintage = bsz_vintage()) {
# Figure 2: Billionaire Class Wealth Grows Much Faster than the Economy.
# Two panels stacked horizontally via patchwork.
# A: CA top .0002% wealth vs CA GDP per family: dual y-axis line chart.
# B: Top .0002% wealth as % of GDP for US and CA: two lines.
# Year range 1982-2025 = longrunseries rows 8..51 (both vintages; August
# added a 2026 row past this range that fig2 does not extend into).
#
# Column letters for these 4 series moved in August (longrunseries grew
# 80 -> 96 columns): AZ -> BJ (CA top .0002% real average wealth, values
# legitimately updated between the May/August Forbes snapshots, not just
# relabeled); BE -> BP (CA GDP per family, $100Ks); AV -> BF (US top 400
# wealth/GDP - value UNCHANGED, byte-identical between vintages); AW -> BG
# (CA top 45 wealth/GDP - also unchanged). Verified cell-by-cell against
# both workbooks (openpyxl) - see VERIFY-AUGUST.md.
lrs <- longrunseries
rows <- 8:51
num <- function(col) suppressWarnings(as.numeric(lrs[[col]][rows]))
year <- num("A")
if (identical(vintage, "may")) {
wealth_b <- num("AZ") # CA top .0002% real wealth, $B
gdp_per_fam <- num("BE") # CA GDP per family, $100Ks
us_share <- num("AV") # US top 400 wealth / GDP
ca_share <- num("AW") # CA top 45 wealth / GDP
} else {
wealth_b <- num("BJ")
gdp_per_fam <- num("BP")
us_share <- num("BF")
ca_share <- num("BG")
}
# Panel A: single y-axis, since both series naturally fit in 0..30 (wealth in $B,
# GDP per family in $100Ks). Wealth is the headline (red, with points);
# GDP per family is the baseline (black, also with points).
n_a <- length(year)
panel_a_data <- tibble::tibble(
year = rep(year, 2),
value = c(wealth_b, gdp_per_fam),
series = factor(rep(c("CA top .0002% wealth ($B, 2025 $)",
"CA GDP per family ($100K, 2025 $)"),
each = n_a),
levels = c("CA top .0002% wealth ($B, 2025 $)",
"CA GDP per family ($100K, 2025 $)"))
)
panel_a <- ggplot2::ggplot(panel_a_data,
ggplot2::aes(x = year, y = value, color = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 1.5) +
ggplot2::scale_color_manual(values = c(
"CA top .0002% wealth ($B, 2025 $)" = "#a91d22",
"CA GDP per family ($100K, 2025 $)" = "#222222"
)) +
ggplot2::scale_y_continuous(limits = c(0, NA),
breaks = seq(0, 30, by = 5)) +
ggplot2::labs(
title = "A. CA Top .0002% wealth vs. GDP per family",
x = NULL, y = NULL, color = NULL
) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank()
)
n_yrs <- length(year)
panel_b_data <- tibble::tibble(
year = rep(year, 2),
share = c(us_share, ca_share),
region = factor(rep(c("US (top 400)", "California (top 45)"), each = n_yrs),
levels = c("US (top 400)", "California (top 45)"))
)
panel_b <- ggplot2::ggplot(panel_b_data,
ggplot2::aes(x = year, y = share, color = region)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
ggplot2::scale_color_manual(values = c("US (top 400)" = "#d62728",
"California (top 45)" = "#1f77b4")) +
ggplot2::labs(
title = "B. Top .0002% wealth (% of annual GDP)",
x = NULL, y = NULL, color = NULL
) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank()
)
patchwork::wrap_plots(panel_a, panel_b, ncol = 2) +
patchwork::plot_annotation(
title = "Figure 2: Billionaire Wealth Grows Much Faster than the Economy"
)
}
# Read-only display. Edit R/figures.R:568-661.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: extract_longrunseries()
def build_fig2(longrunseries, vintage=None):
vintage = vintage or bsz_vintage()
rows = range(8, 52) # 1982-2025 in both vintages
def num(col):
return col_num(longrunseries, col, rows)
year = num("A")
if vintage == "may":
wealth_b, gdp_per_fam, us_share, ca_share = num("AZ"), num("BE"), num("AV"), num("AW")
else:
wealth_b, gdp_per_fam, us_share, ca_share = num("BJ"), num("BP"), num("BF"), num("BG")
a = _long(year, "year", [wealth_b, gdp_per_fam], "value",
["CA top .0002% wealth ($B, 2025 $)", "CA GDP per family ($100K, 2025 $)"], "series")
b = _long(year, "year", [us_share, ca_share], "share",
["US (top 400)", "California (top 45)"], "region")
return [a, b]
# Read-only display. Edit py/figures.py:166-182.III. Taxes paid by California billionaires
A. California income tax: Table 2, Figure 3 and Table 3
Table 2 estimates the California income tax that all billionaires pay each year, $3.06 billion on average from 2019 to 2025. Figure 3 plots it. Table 3 computes the same tax for the top 4 from their SEC filings.
For all California billionaires, we estimate the California income tax they pay using Franchise Tax Board (the California tax administration) tabulated statistics, a Pareto extrapolation, and an adjustment based on Federal tax data. (BSZ PDF p.10)
Why an extrapolation. Tax statistics are published by income bracket, not by name, and the top bracket holds thousands of filers, not the 240 billionaires. The paper assumes the top of the income distribution follows a Pareto law: above any cutoff \(t\), average income is a constant multiple \(b\) of \(t\). From the top bracket’s filer count \(n_t\) and total income \(A_t\), \[b = \frac{A_t / n_t}{t}, \qquad \hat t_N = t \left(\frac{n_t}{N}\right)^{1 - 1/b},\] where \(\hat t_N\) is the cutoff above which exactly \(N\) filers sit (\(N\) = the number of billionaires). Their average income is \(b\,\hat t_N\), and applying the bracket’s average tax rate gives the tax the top \(N\) earners pay.
From top earners to top wealth holders. Billionaires are not the top earners: the paper cites Balkir et al. (2025) and the Survey of Consumer Finances for top wealth holders’ income being about half of top earners’ income (\(\theta = 0.5\)), plus a small correction for the extrapolation overshooting on US data where the answer is known. The result is the “all California billionaires” column of Table 2. Its 2019-2025 average, $3.06 billion a year, is the \(C\) that Table 5 uses below.
The top-4 columns need no extrapolation: the workbook computes each person’s income tax from their SEC filings (stock sales, dividends, option exercises).
R or Python code for Table 2
# How the pipeline builds this result (bsz-analysis/_targets.R):
tab2_gt <- build_tab2(data_sec_agg_r, billionaires_ca_inctax_r, data_sec_top4)
# reading these targets:
data_sec_agg_r <- read_data_sec_agg(workbook_db)
billionaires_ca_inctax_r <- compute_billionaires_ca_inctax(data_sec_agg_r, billionaires_ca_inctax, ftb_b4a_sql)
data_sec_top4 <- extract_data_sec_top4(xlsx_path)
build_tab2 <- function(data_sec_agg_r, billionaires_ca_inctax_r, data_sec_top4,
vintage = bsz_vintage()) {
# Table 2: California Income Tax Paid by California Billionaires.
# Two side-by-side sub-panels: all CA billionaires (cols B-D) and the top 4
# on company wealth (cols F-H), 2019-2025 + a 2019-2025 average row.
yrs <- 2019:2025
m1 <- billionaires_ca_inctax_r$method1
m1_idx <- match(yrs, m1$year)
agg_y <- data_sec_agg_r[match(yrs, data_sec_agg_r$year), ]
total_excl <- data_sec_top4[data_sec_top4$forbes_id == "Total (excluding Ellison)" &
data_sec_top4$year %in% yrs, ]
total_excl <- total_excl[order(total_excl$year), ]
panel <- tibble::tibble(
year = as.character(yrs),
wealth_b = agg_y$forbes_worth,
ca_inctax_b = m1$ca_inctax_ca_billionaires_b[m1_idx],
ca_inctax_per_wealth = NA_real_,
top4_company_wealth_b = total_excl$public_worth / 1000,
top4_ca_inctax_b = total_excl$ca_income_tax / 1000,
top4_ca_inctax_per_wealth = NA_real_
)
panel$ca_inctax_per_wealth <- panel$ca_inctax_b / panel$wealth_b
panel$top4_ca_inctax_per_wealth <- panel$top4_ca_inctax_b / panel$top4_company_wealth_b
# Average row, an Excel quirk: D14 = AVERAGE(D7:D12) (6 yrs, 2019-2024 only,
# a direct average of the RATIO column) while B/C/F14 = AVERAGE(*7:*13)
# (7 yrs). May's top4-ratio cell (H14) = G14/F14, i.e. mean-of-PARTS over
# all 7 years (matches the code below unconditionally). August's
# equivalent cell (J14) changed formula entirely to
# `=AVERAGE(J7:J12)` - a direct 6-year average of the ratio column itself,
# picking up the SAME 6-year truncation quirk as D14/E14 - a genuine,
# vintage-specific formula difference, not a row-shift artifact (verified
# against both workbooks' formulas directly).
top4_ratio_avg <- if (identical(vintage, "may")) {
mean(panel$top4_ca_inctax_b) / mean(panel$top4_company_wealth_b)
} else {
mean(panel$top4_ca_inctax_per_wealth[1:6]) # 2019-2024
}
avg_row <- tibble::tibble(
year = "2019-2025 average",
wealth_b = mean(panel$wealth_b),
ca_inctax_b = mean(panel$ca_inctax_b),
ca_inctax_per_wealth = mean(panel$ca_inctax_per_wealth[1:6]), # 2019-2024
top4_company_wealth_b = mean(panel$top4_company_wealth_b),
top4_ca_inctax_b = mean(panel$top4_ca_inctax_b),
top4_ca_inctax_per_wealth = top4_ratio_avg
)
full <- dplyr::bind_rows(panel, avg_row)
tab <- gt::gt(full) |>
gt::tab_header(title = "Table 2. California Income Tax Paid by California Billionaires") |>
gt::tab_spanner(label = "All California Billionaires",
columns = c(wealth_b, ca_inctax_b, ca_inctax_per_wealth)) |>
gt::tab_spanner(label = "Top 4 (Page, Brin, Zuckerberg, Huang) on Company Wealth",
columns = c(top4_company_wealth_b, top4_ca_inctax_b,
top4_ca_inctax_per_wealth)) |>
gt::cols_label(
year = "Year",
wealth_b = "Wealth ($B)",
ca_inctax_b = "Est. CA inc. tax ($B)",
ca_inctax_per_wealth = "Tax / wealth",
top4_company_wealth_b = "Company wealth ($B)",
top4_ca_inctax_b = "Est. CA inc. tax ($B)",
top4_ca_inctax_per_wealth = "Tax / wealth"
) |>
gt::fmt_number(columns = c(wealth_b, top4_company_wealth_b),
decimals = 0, use_seps = TRUE) |>
gt::fmt_number(columns = c(ca_inctax_b, top4_ca_inctax_b),
decimals = 2) |>
gt::fmt_percent(columns = c(ca_inctax_per_wealth, top4_ca_inctax_per_wealth),
decimals = 3) |>
gt::tab_source_note(source_note = gt::md(paste(
"**Notes:** All amounts in nominal $B. CA income tax for all billionaires comes",
"from the Method I FTB-extrapolation calculation; top 4 figures come from SEC",
"filings. Source: `billionaires_ca_inctax_r` + `data_sec_top4`."
)))
attr(tab, "panel") <- full
tab
}
# Read-only display. Edit R/tables.R:21-103.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: read_data_sec_agg(), compute_billionaires_ca_inctax(), extract_data_sec_top4()
def build_tab2(data_sec_agg_r, billionaires_ca_inctax_r, data_sec_top4, vintage=None):
vintage = vintage or bsz_vintage()
yrs = list(range(2019, 2026))
m1 = billionaires_ca_inctax_r["method1"].set_index("year")
agg_y = data_sec_agg_r.set_index("year").loc[yrs]
te = _total_excl(data_sec_top4, yrs)
panel = pd.DataFrame({
"year": [str(y) for y in yrs],
"wealth_b": agg_y["forbes_worth"].to_numpy(float),
"ca_inctax_b": m1.loc[yrs, "ca_inctax_ca_billionaires_b"].to_numpy(float),
"ca_inctax_per_wealth": NA,
"top4_company_wealth_b": te["public_worth"].to_numpy(float) / 1000,
"top4_ca_inctax_b": te["ca_income_tax"].to_numpy(float) / 1000,
"top4_ca_inctax_per_wealth": NA,
})
panel["ca_inctax_per_wealth"] = panel["ca_inctax_b"] / panel["wealth_b"]
panel["top4_ca_inctax_per_wealth"] = panel["top4_ca_inctax_b"] / panel["top4_company_wealth_b"]
# The average row's Excel quirks (6-year averages of the ratio columns).
if vintage == "may":
top4_ratio_avg = _rmean(panel["top4_ca_inctax_b"]) / _rmean(panel["top4_company_wealth_b"])
else:
top4_ratio_avg = _rmean(panel["top4_ca_inctax_per_wealth"].iloc[0:6])
avg_row = {
"year": "2019-2025 average",
"wealth_b": _rmean(panel["wealth_b"]),
"ca_inctax_b": _rmean(panel["ca_inctax_b"]),
"ca_inctax_per_wealth": _rmean(panel["ca_inctax_per_wealth"].iloc[0:6]),
"top4_company_wealth_b": _rmean(panel["top4_company_wealth_b"]),
"top4_ca_inctax_b": _rmean(panel["top4_ca_inctax_b"]),
"top4_ca_inctax_per_wealth": top4_ratio_avg,
}
return {"panel": pd.concat([panel, pd.DataFrame([avg_row])], ignore_index=True)}
# Read-only display. Edit py/tables.py:35-68.| Table 2. California Income Tax Paid by California Billionaires | ||||||
| Year | All California Billionaires | Top 4 (Page, Brin, Zuckerberg, Huang) on Company Wealth | ||||
|---|---|---|---|---|---|---|
| Wealth ($B) | Est. CA inc. tax ($B) | Tax / wealth | Company wealth ($B) | Est. CA inc. tax ($B) | Tax / wealth | |
| 2019 | 821 | 1.99 | 0.242% | 184 | 0.03 | 0.018% |
| 2020 | 967 | 2.77 | 0.286% | 247 | 0.06 | 0.022% |
| 2021 | 1,212 | 4.50 | 0.371% | 365 | 0.77 | 0.210% |
| 2022 | 843 | 2.33 | 0.276% | 188 | 0.27 | 0.142% |
| 2023 | 1,157 | 2.45 | 0.212% | 377 | 0.04 | 0.010% |
| 2024 | 1,570 | 3.05 | 0.194% | 603 | 0.36 | 0.059% |
| 2025 | 2,055 | 4.35 | 0.211% | 856 | 0.36 | 0.042% |
| 2019-2025 average | 1,232 | 3.06 | 0.264% | 403 | 0.27 | 0.077% |
Notes: All amounts in nominal $B. CA income tax for all billionaires comes from the Method I FTB-extrapolation calculation; top 4 figures come from SEC filings. Source: billionaires_ca_inctax_r + data_sec_top4. |
||||||
R or Python code for Figure 3
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig3 <- build_fig3(shortrunseries_r)
# reading these targets:
shortrunseries_r <- compute_shortrunseries(data_sec_agg_r, data_sec_top4, billionaires_ca_inctax_r, shortrunseries)
build_fig3 <- function(shortrunseries_r) {
# Figure 3: The California Income Tax Paid by Billionaires.
# Panel A: $B income tax for all CA billionaires + top 5 (from SEC),
# 2019-2025. Panel B: same series as % of wealth.
panel <- shortrunseries_r$panel
d <- panel[panel$year %in% 2019:2025, ]
n_yrs <- nrow(d)
panel_a_data <- tibble::tibble(
year = rep(d$year, 2),
value = c(d$ca_inctax_billionaires_b, d$top5_sec_ca_inctax_b),
series = factor(rep(c("All CA billionaires", "Top 5 (SEC filings)"),
each = n_yrs),
levels = c("All CA billionaires", "Top 5 (SEC filings)"))
)
panel_b_data <- tibble::tibble(
year = rep(d$year, 2),
value = c(d$ca_inctax_per_wealth, d$top5_sec_tax_rate),
series = factor(rep(c("All CA billionaires", "Top 5 (SEC filings)"),
each = n_yrs),
levels = c("All CA billionaires", "Top 5 (SEC filings)"))
)
colors <- c("All CA billionaires" = "#d62728", "Top 5 (SEC filings)" = "#1f77b4")
panel_a <- ggplot2::ggplot(panel_a_data,
ggplot2::aes(x = year, y = value, color = series,
shape = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2.2) +
ggplot2::scale_x_continuous(breaks = 2019:2025) +
ggplot2::scale_y_continuous(limits = c(0, NA)) +
ggplot2::scale_color_manual(values = colors) +
ggplot2::labs(
title = "A. CA income tax paid by billionaires ($B)",
x = NULL, y = NULL, color = NULL, shape = NULL
) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor.x = ggplot2::element_blank())
panel_b <- ggplot2::ggplot(panel_b_data,
ggplot2::aes(x = year, y = value, color = series,
shape = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2.2) +
ggplot2::scale_x_continuous(breaks = 2019:2025) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1),
limits = c(0, NA)) +
ggplot2::scale_color_manual(values = colors) +
ggplot2::labs(
title = "B. CA income tax / wealth",
x = NULL, y = NULL, color = NULL, shape = NULL
) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor.x = ggplot2::element_blank())
patchwork::wrap_plots(panel_a, panel_b, ncol = 2) +
patchwork::plot_annotation(
title = "Figure 3: California Income Tax Paid by Billionaires"
)
}
# Read-only display. Edit R/figures.R:502-566.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_shortrunseries()
def build_fig3(shortrunseries_r):
p = shortrunseries_r["panel"]
d = p[p["year"].isin(range(2019, 2026))]
labels = ["All CA billionaires", "Top 5 (SEC filings)"]
a = _long(d["year"], "year", [d["ca_inctax_billionaires_b"], d["top5_sec_ca_inctax_b"]], "value",
labels, "series")
b = _long(d["year"], "year", [d["ca_inctax_per_wealth"], d["top5_sec_tax_rate"]], "value",
labels, "series")
return [a, b]
# Read-only display. Edit py/figures.py:155-163.R or Python code for Table 3
# How the pipeline builds this result (bsz-analysis/_targets.R):
tab3_gt <- build_tab3(data_sec_top4)
# reading these targets:
data_sec_top4 <- extract_data_sec_top4(xlsx_path)
build_tab3 <- function(data_sec_top4) {
# Table 3: CA Income Tax Paid by the Top 4 on Company Wealth.
# Per-billionaire panel (Page, Brin, Zuckerberg, Huang) + all-top-4 sum.
# 7 yearly CA income tax rows (2019-2025) + 1 average row +
# begin-of-2019 wealth + end-of-2025 wealth + tax / wealth-gain.
ids <- c("larry-page", "sergey-brin", "mark-zuckerberg", "jensen-huang")
ids_lbl <- c("page", "brin", "zuckerberg", "huang")
d <- data_sec_top4
pick <- function(id, yr, col) {
row <- d[d$forbes_id == id & d$year == yr, ]
if (nrow(row) == 1L) row[[col]] else NA_real_
}
ca_tax_yearly <- function(yr) {
setNames(vapply(ids, pick, numeric(1), yr = yr, col = "ca_income_tax"), ids_lbl)
}
tax_yrs <- lapply(2019:2025, ca_tax_yearly)
# Per-row: per-billionaire + all_top4 sum
yearly_rows <- lapply(seq_along(tax_yrs), function(i) {
row <- as.list(tax_yrs[[i]])
row$all_top4 <- sum(unlist(row))
row$metric <- paste("CA income tax", 2018 + i)
tibble::as_tibble(row)
})
panel <- dplyr::bind_rows(yearly_rows)
panel <- panel[, c("metric", "page", "brin", "zuckerberg", "huang", "all_top4")]
# Average row (mean of the 7 yearly rows, per column)
num_cols <- setdiff(names(panel), "metric")
avg_values <- setNames(lapply(num_cols, function(c) mean(panel[[c]])), num_cols)
avg_row <- tibble::as_tibble(c(
list(metric = "Average CA income tax 2019-2025"),
avg_values
))
# Wealth rows: begin = end-of-2018 public_worth; end = end-of-2025 public_worth.
wealth_begin <- setNames(vapply(ids, pick, numeric(1),
yr = 2018, col = "public_worth"), ids_lbl)
wealth_end <- setNames(vapply(ids, pick, numeric(1),
yr = 2025, col = "public_worth"), ids_lbl)
wealth_begin_row <- tibble::as_tibble(c(
list(metric = "Wealth at the beginning of 2019"),
as.list(wealth_begin),
list(all_top4 = sum(wealth_begin))
))
wealth_end_row <- tibble::as_tibble(c(
list(metric = "Wealth at end of 2025"),
as.list(wealth_end),
list(all_top4 = sum(wealth_end))
))
# Total CA income tax / (end_wealth - begin_wealth), per column.
total_taxes <- vapply(num_cols, function(c) sum(panel[[c]]), numeric(1))
end_minus_begin <- c(wealth_end, all_top4 = sum(wealth_end)) -
c(wealth_begin, all_top4 = sum(wealth_begin))
ratio_row <- tibble::as_tibble(c(
list(metric = "Total CA income tax / wealth gain 2019-2025"),
as.list(total_taxes / end_minus_begin)
))
full <- dplyr::bind_rows(panel, avg_row, wealth_begin_row, wealth_end_row, ratio_row)
tab <- gt::gt(full) |>
gt::tab_header(title = "Table 3. California Income Tax Paid by the Top 4 on Company Wealth") |>
gt::cols_label(
metric = "",
page = "Larry Page (Alphabet)",
brin = "Sergei Brin (Alphabet)",
zuckerberg = "Mark Zuckerberg (Meta)",
huang = "Jensen Huang (Nvidia)",
all_top4 = "All top 4"
) |>
gt::fmt_number(rows = 1:8, decimals = 2, use_seps = TRUE) |>
gt::fmt_number(rows = 9:10, decimals = 0, use_seps = TRUE) |>
gt::fmt_percent(rows = 11, decimals = 3) |>
gt::tab_source_note(source_note = gt::md(paste(
"**Notes:** All amounts in nominal $M unless noted. CA income tax is",
"computed from each billionaire's SEC Form 4 filings (target",
"`data_sec_top4`). Row 11 is the lifetime effective tax rate on the",
"2019-2025 wealth gain."
)))
attr(tab, "panel") <- full
tab
}
# Read-only display. Edit R/tables.R:426-511.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: extract_data_sec_top4()
def build_tab3(data_sec_top4):
ids = ["larry-page", "sergey-brin", "mark-zuckerberg", "jensen-huang"]
lbl = ["page", "brin", "zuckerberg", "huang"]
d = data_sec_top4
def pick(fid, yr, col):
row = d[(d["forbes_id"] == fid) & (d["year"] == yr)]
return float(row[col].iloc[0]) if len(row) == 1 else NA
def rsum(values):
s = 0.0
for v in values:
s += v
return s
rows = []
for i, yr in enumerate(range(2019, 2026)):
r = {"metric": f"CA income tax {2019 + i}"}
r.update({l: pick(f, yr, "ca_income_tax") for f, l in zip(ids, lbl)})
r["all_top4"] = rsum([r[l] for l in lbl])
rows.append(r)
panel = pd.DataFrame(rows)[["metric"] + lbl + ["all_top4"]]
num_cols = lbl + ["all_top4"]
avg_row = {"metric": "Average CA income tax 2019-2025",
**{c: _rmean(panel[c]) for c in num_cols}}
wb = {l: pick(f, 2018, "public_worth") for f, l in zip(ids, lbl)}
we = {l: pick(f, 2025, "public_worth") for f, l in zip(ids, lbl)}
wb_row = {"metric": "Wealth at the beginning of 2019", **wb, "all_top4": rsum(wb.values())}
we_row = {"metric": "Wealth at end of 2025", **we, "all_top4": rsum(we.values())}
totals = {c: rsum(panel[c].to_numpy(float)) for c in num_cols}
ratio_row = {"metric": "Total CA income tax / wealth gain 2019-2025",
**{c: totals[c] / (we_row[c] - wb_row[c]) for c in num_cols}}
full = pd.concat([panel, pd.DataFrame([avg_row, wb_row, we_row, ratio_row])], ignore_index=True)
return {"panel": full}
# Read-only display. Edit py/tables.py:212-245.| Table 3. California Income Tax Paid by the Top 4 on Company Wealth | |||||
| Larry Page (Alphabet) | Sergei Brin (Alphabet) | Mark Zuckerberg (Meta) | Jensen Huang (Nvidia) | All top 4 | |
|---|---|---|---|---|---|
| CA income tax 2019 | 0.00 | 0.00 | 26.40 | 6.19 | 32.59 |
| CA income tax 2020 | 0.00 | 0.00 | 34.89 | 20.29 | 55.18 |
| CA income tax 2021 | 240.55 | 167.59 | 298.58 | 58.88 | 765.60 |
| CA income tax 2022 | 120.26 | 83.93 | 2.52 | 60.50 | 267.21 |
| CA income tax 2023 | 0.00 | 0.00 | 11.26 | 27.85 | 39.11 |
| CA income tax 2024 | 82.92 | 54.15 | 165.99 | 54.85 | 357.91 |
| CA income tax 2025 | 87.07 | 56.25 | 125.78 | 89.44 | 358.54 |
| Average CA income tax 2019-2025 | 75.83 | 51.70 | 95.06 | 45.43 | 268.02 |
| Wealth at the beginning of 2019 | 41,576 | 40,085 | 47,768 | 2,857 | 132,286 |
| Wealth at end of 2025 | 244,240 | 225,401 | 225,635 | 160,228 | 855,503 |
| Total CA income tax / wealth gain 2019-2025 | 0.262% | 0.195% | 0.374% | 0.202% | 0.259% |
Notes: All amounts in nominal $M unless noted. CA income tax is computed from each billionaire’s SEC Form 4 filings (target data_sec_top4). Row 11 is the lifetime effective tax rate on the 2019-2025 wealth gain. |
|||||
B. All taxes paid by billionaires: Figure 4
What is computed. Figure 4 adds federal income tax, corporate tax and property and sales tax to the state income tax, each as a share of wealth. Corporate tax is attributed to billionaires in proportion to what they own. For private businesses, which file no public accounts, the workbook scales the public-company tax rate by the split of private wealth between pass-through and private C-corporations, with a 9.5% gross-up for diversified holdings.
R or Python code for Figure 4
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig4 <- build_fig4(billionaires_ca_inctax_r)
# reading these targets:
billionaires_ca_inctax_r <- compute_billionaires_ca_inctax(data_sec_agg_r, billionaires_ca_inctax, ftb_b4a_sql)
build_fig4 <- function(billionaires_ca_inctax_r) {
# Figure 4: Total Taxes Paid by California Billionaires relative to Wealth.
# Stacked area chart of 4 tax components (CA inctax, fed inctax, corporate,
# property+sales) as % of total wealth, 2019-2025.
at <- billionaires_ca_inctax_r$all_taxes
yrs <- 2019:2025
d <- at[at$year %in% yrs, ]
# geom_area's default stacking plots the FIRST factor level on TOP. To
# match the BSZ figure (CA income tax at the bottom, property+sales as the
# thin top sliver), list factor levels in TOP-TO-BOTTOM order.
stack_levels <- c("Property + sales taxes", "Corporate taxes",
"Federal income tax", "CA income tax")
long <- tibble::tibble(
year = rep(d$year, 4),
share = c(d$ca_inctax_per_total_wealth,
d$fed_inctax_per_total_wealth,
d$corp_per_total_wealth,
d$prop_sales_per_total_wealth),
tax = factor(rep(c("CA income tax", "Federal income tax",
"Corporate taxes", "Property + sales taxes"),
each = nrow(d)),
levels = stack_levels)
)
ggplot2::ggplot(long, ggplot2::aes(x = year, y = share, fill = tax)) +
ggplot2::geom_area(alpha = 0.95, color = "white", linewidth = 0.3) +
ggplot2::scale_x_continuous(breaks = yrs) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
ggplot2::scale_fill_manual(values = c(
"CA income tax" = "#4a1414", # darkest, anchor band
"Federal income tax" = "#8a2c2c", # burgundy
"Corporate taxes" = "#c47878", # rose
"Property + sales taxes" = "#f0d4d4" # pale pink, top sliver
)) +
ggplot2::labs(
title = "Figure 4: Total Taxes Paid by California Billionaires (% of wealth)",
subtitle = "CA income tax + Federal income tax + Corporate + Property/sales, 2019-2025",
x = NULL, y = NULL, fill = NULL
) +
ggplot2::theme_minimal(base_size = 12) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank()
)
}
# Read-only display. Edit R/figures.R:454-500.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_billionaires_ca_inctax()
def build_fig4(billionaires_ca_inctax_r):
at = billionaires_ca_inctax_r["all_taxes"]
d = at[at["year"].isin(range(2019, 2026))]
return _long(d["year"], "year",
[d["ca_inctax_per_total_wealth"], d["fed_inctax_per_total_wealth"],
d["corp_per_total_wealth"], d["prop_sales_per_total_wealth"]], "share",
["CA income tax", "Federal income tax", "Corporate taxes", "Property + sales taxes"],
"tax")
# Read-only display. Edit py/figures.py:145-152.C. All taxes paid by the top 4: Table 4 and Figures 5 to 7
Table 4 and Figures 5 to 7 add up every tax the four largest fortunes pay and compare it with their income and wealth.
Table 4 provides detailed numbers of wealth, income earned, and taxes paid by type of tax for the top 4 over the full 2019-2025 period. (BSZ PDF p.15)
What is computed. For the four largest fortunes the workbook builds taxes bottom up from company filings: each person’s share \(s_{i,t}\) of company profits and corporate taxes, \[\Pi_{i,t} = s_{i,t}\,\Pi_{\text{firm},t},\] plus imputed property tax (1% of property, plant and equipment) and sales tax. “Economic income” adds retained company profits to the income that shows up on a tax return, because a founder’s share of profits the company keeps is income to them even though it is never taxed as such. Figure 5 compares taxes with fiscal income, economic income and wealth gains; Figure 6 runs the tax rates back to 2004; Figure 7 compares them with economy-wide averages.
R or Python code for Table 4
# How the pipeline builds this result (bsz-analysis/_targets.R):
tab4_gt <- build_tab4(data_sec_top4)
# reading these targets:
data_sec_top4 <- extract_data_sec_top4(xlsx_path)
build_tab4 <- function(data_sec_top4) {
# Table 4: Wealth, Income, and Taxes of the Top 4, 2019-2025.
# Two columns: "Total 2019-2025" and "Annual average" (= total / 7).
d <- data_sec_top4
total_excl <- d[d$forbes_id == "Total (excluding Ellison)" &
d$year %in% 2019:2025, ]
total_excl <- total_excl[order(total_excl$year), ]
begin <- d[d$forbes_id == "Total (excluding Ellison)" & d$year == 2018, ]
end <- d[d$forbes_id == "Total (excluding Ellison)" & d$year == 2025, ]
# All values converted from $M to $B by dividing by 1000.
wealth_begin <- begin$public_worth / 1000
wealth_end <- end$public_worth / 1000
wealth_gain <- wealth_end - wealth_begin
wealth_avg <- mean(total_excl$public_worth) / 1000
sum_col <- function(col) sum(total_excl[[col]]) / 1000
fiscal_income <- sum_col("fiscal_income")
stock_options <- sum_col("option_profit") + sum_col("noneq_comp")
dividends <- sum_col("dividend")
realized_gains <- sum_col("kg_taxable")
appreciated_stock <- sum_col("donation")
net_collateral <- sum_col("value_borrowed")
fed_inctax <- sum_col("fed_income_tax")
ca_inctax <- sum_col("ca_income_tax")
corp_profits <- sum_col("w_pi")
corp_taxes <- sum_col("w_txt")
rows <- tibble::tibble(
metric = c(
"Wealth in 2019 (beginning of year)",
"Wealth in 2025 (end of year)",
"Gain in wealth during 2019-2025",
"Wealth (average over 2019-2025)",
"Fiscal individual income",
" Stock-options exercise + non-equity comp",
" Dividends",
" Realized capital gains",
"Memo: Appreciated stock donated to charity",
"Memo: Net collateral pledged",
"Federal individual income tax",
"California individual income tax",
"Individual taxes / individual income",
"Corporate profits",
"Corporate taxes (federal)",
"Corporate tax rate (effective)"
),
total = c(
wealth_begin, wealth_end, wealth_gain, NA_real_,
fiscal_income, stock_options, dividends, realized_gains,
appreciated_stock, net_collateral,
fed_inctax, ca_inctax,
(fed_inctax + ca_inctax) / fiscal_income,
corp_profits, corp_taxes, corp_taxes / corp_profits
),
annual_avg = c(
NA_real_, NA_real_, wealth_gain / 7, wealth_avg,
fiscal_income / 7, stock_options / 7, dividends / 7, realized_gains / 7,
appreciated_stock / 7, net_collateral / 7,
fed_inctax / 7, ca_inctax / 7,
(fed_inctax + ca_inctax) / fiscal_income,
corp_profits / 7, corp_taxes / 7, corp_taxes / corp_profits
)
)
ratio_rows <- c(13, 16) # tax-rate rows
fmt_rows <- setdiff(seq_len(nrow(rows)), ratio_rows)
tab <- gt::gt(rows) |>
gt::tab_header(title = "Table 4. Wealth, Income, and Taxes of the Top 4, 2019-2025") |>
gt::cols_label(
metric = "",
total = "Total 2019-2025 ($B)",
annual_avg = "Annual average ($B)"
) |>
gt::fmt_number(rows = fmt_rows, decimals = 2) |>
gt::fmt_percent(rows = ratio_rows, decimals = 1) |>
gt::sub_missing(missing_text = "–") |>
gt::tab_source_note(source_note = gt::md(paste(
"**Notes:** All amounts in nominal $B. Source: per-billionaire SEC Form 4",
"filings aggregated as `data_sec_top4`'s \"Total (excluding Ellison)\" rows."
)))
attr(tab, "panel") <- rows
tab
}
# Read-only display. Edit R/tables.R:338-424.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: extract_data_sec_top4()
def build_tab4(data_sec_top4):
d = data_sec_top4
te = _total_excl(d, range(2019, 2026))
tot = d[d["forbes_id"] == "Total (excluding Ellison)"]
begin = float(tot.loc[tot["year"] == 2018, "public_worth"].iloc[0])
end = float(tot.loc[tot["year"] == 2025, "public_worth"].iloc[0])
wealth_begin, wealth_end = begin / 1000, end / 1000
wealth_gain = wealth_end - wealth_begin
wealth_avg = _rmean(te["public_worth"]) / 1000
def sum_col(col):
s = 0.0
for v in te[col].to_numpy(float): # R's sum(): left to right
s += v
return s / 1000
fiscal_income = sum_col("fiscal_income")
stock_options = sum_col("option_profit") + sum_col("noneq_comp")
dividends = sum_col("dividend")
realized_gains = sum_col("kg_taxable")
appreciated_stock = sum_col("donation")
net_collateral = sum_col("value_borrowed")
fed_inctax = sum_col("fed_income_tax")
ca_inctax = sum_col("ca_income_tax")
corp_profits = sum_col("w_pi")
corp_taxes = sum_col("w_txt")
metric = [
"Wealth in 2019 (beginning of year)",
"Wealth in 2025 (end of year)",
"Gain in wealth during 2019-2025",
"Wealth (average over 2019-2025)",
"Fiscal individual income",
" Stock-options exercise + non-equity comp",
" Dividends",
" Realized capital gains",
"Memo: Appreciated stock donated to charity",
"Memo: Net collateral pledged",
"Federal individual income tax",
"California individual income tax",
"Individual taxes / individual income",
"Corporate profits",
"Corporate taxes (federal)",
"Corporate tax rate (effective)",
]
total = [wealth_begin, wealth_end, wealth_gain, NA,
fiscal_income, stock_options, dividends, realized_gains,
appreciated_stock, net_collateral, fed_inctax, ca_inctax,
(fed_inctax + ca_inctax) / fiscal_income,
corp_profits, corp_taxes, corp_taxes / corp_profits]
annual = [NA, NA, wealth_gain / 7, wealth_avg,
fiscal_income / 7, stock_options / 7, dividends / 7, realized_gains / 7,
appreciated_stock / 7, net_collateral / 7, fed_inctax / 7, ca_inctax / 7,
(fed_inctax + ca_inctax) / fiscal_income,
corp_profits / 7, corp_taxes / 7, corp_taxes / corp_profits]
return {"panel": pd.DataFrame({"metric": metric, "total": total, "annual_avg": annual})}
# Read-only display. Edit py/tables.py:154-209.| Table 4. Wealth, Income, and Taxes of the Top 4, 2019-2025 | ||
| Total 2019-2025 ($B) | Annual average ($B) | |
|---|---|---|
| Wealth in 2019 (beginning of year) | 132.29 | – |
| Wealth in 2025 (end of year) | 855.50 | – |
| Gain in wealth during 2019-2025 | 723.22 | 103.32 |
| Wealth (average over 2019-2025) | – | 402.86 |
| Fiscal individual income | 18.44 | 2.63 |
| Stock-options exercise + non-equity comp | 2.32 | 0.33 |
| Dividends | 4.00 | 0.57 |
| Realized capital gains | 11.80 | 1.69 |
| Memo: Appreciated stock donated to charity | 17.37 | 2.48 |
| Memo: Net collateral pledged | 3.14 | 0.45 |
| Federal individual income tax | 3.62 | 0.52 |
| California individual income tax | 1.88 | 0.27 |
| Individual taxes / individual income | 29.8% | 29.8% |
| Corporate profits | 128.41 | 18.34 |
| Corporate taxes (federal) | 21.32 | 3.05 |
| Corporate tax rate (effective) | 16.6% | 16.6% |
Notes: All amounts in nominal $B. Source: per-billionaire SEC Form 4 filings aggregated as data_sec_top4’s “Total (excluding Ellison)” rows. |
||
R or Python code for Figure 5
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig5 <- build_fig5(data_sec_top4)
# reading these targets:
data_sec_top4 <- extract_data_sec_top4(xlsx_path)
build_fig5 <- function(data_sec_top4) {
# Figure 5: Fiscal Income, Economic Income, and Wealth Gains of the Top 4,
# 2019-2025. Three stacked bars showing the size of each income/wealth
# concept and the share absorbed by taxes (CA, federal, corporate, and a
# hypothetical 5% wealth tax).
total_excl <- data_sec_top4[data_sec_top4$forbes_id == "Total (excluding Ellison)" &
data_sec_top4$year %in% 2019:2025, ]
d <- total_excl[order(total_excl$year), ]
begin <- data_sec_top4[data_sec_top4$forbes_id == "Total (excluding Ellison)" &
data_sec_top4$year == 2018, ]
end <- data_sec_top4[data_sec_top4$forbes_id == "Total (excluding Ellison)" &
data_sec_top4$year == 2025, ]
fiscal_income <- sum(d$fiscal_income) / 1000
econ_income <- sum(d$economic_income) / 1000
wealth_gain <- (end$public_worth - begin$public_worth) / 1000
ca_inctax <- sum(d$ca_income_tax) / 1000
fed_inctax <- sum(d$fed_income_tax) / 1000
corp_tax <- sum(d$total_tax) / 1000 - ca_inctax - fed_inctax
wealth_tax_5p <- 0.05 * end$public_worth / 1000
# Per-bar breakdown: net income + CA + Fed + (corp only for Econ/Wealth) +
# (5% wealth tax only for Wealth Gain).
net_fi <- fiscal_income - ca_inctax - fed_inctax
net_ei <- econ_income - ca_inctax - fed_inctax - corp_tax
net_wg <- wealth_gain - wealth_tax_5p # corporate taxes absorbed within net
bars <- tibble::tibble(
bar = factor(rep(c("Fiscal Income", "Economic Income", "Wealth Gain"), each = 5),
levels = c("Fiscal Income", "Economic Income", "Wealth Gain")),
component = factor(rep(c("Net of taxes", "CA income tax", "Federal income tax",
"Corporate taxes", "5% wealth tax"), times = 3),
levels = c("Net of taxes", "CA income tax",
"Federal income tax", "Corporate taxes",
"5% wealth tax")),
value = c(
net_fi, ca_inctax, fed_inctax, 0, 0,
net_ei, ca_inctax, fed_inctax, corp_tax, 0,
net_wg, ca_inctax, fed_inctax, corp_tax, wealth_tax_5p
)
)
ggplot2::ggplot(bars, ggplot2::aes(x = bar, y = value, fill = component)) +
ggplot2::geom_col(width = 0.65) +
ggplot2::scale_y_continuous(
labels = function(v) format(v, big.mark = ",", scientific = FALSE)
) +
ggplot2::scale_fill_manual(values = c(
"Net of taxes" = "#a6cee3",
"CA income tax" = "#1f77b4",
"Federal income tax" = "#2ca02c",
"Corporate taxes" = "#d62728",
"5% wealth tax" = "#ff7f0e"
)) +
ggplot2::labs(
title = "Figure 5: Fiscal Income, Economic Income, and Wealth Gains of the Top 4",
subtitle = "2019-2025 cumulative, $B; bars stack net + tax components",
x = NULL, y = "$B", fill = NULL
) +
ggplot2::theme_minimal(base_size = 12) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.major.x = ggplot2::element_blank()
)
}
# Read-only display. Edit R/figures.R:387-452.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: extract_data_sec_top4()
def build_fig5(data_sec_top4):
t = data_sec_top4[data_sec_top4["forbes_id"] == "Total (excluding Ellison)"]
d = t[t["year"].isin(range(2019, 2026))].sort_values("year", kind="stable")
begin = float(t.loc[t["year"] == 2018, "public_worth"].iloc[0])
end = float(t.loc[t["year"] == 2025, "public_worth"].iloc[0])
def rsum(col):
s = 0.0
for v in d[col].to_numpy(float):
s += v
return s
fiscal_income = rsum("fiscal_income") / 1000
econ_income = rsum("economic_income") / 1000
wealth_gain = (end - begin) / 1000
ca_inctax = rsum("ca_income_tax") / 1000
fed_inctax = rsum("fed_income_tax") / 1000
corp_tax = rsum("total_tax") / 1000 - ca_inctax - fed_inctax
wealth_tax_5p = 0.05 * end / 1000
net_fi = fiscal_income - ca_inctax - fed_inctax
net_ei = econ_income - ca_inctax - fed_inctax - corp_tax
net_wg = wealth_gain - wealth_tax_5p
components = ["Net of taxes", "CA income tax", "Federal income tax", "Corporate taxes",
"5% wealth tax"]
return pd.DataFrame({
"bar": _rep_each(["Fiscal Income", "Economic Income", "Wealth Gain"], 5),
"component": components * 3,
"value": [net_fi, ca_inctax, fed_inctax, 0, 0,
net_ei, ca_inctax, fed_inctax, corp_tax, 0,
net_wg, ca_inctax, fed_inctax, corp_tax, wealth_tax_5p],
})
# Read-only display. Edit py/figures.py:111-142.R or Python code for Figure 6
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig6 <- build_fig6(top4taxes_r)
# reading these targets:
top4taxes_r <- compute_top4taxes(data_sec_top4)
build_fig6 <- function(top4taxes_r) {
# Figure 6: Taxes paid by Top 4 relative to wealth and economic income.
# 2004-2025 line chart, two panels: A) tax/wealth, B) tax/economic income.
d <- top4taxes_r$panel
yrs <- d$year
n <- length(yrs)
panel_a <- tibble::tibble(
year = rep(yrs, 2),
value = c(d$total_tax_per_wealth, d$ca_inctax_per_wealth),
series = factor(rep(c("Total taxes / wealth", "CA income tax / wealth"),
each = n),
levels = c("Total taxes / wealth", "CA income tax / wealth"))
)
panel_b <- tibble::tibble(
year = rep(yrs, 2),
value = c(d$total_tax_per_income, d$ca_inctax_per_income),
series = factor(rep(c("Total taxes / income", "CA income tax / income"),
each = n),
levels = c("Total taxes / income", "CA income tax / income"))
)
colors_a <- c("Total taxes / wealth" = "#d62728", "CA income tax / wealth" = "#1f77b4")
colors_b <- c("Total taxes / income" = "#d62728", "CA income tax / income" = "#1f77b4")
pa <- ggplot2::ggplot(panel_a, ggplot2::aes(x = year, y = value, color = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 1.5) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1),
limits = c(0, NA)) +
ggplot2::scale_color_manual(values = colors_a) +
ggplot2::labs(title = "A. Top 4 Total Tax and CA income tax (% of wealth)",
x = NULL, y = NULL, color = NULL) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
pb <- ggplot2::ggplot(panel_b, ggplot2::aes(x = year, y = value, color = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 1.5) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1),
limits = c(0, NA)) +
ggplot2::scale_color_manual(values = colors_b) +
ggplot2::labs(title = "B. Top 4 Total Tax and CA income tax (% of economic income)",
x = NULL, y = NULL, color = NULL) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
patchwork::wrap_plots(pa, pb, ncol = 2) +
patchwork::plot_annotation(
title = "Figure 6: Taxes Paid by the Top 4 Relative to Wealth and Income"
)
}
# Read-only display. Edit R/figures.R:330-385.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_top4taxes()
def build_fig6(top4taxes_r):
d = top4taxes_r["panel"]
a = _long(d["year"], "year", [d["total_tax_per_wealth"], d["ca_inctax_per_wealth"]], "value",
["Total taxes / wealth", "CA income tax / wealth"], "series")
b = _long(d["year"], "year", [d["total_tax_per_income"], d["ca_inctax_per_income"]], "value",
["Total taxes / income", "CA income tax / income"], "series")
return [a, b]
# Read-only display. Edit py/figures.py:102-108.R or Python code for Figure 7
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig7 <- build_fig7(top4taxes_r, data_dina)
# reading these targets:
top4taxes_r <- compute_top4taxes(data_sec_top4)
data_dina <- extract_data_dina(xlsx_path)
build_fig7 <- function(top4taxes_r, data_dina) {
# Figure 7: Total Taxes / Economic Income, Top 4 vs US-wide average; and
# CA income tax / Economic Income, Top 4 vs CA average. 2004-2025.
d <- top4taxes_r$panel
yrs <- d$year # 2004:2025
# data_dina K = US avg total tax/economic income, S = CA avg ca_inctax/economic
# income. Rows 6..27 = years 2004..2025.
dina_K <- suppressWarnings(as.numeric(data_dina$K[6:27]))
dina_S <- suppressWarnings(as.numeric(data_dina$S[6:27]))
n <- length(yrs)
panel_a <- tibble::tibble(
year = rep(yrs, 2),
value = c(d$total_tax_per_income, dina_K),
series = factor(rep(c("Top 4 (CA billionaires)", "US average"), each = n),
levels = c("Top 4 (CA billionaires)", "US average"))
)
panel_b <- tibble::tibble(
year = rep(yrs, 2),
value = c(d$ca_inctax_per_income, dina_S),
series = factor(rep(c("Top 4 (CA billionaires)", "CA average"), each = n),
levels = c("Top 4 (CA billionaires)", "CA average"))
)
colors <- c("Top 4 (CA billionaires)" = "#1f77b4",
"US average" = "#d62728",
"CA average" = "#d62728")
pa <- ggplot2::ggplot(panel_a,
ggplot2::aes(x = year, y = value, color = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 1.5) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1),
limits = c(0, NA)) +
ggplot2::scale_color_manual(values = colors) +
ggplot2::labs(title = "A. Top 4 vs. US average: Total Taxes / Economic Income",
x = NULL, y = NULL, color = NULL) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
pb <- ggplot2::ggplot(panel_b,
ggplot2::aes(x = year, y = value, color = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 1.5) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1),
limits = c(0, NA)) +
ggplot2::scale_color_manual(values = colors) +
ggplot2::labs(title = "B. Top 4 vs. CA average: CA Income Tax / Economic Income",
x = NULL, y = NULL, color = NULL) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
patchwork::wrap_plots(pa, pb, ncol = 2) +
patchwork::plot_annotation(
title = "Figure 7: Top 4 Effective Tax Rates vs. US/CA Averages"
)
}
# Read-only display. Edit R/figures.R:268-328.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_top4taxes(), extract_data_dina()
def build_fig7(top4taxes_r, data_dina):
d = top4taxes_r["panel"]
yrs = d["year"]
# data_dina K = US total tax / economic income, S = CA income tax /
# economic income; rows 6..27 = 2004..2025.
dina_K = col_num(data_dina, "K", range(6, 28))
dina_S = col_num(data_dina, "S", range(6, 28))
a = _long(yrs, "year", [d["total_tax_per_income"], dina_K], "value",
["Top 4 (CA billionaires)", "US average"], "series")
b = _long(yrs, "year", [d["ca_inctax_per_income"], dina_S], "value",
["Top 4 (CA billionaires)", "CA average"], "series")
return [a, b]
# Read-only display. Edit py/figures.py:88-99.IV. The economics of taxing billionaires’ wealth
A. The one-time 5% tax: Table 5, step by step
This is the section BSZ’s headline rests on. Table 5 has four scenarios: the base scenario (row 1), the missing billionaires scenario (row 2), the leavers scenario (row 3), and missing + leavers (row 4). This OPA builds the base scenario in five steps, then each other scenario as a change to it.
Step 1: the tax base
The tax base is the Forbes wealth of California’s billionaires on 1 July 2026.
Using the July 1st wealth value of $2.3 trillion, the wealth tax scores at $104 billion (=90%*5%*$2307 billion). (BSZ PDF p.24)
The base is Forbes’ real-time list on 1 July 2026: \(n_0\) = 250 billionaires with \(W_0\) = $2,307 billion. These two numbers are typed into the workbook (Tab5!B6:C6); they are data, but data the authors read off Forbes on that day, not something the pipeline recomputes. The Act itself values wealth on 31 December 2026, so the real base will differ; GGSS note that the daily estimate ranged from $87 billion to $107 billion between January and mid-July 2026 (GGSS p.4, fn.8).
Step 2: avoidance and evasion
Some wealth will escape the tax, through under-valuation, hiding, or leaving the state in time. The authors assume a share \(\alpha\) = 10% of all of these together: \[\begin{aligned} W^{\text{tax}} &= (1 - \alpha)\,W_0 \\ &= 0.9 \times 2,307 = 2,076.3. \end{aligned}\] In words: the tax is charged on 90% of listed wealth. The 10% is not estimated. GGSS argue it is small because most of the wealth is listed stock that is easy to value, and each of some 250 taxpayers can be audited individually (GGSS p.4). It is guesswork, and it matters most for the headline. With the leavers assumption, it is one of only two guesses that move the revenue (see Every input, by origin).
Step 3: wealth tax revenue
\[T = \tau\,W^{\text{tax}} = 0.05 \times 2,076.3 = 103.8.\] That is Table 5’s row 1, printed as $104 billion and reproduced here as $103.8 billion, collected once (payable at once or in five annual installments). Before avoidance, 5% of the base would be $115.4 billion; avoidance costs $11.5 billion.
Step 4: extra state income tax from selling assets
Paying the tax by selling shares raises some extra California income tax, about 3.5% of the wealth tax.
Table 5 reports a column of this extra CA income tax generated assuming that wealth taxes will be paid one-third by selling assets triggering taxable realized capital gains (with a basis of 20% of selling price …) and two-thirds with liquidities or debt. (BSZ PDF p.24)
To pay the tax, some billionaires sell shares, and selling realises capital gains that the state taxes. Only the gain is taxed: the sale price minus what the stock originally cost (its basis). The authors put the basis at 20% of the sale price, so the gain is \(g\) = 80% of each dollar sold. Example: selling $100 of stock bought for $20 gives an $80 taxable gain. With a share \(s\) of the tax paid by selling and the top state rate \(t_{cg}\) on gains: \[\begin{aligned} E &= s \cdot g \cdot t_{cg} \cdot T \\ &= \tfrac13 \times 0.80 \times 0.133 \times 103.8 \\ &= 3.68. \end{aligned}\] So the tax pays back about 3.5% of itself in income tax, spread over the five payment years. The one-third is guesswork; the 80% gain share is the paper’s own estimate of how much of billionaire wealth is unrealised gains.
Step 5: income tax lost to billionaires who leave
Billionaires who leave stop paying California income tax: $0.15 billion a year in the base scenario.
If we assume that half of the 10% avoidance takes the form of mobility: i.e. 5% of billionaires were able to successfully relocate outside California in the narrow time window before January 1st, 2026, then we estimate that a corresponding 5% of the total California individual income tax paid by billionaires will be lost. (BSZ PDF p.24)
A billionaire who leaves stops paying California income tax. The authors assume half of the avoidance (\(m\) = 50%) is people leaving, so \(\alpha m\) = 5% of the income tax billionaires pay is lost: \[\begin{aligned} L &= -\alpha\,m\,C \\ &= -0.10 \times 0.5 \times 3.06 \\ &= -0.153 \text{ per year}. \end{aligned}\]
In dollars: Step 2 took 10% of the wealth, $230.70 billion, out of the wealth tax. Half of it is under-valued or hidden, and its owners still live in California and keep paying income tax. The other half, $115.35 billion, belongs to people who move out. Step 2 already removed their wealth from the wealth tax. Step 5 is about a different tax, the income tax, so nothing is counted twice. Billionaires pay about $1.33 of California income tax for every $1,000 of wealth, and the authors assume the movers pay at that same rate:
| Wealth, $B | Income tax, $B a year | |
|---|---|---|
| All California billionaires | 2,307 | 3.061 |
| The movers (\(\alpha m\) = 5% of the wealth) | 115.35 | 0.153 |
This is a yearly loss. The paper argues that, for a genuinely one-time tax, the losses should be temporary, and gives five years, the payment period, as an example (“If they last say only 5 years”, PDF p.27). Table 5 itself takes no stand on how long they last. The JR analysis (Jaros and Rauh; first version Rauh, Jaros, Kearney, Doran and Cosso) counts the loss forever, because it assumes leavers do not come back once the Act permanently lifts the constitutional cap on taxes on intangible property (chapter PDF p.32). That difference in horizon is one of the largest disputes between the two analyses (comparison/DISPUTES.md, row 2). The comparison’s report gives each dispute’s size in dollars.
The main estimate
The main estimate adds the three pieces. It counts the yearly loss for 5 years (BSZ’s example; this OPA’s reading of it), discounted at 3% a year, the middle of the 1.5% to 4.5% range from which the JR analysis draws its rate (BSZ give no rate): \[\begin{aligned} M &= T + E + L \times \frac{1 - 1.03^{-5}}{0.03} \\ &= 103.82 + 3.68 - 0.153 \times 4.58 \\ &= 106.80. \end{aligned}\] It is not the authors’ choice (see the introduction): BSZ’s headline is \(T\) alone. The explorer shows \(M\) for every setting of the guesses.
Missing billionaires scenario (Table 5, row 2)
Counting the billionaires Forbes misses raises the wealth tax to $127.9 billion.
If we assume that the tail is Pareto with constant b=4.48 between $1 billion and $4.5 billion, then this means that there should be around 617 California billionaires (instead of the 249 listed by Forbes). (BSZ PDF p.25)
The paper’s 249 is the 15 April 2026 Forbes list, which the workbook’s Pareto sheet uses. The base here is the 1 July list of 250.
Above $4.5 billion, average wealth over any threshold is a steady multiple of the threshold, \(b\) = 4.48: the signature of a Pareto distribution. Below $4.5 billion the Forbes list’s \(b\) climbs towards 9, which is what a list missing many small fortunes looks like. Holding \(b\) at 4.48 down to $1 billion adds 368 billionaires and $615 billion to the 15 April 2026 list the workbook used, that is 148% more people but only 28.2% more wealth. The scenario applies those two percentages to the 1 July base, with a higher avoidance rate \(\alpha_s\) = 20% for these less visible fortunes, and subtracts a deduction for those in the $1 to $1.1 billion band, where the Act phases the rate in: \[\begin{aligned} T_2 &= \tau\left[(1-\alpha)W_0 + (1-\alpha_s)\,\Delta W\right] \\ &\quad {}- 0.025\,f_{ph}\,\Delta W, \\ \Delta W &= 28.2\% \times W_0, \end{aligned}\] with \(f_{ph}\) = 12.1% the share of the added wealth in the phase-in band. The 0.025 is the average rate forgone in that band: the Act’s rate rises from 0 at $1 billion to 5% at $1.1 billion, so it is half of 5% on average. The result is $127.9 billion.
The figure compares the two distributions. Each bar is a share of the 617 billionaires a Pareto tail puts above $1 billion. On the left, solid violet is what Forbes lists and pale violet is what it misses. On the right is the Pareto tail itself, the shape top wealth usually takes in wealth and tax data.
R code for the Forbes vs Pareto figure
pm <- tr("pareto_missing_r")
cuts <- seq(1, 5, by = 0.5)
band <- function(n) { v <- n[match(cuts, pm$threshold_b)]; head(v, -1) - tail(v, -1) }
labs_b <- paste0("$", head(cuts, -1), "-", tail(cuts, -1))
fb <- band(pm$n_above_threshold_emp) / par$total_count_proj
pb <- band(pm$n_above_threshold_proj) / par$total_count_proj
d <- rbind(
data.frame(panel = "What the Forbes list shows", b = labs_b, part = "listed by Forbes", y = fb),
data.frame(panel = "What the Forbes list shows", b = labs_b, part = "missing", y = pmax(pb - fb, 0)),
data.frame(panel = "What wealth data usually show", b = labs_b, part = "Pareto tail", y = pb))
d$b <- factor(d$b, levels = labs_b)
d$part <- factor(d$part, levels = c("missing", "listed by Forbes", "Pareto tail"))
ggplot(d, aes(b, y, fill = part)) +
geom_col(width = 0.8) + facet_wrap(~panel) +
scale_fill_manual(values = c("missing" = "#6a2f8640", "listed by Forbes" = "#6a2f86",
"Pareto tail" = "#9a9a9a"), name = NULL) +
scale_y_continuous(labels = function(x) paste0(round(100 * x), "%")) +
labs(x = "wealth band, $ billion", y = "share of billionaires") +
theme_minimal(base_size = 13) +
theme(legend.position = "bottom", panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(), axis.text.x = element_text(size = 9))The paper’s own figure plots the ratio \(b\) at each cutoff (Appendix Figure A4):
R or Python code for Appendix Figure A4
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig_a4 <- build_fig_a4(pareto_missing_r)
# reading these targets:
pareto_missing_r <- compute_pareto_missing(pareto_missing)
build_fig_a4 <- function(pareto_missing_r) {
# Appendix Figure A4: Pareto extrapolation for missing CA billionaires.
# Panel A: Pareto b (empirical vs projected) at each wealth threshold.
# Panel B: density (empirical vs projected) at each wealth threshold.
d <- pareto_missing_r
n <- nrow(d)
panel_a <- tibble::tibble(
threshold = rep(d$threshold_b, 2),
pareto_b = c(d$pareto_b_emp, d$pareto_b_proj),
series = factor(rep(c("Pareto b (Forbes data)", "Pareto b (projected)"),
each = n),
levels = c("Pareto b (Forbes data)", "Pareto b (projected)"))
)
panel_b <- tibble::tibble(
threshold = rep(d$threshold_b, 2),
density = c(d$actual_density, d$projected_density),
series = factor(rep(c("Density (Forbes)", "Density (Pareto-projected)"),
each = n),
levels = c("Density (Forbes)", "Density (Pareto-projected)"))
)
colors_a <- c("Pareto b (Forbes data)" = "#d62728",
"Pareto b (projected)" = "#1f77b4")
colors_b <- c("Density (Forbes)" = "#d62728",
"Density (Pareto-projected)" = "#1f77b4")
pa <- ggplot2::ggplot(panel_a,
ggplot2::aes(x = threshold, y = pareto_b, color = series,
shape = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2) +
ggplot2::labs(title = "A. Pareto coefficient b (actual and projected)",
x = "Wealth threshold ($B)", y = "Pareto b", color = NULL, shape = NULL) +
ggplot2::scale_color_manual(values = colors_a) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
pb <- ggplot2::ggplot(panel_b,
ggplot2::aes(x = threshold, y = density, color = series,
shape = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2) +
ggplot2::labs(title = "B. Density (Forbes vs. adding missing)",
x = "Wealth threshold ($B)", y = "# billionaires in bracket",
color = NULL, shape = NULL) +
ggplot2::scale_color_manual(values = colors_b) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
patchwork::wrap_plots(pa, pb, ncol = 2) +
patchwork::plot_annotation(
title = "Appendix Figure A4: Pareto Extrapolation for Missing CA Billionaires"
)
}
# Read-only display. Edit R/figures.R:6-63.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_pareto_missing()
def build_fig_a4(pareto_missing_r):
d = pareto_missing_r
a = _long(d["threshold_b"], "threshold", [d["pareto_b_emp"], d["pareto_b_proj"]], "pareto_b",
["Pareto b (Forbes data)", "Pareto b (projected)"], "series")
b = _long(d["threshold_b"], "threshold", [d["actual_density"], d["projected_density"]], "density",
["Density (Forbes)", "Density (Pareto-projected)"], "series")
return [a, b]
# Read-only display. Edit py/figures.py:30-36.Leavers scenario (Table 5, row 3)
If four billionaires had left before 1 January 2026, the wealth tax would fall to $88.8 billion.
The third row in Table 5 scores the wealth tax assuming that Page, Thiel, Hankey, Kalanick successfully moved before January 1, 2026 which is an aggressive assumption. (BSZ PDF p.26)
Two groups are treated differently. Four people are assumed to have left before 1 January 2026, so they escape the tax altogether and stop paying income tax. Three more (Brin, Zuckerberg, Fang) pay the tax but are assumed to leave later, so only their income tax is lost. For the top three, lost income tax on company wealth is their SEC-based average from Table 3; on everything else it is proportional to wealth, at the rate billionaires pay outside the top four’s company wealth, 0.201% of wealth a year (Tab5!F18/B18):
R code for the leavers table
lv <- inp$leavers
lv$name <- tools::toTitleCase(lv$name)
lv$wealth <- bn(lv$wealth)
lv$annual_inctax_loss <- bn(lv$annual_inctax_loss, 3)
names(lv) <- c("Person", "Assumed timing", "Wealth, $B", "Income tax lost, $B/yr")
show(lv)| Person | Assumed timing | Wealth, $B | Income tax lost, $B/yr |
|---|---|---|---|
| Page | pre-2026 (escapes the wealth tax) | 294.6 | 0.103 |
| Thiel | pre-2026 (escapes the wealth tax) | 27.3 | 0.055 |
| Hankey | pre-2026 (escapes the wealth tax) | 8.2 | 0.016 |
| Kalanick | pre-2026 (escapes the wealth tax) | 3.6 | 0.007 |
| Brin | post-2026 (pays it, then leaves) | 271.7 | 0.078 |
| Zuckerberg | post-2026 (pays it, then leaves) | 210.3 | 0.100 |
| Fang | post-2026 (pays it, then leaves) | 1.5 | 0.003 |
Taxable wealth falls to \(0.9\,(W_0 - W_{pre})\), where \(W_{pre}\) = $333.6 billion is the wealth of the four assumed to have left before 1 January 2026, and revenue to $88.8 billion. The yearly income tax loss grows to $0.52 billion, still small, because the SEC data show the top three pay little income tax relative to their wealth. Whether any of these people has actually changed legal residence is a legal judgment, and one of the inputs the two analyses dispute (comparison/DISPUTES.md, row 3).
Missing + leavers scenario (Table 5, row 4)
This scenario adds the missing billionaires to the leavers scenario: $114.8 billion, with a yearly income tax loss of $0.56 billion. One detail: it does not subtract the phase-in deduction that the missing billionaires scenario subtracts for the same added billionaires (Tab5!F9 against Tab5!F7). This OPA reproduces the workbook as it is. A consistent treatment would lower it by $2.0 billion. This OPA lists this under What reproduces.
Table 5, reproduced
R or Python code for Table 5
# How the pipeline builds this result (bsz-analysis/_targets.R):
tab5_gt <- build_tab5(tab5_r)
# reading these targets:
tab5_r <- compute_tab5(pareto_missing_r, tab2, tab3)
build_tab5 <- function(tab5_r) {
# Table 5: Scoring the One-Time 5% CA Wealth Tax.
# 4 scenarios × 7 metric columns. tab5_r already verified element-wise
# against the Excel sheet by the upstream R re-derivation.
long_labels <- c(
"1. Benchmark: Forbes estimates as of 7/1/2026 + 10% evasion/avoidance (half due to mobility)",
"2. Adding missing small billionaires (Pareto extrapolation)",
"3. Aggressive assumptions for pre/post-2026 leavers",
"4. Benchmark with both adding small billionaires and aggressive leavers"
)
panel <- tab5_r
panel$scenario <- long_labels
tab <- gt::gt(panel) |>
gt::tab_header(title = "Table 5. Scoring the One-Time 5% California Wealth Tax") |>
gt::cols_label(
scenario = "",
n_billionaires = "# CA billionaires",
wealth = "Wealth ($B)",
taxable_wealth = "Taxable wealth ($B)",
avoidance_rate = "Avoidance rate",
wealth_tax_revenue = "Wealth tax revenue ($B)",
extra_ca_inctax_sales = "Extra CA inctax from sales ($B)",
annual_ca_inctax_loss = "Annual temporary CA income tax loss from leavers ($B)" # BSZ's header, PDF p.39
) |>
# Decimals as the paper prints Table 5 (PDF p.39): whole counts, the loss to the cent
# of a billion, one decimal for the rest.
gt::fmt_number(columns = n_billionaires, decimals = 0, use_seps = TRUE) |>
gt::fmt_number(columns = c(wealth, taxable_wealth,
wealth_tax_revenue, extra_ca_inctax_sales),
decimals = 1, use_seps = TRUE) |>
gt::fmt_number(columns = annual_ca_inctax_loss, decimals = 2) |>
gt::fmt_percent(columns = avoidance_rate, decimals = 1) |>
gt::tab_source_note(source_note = gt::md(paste(
"**Notes:** Scenarios assume a one-time 5% wealth tax. Wealth tax revenue =",
"taxable_wealth × 5%. Extra CA income tax from sales reflects forced",
"asset sales to pay the tax (33% realization × 80% LTCG-taxable × 13.3%",
"CA rate). The income tax loss \"is annual but temporary if the wealth tax",
"is one-time\" (BSZ Table 5 note, PDF p.39). Row 1's label is BSZ's; rows 2 to 4 are shortened.",
"Source: `tab5_r`."
)))
attr(tab, "panel") <- panel
tab
}
# Read-only display. Edit R/tables.R:291-336.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_tab5()
def build_tab5(tab5_r):
panel = tab5_r.copy()
panel["scenario"] = TAB5_LONG_LABELS
return {"panel": panel}
# Read-only display. Edit py/tables.py:148-151.| Table 5. Scoring the One-Time 5% California Wealth Tax | |||||||
| # CA billionaires | Wealth ($B) | Taxable wealth ($B) | Avoidance rate | Wealth tax revenue ($B) | Extra CA inctax from sales ($B) | Annual temporary CA income tax loss from leavers ($B) | |
|---|---|---|---|---|---|---|---|
| 1. Benchmark: Forbes estimates as of 7/1/2026 + 10% evasion/avoidance (half due to mobility) | 250 | 2,307.0 | 2,076.3 | 10.0% | 103.8 | 3.7 | −0.15 |
| 2. Adding missing small billionaires (Pareto extrapolation) | 620 | 2,957.4 | 2,596.6 | 12.2% | 127.9 | 4.5 | −0.20 |
| 3. Aggressive assumptions for pre/post-2026 leavers | 250 | 2,307.0 | 1,776.1 | 23.0% | 88.8 | 3.1 | −0.52 |
| 4. Benchmark with both adding small billionaires and aggressive leavers | 620 | 2,957.4 | 2,296.3 | 22.4% | 114.8 | 4.1 | −0.56 |
Notes: Scenarios assume a one-time 5% wealth tax. Wealth tax revenue = taxable_wealth × 5%. Extra CA income tax from sales reflects forced asset sales to pay the tax (33% realization × 80% LTCG-taxable × 13.3% CA rate). The income tax loss “is annual but temporary if the wealth tax is one-time” (BSZ Table 5 note, PDF p.39). Row 1’s label is BSZ’s; rows 2 to 4 are shortened. Source: tab5_r. |
|||||||
R code for the Table 5 check against the workbook
xl <- read_sheet("Tab5", range = "B6:H9")
cols <- c("n_billionaires", "wealth", "taxable_wealth", "avoidance_rate",
"wealth_tax_revenue", "extra_ca_inctax_sales", "annual_ca_inctax_loss")
dev <- abs(as.matrix(t5[, cols]) - as.matrix(xl))
t5_cells <- length(dev); t5_maxdev <- max(dev)All 28 cells match the workbook’s cached values; the largest absolute difference is 1.4e-16.
R code for the Table 5 check against the paper
pr <- tr("site_tab5_printed")
miss <- pr[!pr$matches_printed, ]
miss_txt <- paste0("row ", miss$row, " (printed ", formatC(miss$printed, format = "f", digits = 2),
", workbook ", formatC(miss$reproduced, format = "f", digits = 3), ")")Matching the printed table is a stricter test, because the paper rounds. Of the 28 printed cells, 26 are what the reproduced value rounds to. The other 2 are both in the income tax loss column: row 2 (printed -0.19, workbook -0.196) and row 3 (printed -0.51, workbook -0.515). The workbook’s own cached values agree with this OPA’s, and the paper’s text quotes the printed figures ($0.19 billion on PDF p.25, $0.51 billion on p.26). Each printed figure is one unit lower in the last digit than the workbook’s value rounded, less than a cent of a billion a year. Row 4 of the same column is rounded, and the paper does not say why these two differ (note 1 under Reproduction notes). No other printed number is built on them.
What the tax collects, and what it costs
This table puts the four scenarios side by side: the wealth tax, the extra income tax, the income tax lost (present value, 5 years at 3%) and the total.
R code for the scenario totals
tot <- t5$wealth_tax_revenue + t5$extra_ca_inctax_sales + t5$annual_ca_inctax_loss * pvf
bl <- data.frame(
Scenario = c("Base (main estimate)", "Missing billionaires", "Leavers", "Missing + leavers"),
`Wealth tax (once)` = paste0("$", bn(t5$wealth_tax_revenue), "B"),
`Extra income tax (once)` = paste0("+$", bn(t5$extra_ca_inctax_sales, 2), "B"),
`Income tax lost (present value, 5 years at 3%)` = paste0("-$", bn(-t5$annual_ca_inctax_loss * pvf, 2), "B"),
Total = paste0("$", bn(tot), "B"), check.names = FALSE)
stopifnot(abs(tot[1] - main1) < 1e-12)
show(bl)| Scenario | Wealth tax (once) | Extra income tax (once) | Income tax lost (present value, 5 years at 3%) | Total |
|---|---|---|---|---|
| Base (main estimate) | $103.8B | +$3.68B | -$0.70B | $106.8B |
| Missing billionaires | $127.9B | +$4.53B | -$0.90B | $131.5B |
| Leavers | $88.8B | +$3.15B | -$2.36B | $89.6B |
| Missing + leavers | $114.8B | +$4.07B | -$2.56B | $116.3B |
The total of the base scenario is the main estimate, $106.8 billion. It is not the authors’ choice: see the introduction.
How far the guesses move the result
The explorer holds every combination of the six assumptions. Here each one moves on its own, with the others at the base scenario. The rows are sorted by how far each moves the main estimate, widest range first, as in the slide deck’s chart:
R code for the guesswork ranges
dials <- site_dials()
keys <- vapply(dials, `[[`, "", "key")
base <- stats::setNames(mapply(function(d, j) d$levels[j], dials, site_named_rows()[[1]]), keys)
lvl <- function(d, v) switch(d$fmt, pct = paste0(round(100 * v), "%"),
bool = if (v == 1) "yes" else "no",
frac3 = c("none", "1/3", "2/3", "all")[round(3 * v) + 1])
alone <- function(key) { # grid cells where only `key` differs from the base scenario
Reduce(`&`, lapply(setdiff(keys, key), function(k) abs(grid[[k]] - base[[k]]) < 1e-9))
}
rng <- do.call(rbind, lapply(dials, function(d) {
g <- grid[alone(d$key), ]
data.frame(
input = d$label, origin = d$origin,
tried = paste(lvl(d, min(d$levels)), "to", lvl(d, max(d$levels))),
`main estimate, $B` = paste(bn(min(g$main_estimate)), "to", bn(max(g$main_estimate))),
`wealth tax, $B` = paste(bn(min(g$wealth_tax_revenue)), "to", bn(max(g$wealth_tax_revenue))),
`income tax lost, $B/yr` = paste(bn(-max(g$annual_ca_inctax_loss), 2), "to",
bn(-min(g$annual_ca_inctax_loss), 2)),
width = diff(range(g$main_estimate)),
check.names = FALSE)
}))
# Widest range of the main estimate first, as the deck's tornado chart sorts them.
rng <- rng[order(-rng$width), names(rng) != "width"]
rownames(rng) <- NULL
show(rng)| input | origin | tried | main estimate, $B | wealth tax, $B | income tax lost, $B/yr |
|---|---|---|---|---|---|
| Avoidance and evasion | guesswork | 0% to 30% | 81.5 to 119.4 | 80.7 to 115.4 | 0.00 to 0.46 |
| Count billionaires Forbes misses | research | no to yes | 106.8 to 131.5 | 103.8 to 127.9 | 0.15 to 0.20 |
| Aggressive assumption on leavers | guesswork | no to yes | 89.6 to 106.8 | 88.8 to 103.8 | 0.15 to 0.52 |
| Share of the tax paid by selling assets | guesswork | none to all | 103.1 to 114.2 | 103.8 to 103.8 | 0.15 to 0.15 |
| Share of avoidance that is leaving | guesswork | 0% to 100% | 106.1 to 107.5 | 103.8 to 103.8 | 0.00 to 0.31 |
| Avoidance by the less visible billionaires | guesswork | 10% to 50% | 106.8 to 106.8 | 103.8 to 103.8 | 0.15 to 0.15 |
Two documents, one number
GGSS cite $100 billion. The check below confirms that it is BSZ’s row 1, rounded.
R code for the GGSS comparison
ggss_cited <- 100 # GGSS p.1 and p.4 (cited, not computed)
stopifnot(round(t5$wealth_tax_revenue[1]) == 104)GGSS start from the same 250 people and $2,307 billion, take 5% ($115 billion), apply the same 10% avoidance and get $104 billion, which they round to $100 billion. The expert report’s other figures also come from BSZ Table 5: the $128 billion with missing billionaires (row 2, reproduced as $127.9 billion) and “about $20 billion/year” (one fifth of the rounded total, the annual installment).
B. A permanent annual wealth tax: the Laffer curve
This part estimates a different policy, a wealth tax charged every year, which is not what Proposition 40 proposes. The paper uses it to answer the argument that billionaires will leave for fear of future taxes. The pipeline’s “Figure 8” is the paper’s Figure 9 (the paper’s Figure 8, on venture capital, is not built here).
With a semi-elasticity e, the wealth tax rate that maximizes tax revenue while taking into account mobility responses is τ=1/e. (BSZ PDF p.28)
What is computed. A semi-elasticity \(e\) says each extra percentage point of tax shrinks the base by about \(e\) percent through people moving away. With a base of \(Z_0\) = $2,000 billion at today’s tax (the workbook’s rounded input) and \(\tau_0\) = 0.2% of wealth, the income tax billionaires already pay, \[\begin{aligned}
Z(\tau) &= Z_0 \exp\{-e(\tau - \tau_0)\}, \\
T(\tau) &= \tau\, Z(\tau).
\end{aligned}\] Revenue peaks where the next point of tax loses as much base as it gains in rate, at \(\tau = 1/e\). With \(e\) = 10 (Brülhart et al. 2022, the calibration the JR analysis uses as a check, 10.32; its first version preferred 12 to 13, comparison/DISPUTES.md, row 4) that is 10%. The long-run curve also erodes wealth itself: \(d\) = 15 years of paying the tax shrinks fortunes by \((1-\tau)^d\), and revenue then peaks at 3.9%. The paper prints the first-order formula \(1/(1+d+e) = 1/26 = 3.8\%\), the optimum once \((1-\tau)^d\) is replaced by \(e^{-d\tau}\) (note 6 under Reproduction notes).
R or Python code for Figure 9
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig8 <- build_fig8(fig8_laffer_r)
# reading these targets:
fig8_laffer_r <- compute_fig8_laffer()
build_fig8 <- function(fig8_laffer_r) {
# Figure 8: Laffer Curve for a permanent annual CA wealth tax.
# Three lines: mechanical revenue (rate * base), actual revenue (with
# mobility response), long-run revenue (mobility + deconcentration).
d <- fig8_laffer_r
long <- tibble::tibble(
rate = rep(d$tax_rate, 3),
revenue = c(d$mechanical_tax_revenue, d$actual_tax_revenue, d$long_run_tax_revenue),
series = factor(rep(c("Mechanical (no behavior)",
"Short-run (with mobility)",
"Long-run (mobility + deconcentration)"),
each = nrow(d)),
levels = c("Mechanical (no behavior)",
"Short-run (with mobility)",
"Long-run (mobility + deconcentration)"))
)
ggplot2::ggplot(long, ggplot2::aes(x = rate, y = revenue,
color = series, linetype = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::scale_x_continuous(labels = scales::percent_format(accuracy = 1),
breaks = seq(0, 0.2, by = 0.05)) +
ggplot2::scale_y_continuous(
labels = function(v) format(v, big.mark = ",", scientific = FALSE),
limits = c(0, NA)
) +
ggplot2::scale_color_manual(values = c(
"Mechanical (no behavior)" = "#999999",
"Short-run (with mobility)" = "#1f77b4",
"Long-run (mobility + deconcentration)" = "#d62728"
)) +
ggplot2::scale_linetype_manual(values = c(
"Mechanical (no behavior)" = "dashed",
"Short-run (with mobility)" = "solid",
"Long-run (mobility + deconcentration)" = "solid"
)) +
ggplot2::labs(
title = "Figure 8: Laffer Curve for a Permanent California Wealth Tax",
subtitle = "Annual revenue ($B) vs. tax rate, under mobility (e=10) + deconcentration (d=15) responses",
x = "Annual wealth-tax rate",
y = "Annual revenue ($B)",
color = NULL, linetype = NULL
) +
ggplot2::theme_minimal(base_size = 12) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank()
)
}
# Read-only display. Edit R/figures.R:217-266.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_fig8_laffer()
def build_fig8(fig8_laffer_r):
d = fig8_laffer_r
return _long(d["tax_rate"], "rate",
[d["mechanical_tax_revenue"], d["actual_tax_revenue"], d["long_run_tax_revenue"]],
"revenue",
["Mechanical (no behavior)", "Short-run (with mobility)",
"Long-run (mobility + deconcentration)"], "series")
# Read-only display. Edit py/figures.py:79-85.Appendix exhibits
Appendix Table A1: US billionaires
Appendix Table A1 does for all US billionaires what Table 1 does for California: their number and wealth from 2022 to 2026, and their long-run growth since 1982.
R or Python code for Appendix Table A1
# How the pipeline builds this result (bsz-analysis/_targets.R):
tab_a1_gt <- build_tab_a1(shortrunseries, longrunseries)
# reading these targets:
shortrunseries <- extract_shortrunseries(xlsx_path)
longrunseries <- extract_longrunseries(xlsx_path)
build_tab_a1 <- function(shortrunseries, longrunseries, vintage = bsz_vintage()) {
# Appendix Table A1: Wealth Growth of US Billionaires (mirrors Tab1 but
# for the US). Panel A: recent nominal growth 2022-2025(+2026 in August).
# Panel B: 1982 vs 2025(2026 in August) long-term real growth of the top
# .0002% (May) / .001% (August) US families.
#
# Mirrors the same RC4/RC5/RC6 August changes as build_tab1() (see its
# comment): a new AGI column (August's Panel A gains F/G columns that did
# not exist at all in May), a new 2026 row, and the widened/rebased
# percentile in Panel B. All ported directly from both workbooks' cell
# formulas (openpyxl, data_only=False) - see VERIFY-AUGUST.md.
srs <- shortrunseries
lrs <- longrunseries
num <- function(df, col, rows) {
unname(vapply(rows, function(r) suppressWarnings(as.numeric(df[[col]][r])),
numeric(1)))
}
# ---- Panel A inputs (shortrunseries rows 10..13 = years 2022..2025, +14
# for 2026 in August) ------------------------------------------------------
years_a <- if (identical(vintage, "may")) 2022:2025 else 2022:2026
rows_a <- if (identical(vintage, "may")) 10:13 else 10:14
n_us_b <- num(srs, srs_col("n_us_citizen"), rows_a)
w_us_b <- num(srs, srs_col("Q_us_wealth"), rows_a)
panel_a <- tibble::tibble(
year = as.character(years_a),
n_us_billionaires = n_us_b,
wealth_b = w_us_b,
annual_growth = c(NA_real_, w_us_b[-1] / w_us_b[-length(w_us_b)] - 1)
)
if (!identical(vintage, "may")) {
# 2026's growth cell doubles the (partial-year) rate, same as Tab1.
n <- nrow(panel_a)
panel_a$annual_growth[n] <- 2 * (w_us_b[n] / w_us_b[n - 1] - 1)
# New AGI columns (August only; May's sheet has no US-AGI columns at all
# in Panel A). longrunseries!AV = "US TOTAL AGI", nominal, rows 48:52.
agi <- suppressWarnings(as.numeric(lrs$AV[48:52]))
panel_a$us_agi_b <- agi
panel_a$wealth_per_agi <- w_us_b / agi
}
# Growth row stays anchored to the 2022 -> 2025 window (indices 1 and 4)
# regardless of whether the 2026 row above extended the table.
growth_row <- tibble::tibble(
year = "Growth during 3 years (2023-2025)",
n_us_billionaires = NA_integer_,
wealth_b = panel_a$wealth_b[4] / panel_a$wealth_b[1] - 1,
annual_growth = NA_real_
)
if (!identical(vintage, "may")) {
growth_row$us_agi_b <- panel_a$us_agi_b[4] / panel_a$us_agi_b[1] - 1
growth_row$wealth_per_agi <- NA_real_
}
panel_a_full <- dplyr::bind_rows(panel_a, growth_row)
# ---- Panel B inputs (longrunseries rows 8 = 1982, 51 = 2025 / 52 = 2026) -
num_lr <- function(col, row) suppressWarnings(as.numeric(lrs[[col]][row]))
W8 <- num_lr("W", 8)
cur_row <- lrs_current_row(vintage)
W_cur <- num_lr("W", cur_row)
defl_1982 <- W8 / W_cur
if (identical(vintage, "may")) {
AM_1982 <- num_lr("AM", 8); AM_cur <- num_lr("AM", cur_row)
wealth_1982 <- num_lr("AP", 8) * defl_1982
wealth_cur <- num_lr("AP", cur_row) # already 2025$
agi_1982 <- num_lr("AS", 8) * defl_1982
agi_cur <- num_lr("AS", cur_row) # already 2025$
} else {
# Families: top .001% = 5x top .0002% (longrunseries!AM, US version of
# AL, unchanged column both vintages). Wealth: US GDP (longrunseries!G,
# nominal) x (US top .001%/GDP ratio, longrunseries!BQ) x deflator - the
# workbook's own Pareto-scaled wealth figure (no separate "already real"
# column exists for the US the way CA has BT). AGI: longrunseries!AV
# (nominal "US TOTAL AGI") x deflator.
AM_1982 <- num_lr("AM", 8) * 5; AM_cur <- num_lr("AM", cur_row) * 5
wealth_1982 <- num_lr("G", 8) * num_lr("BQ", 8) * defl_1982
wealth_cur <- num_lr("G", cur_row) * num_lr("BQ", cur_row) * (W_cur / W_cur)
agi_1982 <- num_lr("AV", 8) * defl_1982
agi_cur <- num_lr("AV", cur_row) * (W_cur / W_cur)
}
X_1982 <- num_lr("X", 8); X_cur <- num_lr("X", cur_row)
make_year_row <- function(label, AM, wealth_b, X, agi_b) {
n_fam_m <- X / 1000
tibble::tibble(
year = label,
families_top0002_k = AM,
wealth_top0002_b = wealth_b,
wealth_per_family_b = wealth_b / AM,
n_us_families_m = n_fam_m,
us_gdp_2025dollars_b = agi_b,
gdp_per_family_k = 1000 * agi_b / n_fam_m
)
}
cur_label <- if (identical(vintage, "may")) "2025" else "2026"
# The workbook annualizes this row over 43 years in every vintage
# (TabA1!B18:G18 is "=(B16/B15)^(1/43)-1" in August), and the paper prints
# those values. 1982 to 2026 is 44 years, which would print lower; the report
# discloses it. Table 1 uses 44 for August, as its sheet does.
n_years <- 43
row_1982 <- make_year_row("1982", AM_1982, wealth_1982, X_1982, agi_1982)
row_cur <- make_year_row(cur_label, AM_cur, wealth_cur, X_cur, agi_cur)
lr <- .long_run_compare(row_1982, row_cur, n_years = n_years)
panel_b <- dplyr::bind_rows(row_1982, row_cur, lr$ratio, lr$annualized)
# ---- Render with gt (display only) ---------------------------------------
# Nothing below changes a value: the panel_a / panel_b attributes are what the
# tests and snapshots read. A gt has one header row, so the column headers
# name Panel A's columns and Panel B's row-group label lists its own; each
# panel's rows get their own formats, as the paper prints TabA1 (PDF p.49).
may <- identical(vintage, "may")
denom_lbl <- if (may) "GDP" else "AGI" # May's sheet is GDP; August's is AGI
ratio_label <- paste0("Ratio ", cur_label, " to 1982")
sec_a <- "A. Recent nominal wealth growth of US billionaires"
panel_a_render <- tibble::tibble(
section = sec_a,
year = ifelse(panel_a_full$year == "2026", "2026 (July 1st)", panel_a_full$year),
col1 = panel_a_full$n_us_billionaires,
col2 = panel_a_full$wealth_b,
col3 = panel_a_full$annual_growth,
col4 = if (!may) panel_a_full$us_agi_b else NA_real_,
col5 = if (!may) panel_a_full$wealth_per_agi else NA_real_,
col6 = NA_real_
)
pctile_label <- if (may) "top .0002%" else "top .001%"
sec_b <- paste0("B. Long-term real wealth growth: ", pctile_label,
" wealthiest US families (", cur_label, " $). Columns: # families in the ",
pctile_label, ", their wealth ($B), wealth per family ($B), ",
"# US families (millions), US ", denom_lbl, " ($B), US ", denom_lbl,
" per family ($)")
panel_b_render <- tibble::tibble(
section = sec_b,
year = ifelse(grepl("^Ratio ", panel_b$year), ratio_label, panel_b$year),
col1 = panel_b$families_top0002_k,
col2 = panel_b$wealth_top0002_b,
col3 = panel_b$wealth_per_family_b,
col4 = panel_b$n_us_families_m,
col5 = panel_b$us_gdp_2025dollars_b,
col6 = panel_b$gdp_per_family_k
)
combined <- dplyr::bind_rows(panel_a_render, panel_b_render)
in_a <- combined$section == sec_a
r_a <- which(in_a & !grepl("^Growth", combined$year)) # Panel A's year rows
r_agr <- which(in_a & grepl("^Growth", combined$year)) # its 2023-2025 growth row
r_blev <- which(!in_a & combined$year %in% c("1982", cur_label))
r_brat <- which(!in_a & combined$year == ratio_label)
r_bann <- which(!in_a & combined$year == "Annualized growth")
tab <- gt::gt(combined, groupname_col = "section") |>
gt::tab_header(title = "Appendix Table A1. Wealth Growth of US Billionaires") |>
gt::cols_label(
year = "Year",
col1 = "#",
col2 = "Wealth",
col3 = "Growth",
col4 = if (may) "" else "US AGI",
col5 = if (may) "" else "Wealth / US AGI",
col6 = ""
) |>
# Panel A: counts and $B as whole numbers, rates as percentages; the growth
# row is a percentage in each column it fills.
gt::fmt_number(columns = c(col1, col2, col4), rows = r_a, decimals = 0, use_seps = TRUE) |>
gt::fmt_percent(columns = c(col3, col5), rows = r_a, decimals = 1) |>
gt::fmt_percent(columns = c(col2, col4), rows = r_agr, decimals = 1) |>
# Panel B: families, wealth ($B), wealth per family ($B), families (millions),
# income ($B), income per family ($); then the ratio and annualized rows.
gt::fmt_number(columns = c(col1, col5, col6), rows = r_blev, decimals = 0, use_seps = TRUE) |>
gt::fmt_number(columns = c(col2, col4), rows = r_blev, decimals = 1, use_seps = TRUE) |>
gt::fmt_number(columns = col3, rows = r_blev, decimals = 2) |>
gt::fmt_number(columns = c(col1, col2, col3, col4, col5), rows = r_brat, decimals = 1) |>
gt::fmt_number(columns = col6, rows = r_brat, decimals = 2) |>
gt::fmt_percent(columns = c(col1, col2, col3, col4, col5, col6), rows = r_bann, decimals = 1) |>
gt::sub_missing(missing_text = "–") |>
gt::tab_source_note(source_note = gt::md(paste0(
"**Notes:** Repeats Tab 1 for US billionaires instead of CA. Panel A: ",
"nominal wealth of all US citizen billionaires (Forbes). Panel B: 1982 ",
"vs ", cur_label, " real wealth of the ", pctile_label, " US families, in ",
cur_label, " dollars."
)))
attr(tab, "panel_a") <- panel_a_full
attr(tab, "panel_b") <- panel_b
tab
}
# Read-only display. Edit R/tables.R:105-289.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: extract_shortrunseries(), extract_longrunseries()
def build_tab_a1(shortrunseries, longrunseries, vintage=None):
vintage = vintage or bsz_vintage()
srs, lrs = shortrunseries, longrunseries
may = vintage == "may"
years_a = list(range(2022, 2026)) if may else list(range(2022, 2027))
rows_a = range(10, 14) if may else range(10, 15)
n_us_b = col_num(srs, srs_col("n_us_citizen", vintage), rows_a)
w_us_b = col_num(srs, srs_col("Q_us_wealth", vintage), rows_a)
panel_a = pd.DataFrame({
"year": [str(y) for y in years_a],
"n_us_billionaires": n_us_b,
"wealth_b": w_us_b,
"annual_growth": np.concatenate([[NA], w_us_b[1:] / w_us_b[:-1] - 1]),
})
if not may:
n = len(panel_a)
panel_a.loc[n - 1, "annual_growth"] = 2 * (w_us_b[n - 1] / w_us_b[n - 2] - 1)
agi = col_num(lrs, "AV", range(48, 53))
panel_a["us_agi_b"] = agi
panel_a["wealth_per_agi"] = w_us_b / agi
growth_row = {
"year": "Growth during 3 years (2023-2025)",
"n_us_billionaires": NA,
"wealth_b": panel_a["wealth_b"].iloc[3] / panel_a["wealth_b"].iloc[0] - 1,
"annual_growth": NA,
}
if not may:
growth_row["us_agi_b"] = panel_a["us_agi_b"].iloc[3] / panel_a["us_agi_b"].iloc[0] - 1
growth_row["wealth_per_agi"] = NA
panel_a_full = pd.concat([panel_a, pd.DataFrame([growth_row])], ignore_index=True)
def num_lr(col, row):
return xls_cell(lrs, f"{col}{row}")
W8 = num_lr("W", 8)
cur = lrs_current_row(vintage)
W_cur = num_lr("W", cur)
defl_1982 = W8 / W_cur
if may:
AM_1982, AM_cur = num_lr("AM", 8), num_lr("AM", cur)
wealth_1982 = num_lr("AP", 8) * defl_1982
wealth_cur = num_lr("AP", cur)
agi_1982 = num_lr("AS", 8) * defl_1982
agi_cur = num_lr("AS", cur)
else:
AM_1982, AM_cur = num_lr("AM", 8) * 5, num_lr("AM", cur) * 5
wealth_1982 = num_lr("G", 8) * num_lr("BQ", 8) * defl_1982
wealth_cur = num_lr("G", cur) * num_lr("BQ", cur) * (W_cur / W_cur)
agi_1982 = num_lr("AV", 8) * defl_1982
agi_cur = num_lr("AV", cur) * (W_cur / W_cur)
X_1982, X_cur = num_lr("X", 8), num_lr("X", cur)
def make_year_row(label, AM, wealth_b, X, agi_b):
n_fam_m = X / 1000
return {"year": label, "families_top0002_k": AM, "wealth_top0002_b": wealth_b,
"wealth_per_family_b": wealth_b / AM, "n_us_families_m": n_fam_m,
"us_gdp_2025dollars_b": agi_b, "gdp_per_family_k": 1000 * agi_b / n_fam_m}
cur_label = "2025" if may else "2026"
n_years = 43 # TabA1!B18:G18 annualizes over 43 years in every vintage (R: build_tab_a1)
r82 = make_year_row("1982", AM_1982, wealth_1982, X_1982, agi_1982)
rcur = make_year_row(cur_label, AM_cur, wealth_cur, X_cur, agi_cur)
ratio, ann = _long_run_compare(r82, rcur, n_years)
panel_b = pd.DataFrame([r82, rcur, ratio, ann])
return {"panel_a": panel_a_full, "panel_b": panel_b}
# Read-only display. Edit py/tables.py:71-137.| Appendix Table A1. Wealth Growth of US Billionaires | ||||||
| Year | # | Wealth | Growth | US AGI | Wealth / US AGI | |
|---|---|---|---|---|---|---|
| A. Recent nominal wealth growth of US billionaires | ||||||
| 2022 | 717 | 4,358 | – | 14,752 | 29.5% | – |
| 2023 | 746 | 5,247 | 20.4% | 15,128 | 34.7% | – |
| 2024 | 813 | 6,723 | 28.1% | 16,457 | 40.9% | – |
| 2025 | 938 | 8,189 | 21.8% | 17,280 | 47.4% | – |
| 2026 (July 1st) | 982 | 8,826 | 15.6% | 18,144 | 48.6% | – |
| Growth during 3 years (2023-2025) | – | 87.9% | – | 17.1% | – | – |
| B. Long-term real wealth growth: top .001% wealthiest US families (2026 $). Columns: # families in the top .001%, their wealth ($B), wealth per family ($B), # US families (millions), US AGI ($B), US AGI per family ($) | ||||||
| 1982 | 1,105 | 458.9 | 0.42 | 110.5 | 6,127 | 55,437 |
| 2026 | 1,950 | 10,569.6 | 5.42 | 195.0 | 18,144 | 93,050 |
| Ratio 2026 to 1982 | 1.8 | 23.0 | 13.1 | 1.8 | 3.0 | 1.68 |
| Annualized growth | 1.3% | 7.6% | 6.2% | 1.3% | 2.6% | 1.2% |
| Notes: Repeats Tab 1 for US billionaires instead of CA. Panel A: nominal wealth of all US citizen billionaires (Forbes). Panel B: 1982 vs 2026 real wealth of the top .001% US families, in 2026 dollars. | ||||||
R code for the annualized row against the workbook
a1_pb <- attr(tr("tab_a1_gt"), "panel_b")
a1_ours <- unlist(a1_pb[a1_pb$year == "Annualized growth", -1]) # this reproduction, 43 years
a1_ratio <- unlist(a1_pb[grepl("^Ratio", a1_pb$year), -1]) # 2026 over 1982
a1_44 <- a1_ratio^(1 / 44) - 1 # the same growth over 44 years
a1_xl <- unlist(read_sheet("TabA1", range = "B18:G18")) # workbook cache, 43 years
stopifnot(length(a1_ours) == 6, length(a1_xl) == 6, max(abs(a1_ours - a1_xl)) < 1e-6)
a1_pct <- function(x) paste0(formatC(100 * x, format = "f", digits = 1), "%") # as the table prints it
a1_diff <- a1_pct(a1_44) != a1_pct(a1_ours)
and_join <- function(x) if (length(x) < 2) x else
paste(paste(head(x, -1), collapse = ", "), tail(x, 1), sep = " and ")The paper and the workbook (TabA1!B18:G18) annualize this growth over 43 years, and so does this reproduction, so the row matches the paper. 1982 to 2026 is 44 years, which would give 7.4%, 6.0% and 2.5% where the paper prints 7.6%, 6.2% and 2.6%.
Appendix Figures A2 and A3
Appendix Figure A2 shows the California income tax billionaires pay as a share of all California personal income tax, 2019 to 2025. Appendix Figure A3 shows their total taxes as a share of their public stock wealth and of the economic income on it.
R or Python code for Appendix Figure A2
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig_a2 <- build_fig_a2(shortrunseries_r)
# reading these targets:
shortrunseries_r <- compute_shortrunseries(data_sec_agg_r, data_sec_top4, billionaires_ca_inctax_r, shortrunseries)
build_fig_a2 <- function(shortrunseries_r) {
# Appendix Figure A2: CA Income Tax paid by Billionaires as % of total CA
# income tax revenue. Two-series line, 2019-2025: all CA billionaires + top 5.
panel <- shortrunseries_r$panel
d <- panel[panel$year %in% 2019:2025, ]
n <- nrow(d)
long <- tibble::tibble(
year = rep(d$year, 2),
share = c(d$ca_inctax_share_total, d$top5_sec_share_of_total),
series = factor(rep(c("All CA billionaires", "Top 5 (SEC filings)"), each = n),
levels = c("All CA billionaires", "Top 5 (SEC filings)"))
)
ggplot2::ggplot(long, ggplot2::aes(x = year, y = share, color = series,
shape = series)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2.5) +
ggplot2::scale_x_continuous(breaks = 2019:2025) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1),
limits = c(0, NA)) +
ggplot2::scale_color_manual(values = c(
"All CA billionaires" = "#d62728", "Top 5 (SEC filings)" = "#1f77b4"
)) +
ggplot2::labs(
title = "Appendix Figure A2: CA Income Tax Paid by Billionaires",
subtitle = "As % of total CA personal income tax revenue, 2019-2025",
x = NULL, y = NULL, color = NULL, shape = NULL
) +
ggplot2::theme_minimal(base_size = 12) +
ggplot2::theme(
legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor.x = ggplot2::element_blank()
)
}
# Read-only display. Edit R/figures.R:130-165.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_shortrunseries()
def build_fig_a2(shortrunseries_r):
p = shortrunseries_r["panel"]
d = p[p["year"].isin(range(2019, 2026))]
return _long(d["year"], "year", [d["ca_inctax_share_total"], d["top5_sec_share_of_total"]], "share",
["All CA billionaires", "Top 5 (SEC filings)"], "series")
# Read-only display. Edit py/figures.py:52-56.R or Python code for Appendix Figure A3
# How the pipeline builds this result (bsz-analysis/_targets.R):
fig_a3 <- build_fig_a3(billionaires_ca_inctax_r)
# reading these targets:
billionaires_ca_inctax_r <- compute_billionaires_ca_inctax(data_sec_agg_r, billionaires_ca_inctax, ftb_b4a_sql)
build_fig_a3 <- function(billionaires_ca_inctax_r) {
# Appendix Figure A3: Total Taxes Paid by CA Billionaires on their Public
# Stock Wealth. Two area panels (% of public-asset wealth; % of public-asset
# economic income), each stacking CA inctax + Fed inctax + Corp + Property/sales.
at <- billionaires_ca_inctax_r$all_taxes
d <- at[at$year %in% 2019:2025, ]
n <- nrow(d)
# Tax labels in the natural reading order; factor levels listed in
# TOP-TO-BOTTOM stack order so geom_area places CA inctax at the bottom
# (matches the original BSZ chart).
labels <- c("CA income tax", "Federal income tax",
"Corporate taxes", "Property + sales taxes")
stack_levels <- rev(labels)
panel_w <- tibble::tibble(
year = rep(d$year, 4),
share = c(d$ca_inctax_per_public_wealth,
d$fed_inctax_per_public_wealth,
d$corp_per_public_wealth,
d$prop_sales_per_public_wealth),
tax = factor(rep(labels, each = n), levels = stack_levels)
)
panel_ei <- tibble::tibble(
year = rep(d$year, 4),
share = c(d$ca_inctax_per_econ_income,
d$fed_inctax_per_econ_income,
d$corp_per_econ_income,
d$prop_sales_per_econ_income),
tax = factor(rep(labels, each = n), levels = stack_levels)
)
fills <- c("CA income tax" = "#4a1414",
"Federal income tax" = "#8a2c2c",
"Corporate taxes" = "#c47878",
"Property + sales taxes" = "#f0d4d4")
pw <- ggplot2::ggplot(panel_w, ggplot2::aes(x = year, y = share, fill = tax)) +
ggplot2::geom_area(alpha = 0.85, color = "white", linewidth = 0.3) +
ggplot2::scale_x_continuous(breaks = 2019:2025) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
ggplot2::scale_fill_manual(values = fills) +
ggplot2::labs(title = "A. Total taxes as % of public-asset wealth",
x = NULL, y = NULL, fill = NULL) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
pei <- ggplot2::ggplot(panel_ei, ggplot2::aes(x = year, y = share, fill = tax)) +
ggplot2::geom_area(alpha = 0.85, color = "white", linewidth = 0.3) +
ggplot2::scale_x_continuous(breaks = 2019:2025) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
ggplot2::scale_fill_manual(values = fills) +
ggplot2::labs(title = "B. Total taxes as % of economic income on public assets",
x = NULL, y = NULL, fill = NULL) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(legend.position = "bottom",
plot.title = ggplot2::element_text(face = "bold"),
panel.grid.minor = ggplot2::element_blank())
patchwork::wrap_plots(pw, pei, ncol = 2) +
patchwork::plot_annotation(
title = "Appendix Figure A3: Total Taxes on CA Billionaire Public Stock Wealth"
)
}
# Read-only display. Edit R/figures.R:65-128.# The Python twin (bsz-analysis/py/); py/run_export.py runs it.
# reads the output of: compute_billionaires_ca_inctax()
def build_fig_a3(billionaires_ca_inctax_r):
at = billionaires_ca_inctax_r["all_taxes"]
d = at[at["year"].isin(range(2019, 2026))]
labels = ["CA income tax", "Federal income tax", "Corporate taxes", "Property + sales taxes"]
pw = _long(d["year"], "year",
[d["ca_inctax_per_public_wealth"], d["fed_inctax_per_public_wealth"],
d["corp_per_public_wealth"], d["prop_sales_per_public_wealth"]], "share", labels, "tax")
pei = _long(d["year"], "year",
[d["ca_inctax_per_econ_income"], d["fed_inctax_per_econ_income"],
d["corp_per_econ_income"], d["prop_sales_per_econ_income"]], "share", labels, "tax")
return [pw, pei]
# Read-only display. Edit py/figures.py:39-49.Every input, by origin
This section is not in the paper. It sorts every input of the one-time estimate, and the two the main estimate adds (the years counted and the rate), by how much evidence stands behind each. That is the first question a reader who wants to argue with the result has to answer.
- data: read off a public source, and in principle re-collectable from it.
- research: an estimate from a published study, including the authors’ own.
- guesswork: set by assumption; argued for, but not measured.
- scenario: a choice about which policy, or which world, is being estimated.
- derived: implied by other rows. convention: a date.
R code for the input table
show(ins)| symbol | input | value | origin | basis |
|---|---|---|---|---|
| τ | Wealth tax rate | 5% one-time | data | The Act as written: 5% (GGSS p.1), payable at once with the 2026 income tax or in five yearly installments with a 7.5% deferral charge (§50301(c)). |
| W₀ | Wealth of CA billionaires, 1 Jul 2026 | $2,307 B | data | Forbes real-time list, 7/1/2026 (Tab5!C6; BSZ PDF p.24). |
| n₀ | Number of CA billionaires, 1 Jul 2026 | 250 | data | Forbes real-time list, 7/1/2026 (Tab5!B6). |
| α | Avoidance and evasion | 10% | guesswork | Set by the authors, argued from enforcement (GGSS p.4; BSZ PDF p.23). Explorer dial. |
| m | Share of avoidance that is leaving | 50% | guesswork | Set by the authors (BSZ PDF p.24; Tab5 row 1 label). Explorer dial. |
| αₛ | Avoidance, less visible billionaires | 20% | guesswork | Set by the authors (BSZ PDF p.25). Explorer dial. |
| s | Share of the tax paid by selling assets | 1/3 | guesswork | Set by the authors (BSZ PDF p.24). Explorer dial. |
| L | Who left before 1 Jan 2026 (Table 5 rows 3 and 4) | no named leaver (row 1); the 5% who move are part of α | guesswork | A legal judgment on residency; rows 3-4 assume Page, Thiel, Hankey, Kalanick (BSZ PDF p.26). Explorer dial. |
| g | Capital-gain share of a sale | 80% | research | BSZ's own estimate that CA billionaire wealth is 80% unrealized gains (PDF p.24). |
| t_cg | Top CA income tax rate on gains | 13.3% | data | California statute, top marginal rate (Tab5!G6). |
| C | CA income tax paid by billionaires, 2019-2025 average | $3.06 B/yr | research | BSZ's own FTB + Pareto estimate (Tab2!C14; Table 2 of the paper), reproduced in this pipeline. |
| b | Pareto coefficient above $4.5B | 4.48 | research | Estimated from Forbes 4/15/2026 (Pareto-missing sheet; BSZ PDF p.25). |
| ΔW_P | Wealth added by the Pareto extrapolation | +28.2% | research | Pareto-missing!I23, derived from b. |
| Δn_P | Billionaires added by the Pareto extrapolation | +148% | research | Pareto-missing!J23, derived from b. |
| f_ph | Share of added wealth in the $1B-1.1B phase-in | 12.1% | derived | Pareto-missing!I24, derived from b. |
| W_pre | Wealth of pre-2026 leavers (Page, Thiel, Hankey, Kalanick) | $333.6 B | data | Forbes 7/1/2026 values typed into Tab5!B20:B23. |
| L_lv | Annual CA income tax of all seven named leavers | $0.36 B/yr | derived | Top-3 company income tax from SEC data (Tab3 row 13) plus a wealth-proportional rate on the rest (Tab5!F19+F24). |
| date | Valuation date | 1 Jul 2026 | convention | The Act values wealth at 31 Dec 2026; BSZ and GGSS estimate on the latest Forbes snapshot. |
| H | Years the yearly income tax loss is counted (main estimate only) | 5 years | scenario | This OPA's reading of BSZ's example ('If they last say only 5 years', PDF p.27); Table 5 gives no horizon. |
| r | Discount rate for that loss (main estimate only) | 3% | scenario | This OPA's choice, the middle of the JR analysis's 1.5% to 4.5% range; BSZ give no rate. |
R code for the input counts
cnt <- as.data.frame(table(factor(ins$origin,
levels = c("data", "research", "guesswork", "scenario", "derived", "convention"))))
names(cnt) <- c("origin", "inputs")
show(cnt)| origin | inputs |
|---|---|
| data | 5 |
| research | 5 |
| guesswork | 5 |
| scenario | 2 |
| derived | 2 |
| convention | 1 |
R code for the input counts
# Which guesses move the headline revenue? Vary each dial alone around row 1.
moves_rev <- vapply(dials, function(d) diff(range(grid$wealth_tax_revenue[alone(d$key)])) > 1e-9, TRUE)
is_guess <- vapply(dials, `[[`, "", "origin") == "guesswork"
guess_moving_rev <- vapply(dials[moves_rev & is_guess], `[[`, "", "label")
n_guess_dials <- sum(is_guess)
stopifnot(sum(ins$origin == "guesswork") == n_guess_dials)The headline. Of the 20 inputs, 5 are guesswork, and they are exactly the explorer’s 5 guesswork dials. Only 2 of them move the revenue number when varied on their own (avoidance and evasion; aggressive assumption on leavers); the others move only the smaller income tax columns, or matter only in the scenario rows. So Table 5’s row 1 ($103.8 billion here, printed as $104 billion) is Forbes’ list, times 5%, times one minus a single guessed avoidance rate; the only other guess that moves it is who has already left. The disputes with the JR analysis are mostly about inputs this table labels guesswork or scenario: who has left (disputes row 3), how long the lost income tax is counted, and whether a one-time tax triggers permanent departures. The JR analysis also discounts the revenue for the chance that courts strike the tax down, an input the BSZ analysis does not have (disputes row 9).
What reproduces
R code for the test results table
tests <- read.csv("data/test-results.csv", stringsAsFactors = FALSE)
by_file <- aggregate(cbind(blocks = 1, passed, failed) ~ file, data = tests, FUN = sum)
show(rbind(by_file, data.frame(file = "total", blocks = sum(by_file$blocks),
passed = sum(by_file$passed), failed = sum(by_file$failed))))| file | blocks | passed | failed |
|---|---|---|---|
| test-ai-badges.R | 2 | 15 | 0 |
| test-compute.R | 9 | 216 | 0 |
| test-data_sheets.R | 9 | 45 | 0 |
| test-figures.R | 22 | 76 | 0 |
| test-ingest_excel.R | 5 | 21 | 0 |
| test-py-parity.R | 2 | 56 | 0 |
| test-site.R | 10 | 100 | 0 |
| test-snapshots.R | 28 | 34 | 0 |
| test-sql-steps.R | 6 | 18 | 0 |
| test-sql-workbook.R | 3 | 41 | 0 |
| test-sql.R | 3 | 38 | 0 |
| test-tables.R | 14 | 127 | 0 |
| test-verify.R | 6 | 7 | 0 |
| total | 119 | 794 | 0 |
The suite ran on 2026-10-16 against the August workbook: 794 expectations passed and 0 failed. Three groups of tests compare R against the workbook’s cached cells, formula by formula, at a stated tolerance (the compute, tables and data_sheets files). One pins every output, so any change fails (snapshots). One checks the explorer against the reproduction (site). Figures are checked for structure and a few anchor values, not pixel by pixel.
Reproduction notes
R code for the count of notes by kind (read from the materials page)
mq <- readLines("materials.qmd", warn = FALSE, encoding = "UTF-8")
note_kind <- sub("^[0-9]+[.] [*][*]([^.]+)[.][*][*].*", "\\1",
grep("^[0-9]+[.] [*][*](Rounding|Small numerical difference|Documentation)[.][*][*]", mq, value = TRUE))
words <- c("one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten")
nk <- function(k) words[sum(note_kind == k)]The code reproduces the paper’s published results from the authors’ workbook. Checking the paper against the workbook left seven notes, kept for transparency. Each records a place where the paper’s text, its tables and the workbook describe a step differently. None is a failure to reproduce a printed result. By kind: one rounding, two small numerical differences from first-order formulas and four documentation notes. They are numbered as on the materials page, which gives each in full.
- Rounding. Table 5’s income tax loss in rows 2 and 3 is printed as $0.19 and $0.51 billion. The workbook, and this reproduction, give values that round to $0.20 and $0.52. Each printed figure is one unit lower in the last digit than the workbook’s value rounded. Row 4 of the same column is rounded, and the paper does not say why these two differ (see Table 5, reproduced).
- Documentation.
Tab5!H6types the income tax loss share as a literal 0.05. This OPA reads it as avoidance times the leaving share (0.10 × 0.5), as the row label says. It matters only when the explorer moves those inputs. - Documentation. Row 4 omits row 2’s phase-in deduction (
Tab5!F9), worth $2.0 billion. The printed row 4 follows the workbook, and so does this reproduction. - Documentation. The workbook (
TabA1!B18:G18) and the paper annualize Appendix Table A1’s 1982 to 2026 growth over 43 years, where the workbook’s own Table 1 uses 44 (Tab1!C18:H18). This reproduction uses 43, as the paper does, and the report states what 44 would give (see Appendix Table A1). - Documentation. The text’s 25-year break-even (PDF p.22) uses its own ratio of income tax to wealth, about 0.2% a year, and reproduces from it. Table 5’s inputs give 0.13% and about 38 years.
- Small numerical difference. The revenue-maximising rate with de-concentration is printed as the first-order 1/26 = 3.8%; the workbook’s curve peaks at 3.9% (see the Laffer curve).
- Small numerical difference. The text’s 88% of mechanical revenue kept by a 1% permanent tax adds up first-order effects (100% − 10% − 2%); the workbook’s curve gives 88.6%.
Errors in this OPA, found and fixed.
- A display error in Tables 1 and A1. This reproduction’s display code put Panel B’s values under Panel A’s column headers and formats, so several cells showed numbers the paper does not print (a count of families as a percentage, growth rates rounded to whole numbers). It also kept the May workbook’s labels (GDP for AGI, 2025 for 2026, .0002% for .001%, 2025 dollars for 2026 dollars). Both were fixed in the display code on 15 October 2026. The values the tests check did not change.
- Appendix Table A1 at first used 44 years, so 3 cells of the annualized row printed lower than the paper. It now uses 43, as the paper does (note 4). A test now checks each cell of the row; the whole-column comparison before it had not caught the gap.
What is not reproduced.
- The paper’s Figure 8 (venture capital deals) and Appendix Figure A1 (wealth relative to AGI) have no builder yet.
- The tax statistics (Franchise Tax Board, IRS, national accounts) and the authors’ hand-filled SEC ownership shares and adjusted fundamentals are not re-collected. An error in those would pass straight through. The Forbes real-time lists, the Forbes 2004 to 2025 panel, the SEC Form 4 filings (through their income items, joined to Compustat prices) and the venture capital panels are rebuilt from the authors’ raw data in shared SQL (
sql/), and match the authors’ own intermediate files and the workbook. - The GGSS July report has no workbook of its own. Its $100 billion is reproduced only in the sense above: it is BSZ row 1, rounded.
Reproducing this document
cd bsz-analysis
Rscript -e 'targets::tar_make(names = !tidyselect::any_of(c("report_html","report_pdf")))'
Rscript tools/site-test-results.R # writes site/data/test-results.csv
cd site && quarto render repro.qmdThe workbook is not redistributed; DATA-SOURCES.csv gives its URL and SHA-256. Set CAWTBSZ_MATERIALS if it lives outside bsz-analysis/original-materials/.
BSZ analysis of California Proposition 40: Overview · Open Output: Explorer · Open Analysis: Report (here) and Slides · Open Materials
Also in this project: Comparison overview · JR analysis overview
OPA done by Fernando Hoces de la Guardia, using agentic coding tools