| Table 1. Wealth Growth of California Billionaires | |||||||
| Year | # | Wealth | Growth | % public | Top 4 wealth | CA GDP | Wealth / GDP |
|---|---|---|---|---|---|---|---|
| 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 | – | 1 | – | – | 3 | 0 | – |
| B. Long-term real wealth growth: top .001% wealthiest CA families (2026 $) | |||||||
| 1982 | 118 | 49 | 41.3% | 1,184.1% | 614 | 51,900 | – |
| 2026 | 225 | 2,291 | 1,019.1% | 2,254.1% | 2,299 | 102,000 | – |
| Ratio 2025 to 1982 | 2 | 47 | 2,469.0% | 190.4% | 4 | 2 | – |
| Annualized growth | 0 | 0 | 7.6% | 1.5% | 0 | 0 | – |
| Notes: Panel A illustrates the wealth growth of CA billionaires in 2022-2025. Panel B compares the top .0002% wealthiest CA families’ wealth in 1982 vs 2025, all in 2025 dollars. Source: Forbes RTB snapshots + SEC EDGAR. | |||||||
Prop 40, BSZ analysis: the reproduction
Boll, Saez and Zucman (BSZ, 2026) and the Galle, Gamage, Saez and Shanske (GGSS) expert report, reproduced step by step from the authors’ August 2026 workbook
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")
pvf <- site_pv_factor() # 5 years at 3% (R/site_exports.R); 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 paid 1% a year over five years. Two documents carry BSZ’s revenue estimate, and this report reproduces both:
- 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.
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. 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. 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.
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 ours. 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. 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, while the \(e\) raised to a power there is the exponential.
II. The wealth of California billionaires
A. Short-run growth: Table 1 Panel A and Figure 1
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). Getting there needed one correction to the workbook, described under What reproduces: four rows of the raw list were pasted twice.
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 -------------------------------------------------------
# Two stacked panels rendered as one gt; row groups give the panel headers.
panel_a_render <- tibble::tibble(
section = "A. Recent nominal wealth growth of CA billionaires",
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%"
panel_b_render <- tibble::tibble(
section = paste0("B. Long-term real wealth growth: ", pctile_label,
" wealthiest CA families (", cur_label, " $)"),
year = 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)
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 = "CA GDP",
col7 = "Wealth / GDP"
) |>
gt::fmt_number(columns = c(col1, col2, col5, col6),
decimals = 0, use_seps = TRUE) |>
gt::fmt_percent(columns = c(col3, col4, col7), decimals = 1) |>
gt::sub_missing(missing_text = "–") |>
gt::tab_source_note(source_note = gt::md(paste(
"**Notes:** Panel A illustrates the wealth growth of CA billionaires in 2022-2025.",
"Panel B compares the top .0002% wealthiest CA families' wealth in 1982 vs 2025,",
"all in 2025 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:475-652.# 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, but some of the pipeline’s display labels are wrong: the denominator column is headed “CA GDP” though it now holds AGI, Panel B’s ratio row says “2025” though the endpoint is 2026, and the note still says “.0002%” and “2025 dollars”. Appendix Table A1 has the same label drift. Listed under 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
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
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:388-473.# 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 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:300-386.# 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). We build the base scenario in five steps, then each other scenario as a change to it.
Step 1: the tax base
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: \[W^{\text{tax}} = (1 - \alpha)\,W_0 = 0.9 \times 2,307 = 2,076.3.\] 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 the single most important one for the headline: it is the only guess the revenue number itself depends on (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 BSZ’s headline: $103.8 billion, collected once (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
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: \[E = s \cdot g \cdot t_{cg} \cdot T = \tfrac13 \times 0.80 \times 0.133 \times 103.8 = 3.68.\] 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
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: \[L = -\alpha\,m\,C = -0.10 \times 0.5 \times 3.06 = -0.153 \text{ per year}.\]
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, and Table 5 does not say for how many years. The paper argues that, for a genuinely one-time tax, departures should be temporary, “say only 5 years” (PDF p.27). The JR report (Jaros and Rauh; first version Rauh, Jaros, Kearney, Doran and Cosso) counts the loss forever. That difference in horizon is the largest single dispute between the two reports (comparison/DISPUTES.md, row 2).
The main estimate
The main estimate adds the three pieces. It counts the yearly loss for BSZ’s 5 years, discounted at 3% a year, the middle of the 1.5% to 4.5% range the JR report uses (BSZ give no rate): \[M = T + E + L \times \frac{1 - 1.03^{-5}}{0.03} = 103.82 + 3.68 - 0.153 \times 4.58 = 106.80.\] 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)
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)
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: \[T_2 = \tau\left[(1-\alpha)W_0 + (1-\alpha_s)\,\Delta W\right] - 0.025\,f_{ph}\,\Delta W, \qquad \Delta W = 28.2\% \times W_0,\] with \(f_{ph}\) = 12.1% the share of the added wealth in the phase-in band. 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 red is what Forbes lists and pale red 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" = "#d6272840", "listed by Forbes" = "#d62728",
"Pareto tail" = "#1f77b4"), 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)
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})\) with \(W_{pre}\) = $333.6 billion, 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 the most important guesswork in the whole 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). We reproduce the workbook as it is. A consistent treatment would lower it by $2.0 billion. We list 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 + 10% avoidance",
"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 CA inctax loss ($B)"
) |>
gt::fmt_number(columns = c(n_billionaires, wealth, taxable_wealth,
wealth_tax_revenue, extra_ca_inctax_sales,
annual_ca_inctax_loss),
decimals = 1, use_seps = TRUE) |>
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). Annual CA inctax loss is the steady-state revenue forgone from",
"billionaires leaving California. Source: `tab5_r`."
)))
attr(tab, "panel") <- panel
tab
}
# Read-only display. Edit R/tables.R:257-298.# 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 CA inctax loss ($B) | |
|---|---|---|---|---|---|---|---|
| 1. Benchmark: Forbes estimates + 10% avoidance | 250.0 | 2,307.0 | 2,076.3 | 10.0% | 103.8 | 3.7 | −0.2 |
| 2. Adding missing small billionaires (Pareto extrapolation) | 619.6 | 2,957.4 | 2,596.6 | 12.2% | 127.9 | 4.5 | −0.2 |
| 3. Aggressive assumptions for pre/post-2026 leavers | 250.0 | 2,307.0 | 1,776.1 | 23.0% | 88.8 | 3.1 | −0.5 |
| 4. Benchmark with both adding small billionaires and aggressive leavers | 619.6 | 2,957.4 | 2,296.3 | 22.4% | 114.8 | 4.1 | −0.6 |
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). Annual CA inctax loss is the steady-state revenue forgone from billionaires leaving California. 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 ours, and the paper’s text quotes the printed figures ($0.19 billion on PDF p.25, $0.51 billion on p.26), so the paper rounds these two cells differently from its own workbook. Both are a cent of a billion; neither changes an argument.
What the tax collects, and what it costs
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:
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)),
check.names = FALSE)
}))
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 |
| 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 |
| 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 |
| 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 |
Two documents, one number
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, \[Z(\tau) = Z_0\, e^{-e(\tau - \tau_0)}, \qquad T(\tau) = \tau\, Z(\tau).\] 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 Rauh et al. also start from, 10.32; they prefer 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%, close to the paper’s \(1/(1+d+e) = 3.8\%\).
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
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"
n_years <- if (identical(vintage, "may")) 43 else 44
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)
panel_a_render <- tibble::tibble(
section = "A. Recent nominal wealth growth of US billionaires",
year = panel_a_full$year,
col1 = panel_a_full$n_us_billionaires,
col2 = panel_a_full$wealth_b,
col3 = panel_a_full$annual_growth,
col4 = NA_real_,
col5 = if (!identical(vintage, "may")) panel_a_full$us_agi_b else NA_real_,
col6 = if (!identical(vintage, "may")) panel_a_full$wealth_per_agi else NA_real_
)
pctile_label <- if (identical(vintage, "may")) "top .0002%" else "top .001%"
panel_b_render <- tibble::tibble(
section = paste0("B. Long-term real wealth growth: ", pctile_label,
" wealthiest US families (", cur_label, " $)"),
year = 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)
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 = "# / families",
col2 = "Wealth",
col3 = "Growth / per family",
col4 = "# US families (M)",
col5 = "US GDP",
col6 = "GDP / family ($)"
) |>
gt::fmt_number(columns = c(col1, col2, col4, col5, col6),
decimals = 0, use_seps = TRUE) |>
gt::fmt_percent(columns = col3, decimals = 1) |>
gt::sub_missing(missing_text = "–") |>
gt::tab_source_note(source_note = gt::md(paste(
"**Notes:** Repeats Tab 1 for US billionaires instead of CA. Panel A:",
"nominal wealth of all US citizen billionaires (Forbes). Panel B: 1982",
"vs 2025 real wealth of top .0002% US families, in 2025 dollars."
)))
attr(tab, "panel_a") <- panel_a_full
attr(tab, "panel_b") <- panel_b
tab
}
# Read-only display. Edit R/tables.R:105-255.# 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 if may else 44
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 | # / families | Wealth | Growth / per family | # US families (M) | US GDP | GDP / family ($) |
|---|---|---|---|---|---|---|
| A. Recent nominal wealth growth of US billionaires | ||||||
| 2022 | 717 | 4,358 | – | – | 14,752 | 0 |
| 2023 | 746 | 5,247 | 20.4% | – | 15,128 | 0 |
| 2024 | 813 | 6,723 | 28.1% | – | 16,457 | 0 |
| 2025 | 938 | 8,189 | 21.8% | – | 17,280 | 0 |
| 2026 | 982 | 8,826 | 15.6% | – | 18,144 | 0 |
| Growth during 3 years (2023-2025) | – | 1 | – | – | 0 | – |
| B. Long-term real wealth growth: top .001% wealthiest US families (2026 $) | ||||||
| 1982 | 1,105 | 459 | 41.5% | 111 | 6,127 | 55,437 |
| 2026 | 1,950 | 10,570 | 542.0% | 195 | 18,144 | 93,050 |
| Ratio 2025 to 1982 | 2 | 23 | 1,305.4% | 2 | 3 | 2 |
| Annualized growth | 0 | 0 | 6.0% | 0 | 0 | 0 |
| Notes: Repeats Tab 1 for US billionaires instead of CA. Panel A: nominal wealth of all US citizen billionaires (Forbes). Panel B: 1982 vs 2025 real wealth of top .0002% US families, in 2025 dollars. | ||||||
Appendix Figures A2 and A3
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 by how much evidence stands behind it, which 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 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 | scenario | The Act as written: 5%, payable 1%/yr over 5 years (GGSS p.1). |
| 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 | Asserted, argued from enforcement (GGSS p.4; BSZ PDF p.23). Explorer dial. |
| m | Share of avoidance that is leaving | 50% | guesswork | Asserted (BSZ PDF p.24; Tab5 row 1 label). Explorer dial. |
| αₛ | Avoidance, less visible billionaires | 20% | guesswork | Asserted (BSZ PDF p.25). Explorer dial. |
| s | Share of the tax paid by selling assets | 1/3 | guesswork | Asserted (BSZ PDF p.24). Explorer dial. |
| L | Who left before 1 Jan 2026 (Table 5 rows 3 and 4) | no one (row 1) | 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. |
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 | 4 |
| research | 5 |
| guesswork | 5 |
| scenario | 1 |
| 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 18 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 BSZ’s headline $103.8 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 report are mostly about inputs this table labels guesswork or scenario: who has left (disputes row 3), how long losses last, and whether a one-time tax triggers permanent departures.
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-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 | 7 | 48 | 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 | 121 | 0 |
| test-verify.R | 6 | 7 | 0 |
| total | 114 | 721 | 0 |
The suite ran on 2026-10-14 against the August workbook: 721 expectations passed and 0 failed. The compute, tables and data_sheets files compare R against the workbook’s own cached cells, formula by formula, at a stated tolerance; snapshots pins every output so any change fails; site pins the explorer to the reproduction. Figures are checked for structure and a few anchor values, not pixel by pixel.
Mismatches found, and how each was resolved
Checking the paper and its workbook found the following.
A data-entry error in the workbook. In the data_sec_all sheet, rows 1338 to 1341 (four 2025 entries: Sijbrandij, Rihanna, Preston-Werner, Stephens) appear a second time at rows 1342 to 1345. Counting both copies gives 244 billionaires; dropping the repeat gives 240, which matches the workbook’s own aggregate sheet and the paper’s text. The pipeline drops rows that repeat year, person and wealth exactly (R/compute_data_sec_agg.R).
Two printed cells that differ from the workbook. 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 (see Table 5, reproduced).
Readings the workbook leaves implicit. Both are kept at the workbook’s behaviour and disclosed on the explorer:
Tab5!H6types the income tax loss share as a literal 0.05. We read 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.- Row 4 omits row 2’s phase-in deduction (
Tab5!F9), worth $2.0 billion.
Display labels that do not match the data (ours). Tables 1 and A1 hold the right values under some wrong labels in this reproduction (GDP for AGI, 2025 for 2026, .0002% for .001%, 2025 dollars for 2026 dollars), and a few summary cells render as rounded zeros. The numbers are tested; the labels are not. Fixing them changes the pinned table snapshots, so it is left for a separate change.
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