Does money help win elections? Answered with data from SF's November 2026 election

San Francisco publishes every campaign finance filing as an open dataset, and the Department of Elections publishes results as they're counted. Join the two and you can ask the old question directly: does the side with more money win? The answer turned out to be less interesting than the trip there: sum the raw transactions table and you overstate the money by about 3x.
All results here are preliminary results as of Nov 4 (the 4:00 PM release). Turnout in that release was 206,320 ballots of 535,608 registered (38.5%), and it's still rising as mail ballots arrive.
The data
Everything was loaded into BigQuery (contextflo-datasets.sf_elections) from public sources, plus two small hand-built maps.
| Table | Source | Rows | Grain |
|---|---|---|---|
campaign_transactions | SF Ethics Commission filings via DataSF, since 2025-01-01 | 105,515 | One line item on one filing (latest version of each filing, so amendments are resolved) |
measure_results | Department of Elections results page | 50 | Measure x choice, with pass threshold |
contest_results | The same release's summary XML | 157 | Contest x choice |
supervisor_rounds | Ranked choice round-by-round pages | 169 | District x row (candidate or ballot tally) x round |
precinct_zips | Precinct breakdown by district, neighborhood and ZIP | 749 | ZIP x precinct |
committee_measures | Hand-built | 25 | Committee x measure x election, with position, and a money_from date for committees reused from an earlier campaign |
candidate_committees | Hand-built | 40 | Committee x candidate |
The two maps exist because the filings don't reliably say which measure or candidate a committee is for. The ballot_number and support_oppose_code columns are there, but sparse. So each measure committee was matched to its measure, side and election by reading committee names and checking filings, and each candidate committee was matched to the candidate's name exactly as contest_results spells it (Bunny McFadden is on the ballot as Bunny but her legal name is Chelsea A McFadden; Gary McCoy in D8 is not Guy McCoy in D2).
Cleaning the money, one gotcha at a time
Every row in campaign_transactions is one line on one form. Only some of those forms are money coming in.
| Form / schedule | What it is | Counts as raised? |
|---|---|---|
| 460 Schedule A | Monetary contributions | Yes |
| 460 Schedule C | In-kind contributions | Yes |
| 460 Schedule I | Misc receipts: public financing, refunds, interest | Yes |
F460ALine2, F460CLine2, F460ILine2 | Unitemized subtotals (gifts under $100) | Yes |
| 460 Schedule E | Payments made | No, that's spending |
| 460 Schedule G | Payments by a consultant on the committee's behalf | No, already inside E |
| 460 Schedule F | Unpaid bills | No |
| 460 Schedule D | Summary of support given to others | No |
Form 497 (F497P1) | 24-hour report of a large late contribution | Only if received after the last 460 period |
memo_code = true | Informational, counted elsewhere | No |
1. Summing every row overstates the money about 3x
Across the 13 November measure committees, counting only each committee's money for this campaign (gotcha 9), every row summed comes to $79.7M. Money actually raised is $26.7M, a ratio of 2.99x. For the 11 committees with regular filings, it runs from 2.76x to 3.71x per committee.
Take Mayor Lurie's Yes on D, E & F committee. Every row summed: $38.5M. Raised: $13.98M. The extra is:
- $11.96M of Schedule E payments, which is spending.
- $7.47M of Schedule G consultant payments. Also spending, and already inside E, so it's counted twice if you add it.
- $4.32M of Form 497 reports. Of that, $4.21M (37 reports) falls inside periods the committee's regular Form 460 filings already cover, so it repeats contributions that appear there; only $110k (2 reports) is money received after the last filing period.
- $0.86M of Schedule F unpaid bills.
2. The unitemized lines matter for small-donor candidates
Raised = Schedules A + C + I (non-memo) + the three unitemized subtotal lines + 497s dated after the committee's last 460 period. That reconciles to the Commission's own Summary Totals to the dollar: Manny Yekutiel's committee is $787,554 both ways, and Gary McCoy ($367,465), Theo Ellington ($495,402) and Stephen Sherrill ($731,662 across two committees) match too.
Drop the unitemized lines and small-donor candidates come out low: Yekutiel by $11,321 (1.4%), McCoy by $12,279. For a big-donor committee like Lurie's H committee, the gap was $150. The error lands hardest on exactly the candidates a "money vs grassroots" question is about.
3. Committees rename mid-campaign
Lurie's D/E/F committee first filed as "Clean Up City Hall, Mayor Lurie's Ballot Measure Committee". Under the current name it received $6.22M; under the old one, $7.64M. Filter on the current name and you get $6.2M instead of $13.9M. That's the mistake I made first. Group on filer_nid, never filer_name.
4. June reused the measure letters
Measure letters reset every election, and the June 2, 2026 ballot had its own A through D: an earthquake bond, term limits, a business tax cut and a CEO pay tax. Search committee names for "Yes on C" and you pull in the $6.6M "Yes on C, No on D to Protect San Francisco's Small Businesses" committee and the $3.5M "Yes on D - Stand Up for SF" committee, both about June. That's why committee_measures carries election_date, and every query filters to 2026-11-03.
5. One committee, several measures
One bank account can fund several measures, and the filings don't split it:
| Committee | Raised | Measures |
|---|---|---|
| Lurie's D/E/F committee | $13.98M | D, E, F |
| Lurie's H committee | $5.41M | H, RTM |
| "Yes on C, No on I" | $2.79M | C, I |
Sum a per-measure money column and you get $62.8M against $26.7M actually raised (2.4x), because Lurie's $14M is counted three times. Either show the full amount per measure and label it as shared, or split it evenly and say so. Never sum across measures.
6. Public financing looks like a donation
SF matches small donations to qualifying candidates with city money. It's booked as a Schedule I receipt from "City and County of San Francisco", so it's in "raised" and in the official totals. Ten supervisor candidates got $1,861,317 between them. Ellington and Yekutiel each got $255,000, about half of what Ellington raised.
If the question is "who funded this candidate", report it separately. And don't filter on the description: one $60,000 payment to DJ Brookter has none, so description LIKE '%public financ%' misses it. Filter on the payer and exclude refunds.
7. A ZIP isn't a neighborhood
Donor rows carry a ZIP. A ZIP spans several precincts and often several neighborhoods, so a plain join to precinct_zips multiplies the money. Split each gift across its ZIP's neighborhoods by each neighborhood's share of the ZIP's precincts. For Lurie's two committees, that puts $4.21M of $18.89M itemized (22%) outside San Francisco, with every SF ZIP mapped.
Two caveats: a company gives from its office ZIP, which inflates downtown (Financial District, South of Market), and the precinct-share split is an approximation.
8. Two result sources disagree slightly
The summary XML and the ranked choice round pages are published on different schedules. In District 4, Alan Wong has 11,296 first choices in the summary and 11,446 in round 1 of the ranked choice page. Use the round pages for ranked choice questions, the summary for everything else, and never mix the two in one calculation.
9. A committee can carry money from an earlier campaign
Committees get reused. "No on G, Save Sunset Dunes" is the same committee (same filer_nid) that ran 2024's "Yes on K, Ocean Beach Park for All". Its filings since 2025 include $792,861 received in 2024 and 2025 for that earlier campaign, against $821,431 in 2026. Count everything and No on G looks twice as well funded as it was. committee_measures carries a money_from date for reused committees, and the queries drop rows dated before it.
Two smaller cases of the same family: a committee with no regular Form 460 filing yet (the DSA committee for Yes on I has only $14,986 of 24-hour reports) drops out of any inner join to the filing periods, and a committee formed late can have no recorded receipts at all (No on B). Both need to stay in the result as rows, not disappear.
What the clean numbers say
Measures
| Measure | Yes side raised | No side raised | Yes % (SF) | Better-funded side ahead? |
|---|---|---|---|---|
| A Charter changes | $452,505 | $0 | 70.51% | Yes |
| B Public bank | $392,271 | $0 | 49.96% | No |
| C Housing Trust Fund | $4,047,421* | $0 | 80.27% | Yes |
| D Ballot measure process | $13,976,301* | $462,335 | 57.54% | Yes |
| E City Administrator | $13,976,301* | $0 | 66.68% | Yes |
| F Executive branch | $13,976,301* | $0 | 62.59% | Yes |
| G Upper Great Highway | $166,475 | $821,431 | 36.43% | Yes (no side) |
| H Muni parcel tax | $5,413,977* | $0 | 72.79% | Yes |
| I Transfer tax changes | $711,495 | $2,792,748* | 45.26% | Yes (no side) |
| J Foreclosure exemption | $237,957 | $0 | 79.47% | Yes |
| RTM Regional transit | $5,413,977* | $0 | 77.91% | Yes in SF; decided region-wide |
* Includes a shared committee's full amount. $0 means no committee in this data recorded receipts on that side, not that nobody campaigned: Measure B has a No committee with nothing recorded. Yes on I includes $14,986 from a committee that has only filed 24-hour reports. Every local measure needed a simple majority.
The better-funded side is ahead on 10 of 11. But only D, G and I had recorded receipts on both sides among the committees included here, which limits what this comparison can tell us about money's effect.
The exception is Measure B, the public bank. $392k was raised for it, the one committee formed against it has no recorded receipts, and it's trailing by 144 votes (95,222 yes, 95,366 no). It needs 50% + 1, and with mail ballots still being counted it's too close to call. It also split by how people voted: 56.9% yes in person (15,741 to 11,947), 48.8% yes by mail (79,481 to 83,419).
Supervisors
Districts 2, 4, 6, 8 and 10 voted, all by ranked choice. "Spent" is payments made: itemized Schedule E plus the unitemized line for payments under $100, which matches the Ethics Commission's own payments total. "Spent per vote" divides that by first-choice votes.
| District | Candidate | Raised | Public financing | Spent | First-choice votes | Spent per vote | Preliminary result |
|---|---|---|---|---|---|---|---|
| 2 | Stephen Sherrill | $731,662 | $252,000 | $651,274 | 13,952 | $46.68 | Leads |
| 4 | Alan Wong | $532,426 | $252,000 | $487,720 | 11,296 | $43.18 | Leads |
| 4 | Albert Chow | $241,551 | $176,160 | $227,602 | 3,073 | $74.07 | |
| 6 | Matt Dorsey | $130,689 | $0 | $110,923 | 8,396 | $13.21 | Leads |
| 8 | Gary McCoy | $367,465 | $217,524 | $302,469 | 12,517 | $24.16 | Leads (round 3) |
| 8 | Darshini Patel | $169,558 | $102,417 | $144,730 | 6,539 | $22.13 | |
| 8 | Manny Yekutiel | $787,554 | $255,000 | $612,740 | 3,121 | $196.33 | 3rd on first choices |
| 8 | Michael Nguyen | $221,594 | $133,974 | $195,968 | 2,767 | $70.82 | |
| 10 | Theo Ellington | $495,402 | $255,000 | $289,337 | 5,905 | $49.00 | Leads (round 9) |
| 10 | DJ Brookter | $199,627 | $94,200 | $174,768 | 1,918 | $91.12 | |
| 10 | J.R. Eppler | $189,287 | $123,042 | $157,660 | 1,533 | $102.84 |
Smaller candidates are omitted; the full table comes from the last query below. Brookter's public financing is $94,200 with the payer filter from gotcha 6, and $34,200 with the description filter.
- The top fundraiser leads in 4 of 5 districts.
- The exception is District 8. Manny Yekutiel raised the most of any supervisor candidate ($787,554, including $255,000 of public financing) and is in third place on first choices, at $196 of spending per first-choice vote. Gary McCoy raised less than half that and spent $24 per vote.
- Raising the most and spending the most aren't the same: Sherrill spent the most ($651k vs Yekutiel's $613k).
- District 6 isn't a "less money wins" story. Dorsey's only opponent raised about $4k.
- Ranked choice changed no outcome. Every round-1 leader is still ahead after the final round; On the round pages, D8 (McCoy at 49.8% in round 1) and D10 (Ellington at 49.4%, nine rounds) needed later rounds to pass 50%. (The summary release the table uses has McCoy at 50.2% of first choices; that's gotcha 8, two sources on different schedules.)
So, does money win?
In this election, the better-funded side leads almost everywhere. But only three measures had recorded receipts on both sides among the committees in this data, so the 10-of-11 figure says little about money's effect. And there are counterexamples: Measure B had all the recorded money and is trailing, and the best-funded supervisor candidate is in third place. Counterexamples aren't a causal test either; this is a description of one election, not an estimate of what money does.
The more useful lesson for anyone working with this kind of data is that the raw table hands you a number about 3x too big, and the fixes are boring and specific: which form a row is on, which committee it belongs to, and which campaign it was for.
Caveats
- Preliminary count. Measure B is within 144 votes and can flip. Certification is about a month out.
- State-filed committees aren't here. Committees that file only with the California Secretary of State are missing from the Ethics Commission data.
- General purpose PACs aren't attributed. A PAC like the Uber Innovation Ballot Measure Committee (about $23.7M received in filings since January 2025) funds several causes and isn't assigned to any one. "Money per measure" means money raised by the measure committees in
committee_measures, not all money spent on the measure. Independent expenditures aren't separated. - Spending runs to each committee's latest filing. The last days may be missing or sitting in Schedule F.
- Candidate "raised" includes public financing.
- Per-vote figures use first-choice votes, not the final ranked choice round.
- The committee maps are hand-built. A missed committee changes a total.
- ZIP to neighborhood is an approximation, and company donors sit at their office ZIP.
The SQL
All BigQuery. The covered CTE finds each committee's last 460 period, so 497s that its regular filings already cover are dropped. It is LEFT JOINed, so a committee with no regular filing keeps its 24-hour reports instead of vanishing from the join. money_from in committee_measures drops a reused committee's money from earlier campaigns.
Money raised per committee
WITH covered AS (
SELECT filer_nid, MAX(end_date) AS covered_through
FROM `contextflo-datasets.sf_elections.campaign_transactions`
WHERE form_type IN ('A', 'C', 'I', 'E')
GROUP BY filer_nid
)
SELECT t.filer_nid,
SUM(IF((t.form_type IN ('A', 'C', 'I') AND NOT t.memo_code)
OR t.form_type IN ('F460ALine2', 'F460CLine2', 'F460ILine2'), t.calculated_amount, 0))
+ SUM(IF(t.form_type = 'F497P1'
AND t.calculated_date > COALESCE(c.covered_through, DATE '1900-01-01'), t.calculated_amount, 0)) AS amount_raised
FROM `contextflo-datasets.sf_elections.campaign_transactions` t
LEFT JOIN covered c USING (filer_nid)
GROUP BY t.filer_nid
This is all of a committee's money in the loaded filings. For a reused committee, filter rows by its money_from date first, as the next two queries do.
Raised vs every row summed, per November measure committee
WITH committees AS (
SELECT DISTINCT filer_nid, committee_name, money_from
FROM `contextflo-datasets.sf_elections.committee_measures`
WHERE election_date = '2026-11-03'
),
-- This campaign's rows only: a committee reused from an earlier campaign carries a money_from date.
t AS (
SELECT t.*
FROM `contextflo-datasets.sf_elections.campaign_transactions` t
JOIN committees c USING (filer_nid)
WHERE c.money_from IS NULL OR t.calculated_date >= c.money_from
),
covered AS (
SELECT filer_nid, MAX(end_date) AS covered_through
FROM t WHERE form_type IN ('A', 'C', 'I', 'E') GROUP BY filer_nid
)
SELECT c.committee_name,
ROUND(COALESCE(SUM(t.calculated_amount), 0)) AS every_row_summed,
ROUND(COALESCE(SUM(IF((t.form_type IN ('A', 'C', 'I') AND NOT t.memo_code)
OR t.form_type IN ('F460ALine2', 'F460CLine2', 'F460ILine2'), t.calculated_amount, 0)), 0)
+ COALESCE(SUM(IF(t.form_type = 'F497P1'
AND t.calculated_date > COALESCE(cv.covered_through, DATE '1900-01-01'), t.calculated_amount, 0)), 0)) AS raised,
ROUND(COALESCE(SUM(IF((t.form_type = 'E' AND NOT t.memo_code) OR t.form_type = 'F460ELine2', t.calculated_amount, 0)), 0)) AS spent
FROM committees c
LEFT JOIN t USING (filer_nid)
LEFT JOIN covered cv USING (filer_nid)
GROUP BY c.committee_name
ORDER BY raised DESC
Money for and against each November measure, with the result
WITH cm AS (
SELECT *, COUNT(*) OVER (PARTITION BY filer_nid) AS measures_backed
FROM `contextflo-datasets.sf_elections.committee_measures`
WHERE election_date = '2026-11-03'
),
committees AS (SELECT DISTINCT filer_nid, money_from FROM cm),
-- This campaign's rows only: a committee reused from an earlier campaign carries a money_from date.
t AS (
SELECT t.*
FROM `contextflo-datasets.sf_elections.campaign_transactions` t
JOIN committees c USING (filer_nid)
WHERE c.money_from IS NULL OR t.calculated_date >= c.money_from
),
covered AS (
SELECT filer_nid, MAX(end_date) AS covered_through
FROM t WHERE form_type IN ('A', 'C', 'I', 'E') GROUP BY filer_nid
),
-- LEFT JOINs keep committees with no regular filing; their 24-hour reports all count as new money.
raised AS (
SELECT c.filer_nid,
COALESCE(SUM(IF((t.form_type IN ('A', 'C', 'I') AND NOT t.memo_code)
OR t.form_type IN ('F460ALine2', 'F460CLine2', 'F460ILine2'), t.calculated_amount, 0)), 0)
+ COALESCE(SUM(IF(t.form_type = 'F497P1'
AND t.calculated_date > COALESCE(cv.covered_through, DATE '1900-01-01'), t.calculated_amount, 0)), 0) AS raised
FROM committees c
LEFT JOIN t USING (filer_nid)
LEFT JOIN covered cv USING (filer_nid)
GROUP BY c.filer_nid
),
results AS (
SELECT measure_code, MAX(IF(choice IN ('YES', 'BONDS - YES'), pct, NULL)) AS yes_pct
FROM `contextflo-datasets.sf_elections.measure_results`
GROUP BY measure_code
)
SELECT cm.measure_code,
ROUND(SUM(IF(cm.position = 'yes', r.raised, 0))) AS yes_side_raised,
ROUND(SUM(IF(cm.position = 'no', r.raised, 0))) AS no_side_raised,
COUNTIF(cm.measures_backed > 1) AS shared_committees,
res.yes_pct
FROM cm
JOIN raised r USING (filer_nid)
JOIN results res USING (measure_code)
GROUP BY cm.measure_code, res.yes_pct
ORDER BY cm.measure_code
Supervisor candidates: money, public financing, spending and votes
WITH t AS (
SELECT t.*
FROM `contextflo-datasets.sf_elections.campaign_transactions` t
JOIN `contextflo-datasets.sf_elections.candidate_committees` cc USING (filer_nid)
),
covered AS (
SELECT filer_nid, MAX(end_date) AS covered_through
FROM t WHERE form_type IN ('A', 'C', 'I', 'E') GROUP BY filer_nid
),
-- LEFT JOINs keep candidates whose committee has no regular filing yet.
money AS (
SELECT cc.contest, cc.candidate,
COALESCE(SUM(IF((t.form_type IN ('A', 'C', 'I') AND NOT t.memo_code)
OR t.form_type IN ('F460ALine2', 'F460CLine2', 'F460ILine2'), t.calculated_amount, 0)), 0)
+ COALESCE(SUM(IF(t.form_type = 'F497P1'
AND t.calculated_date > COALESCE(cv.covered_through, DATE '1900-01-01'), t.calculated_amount, 0)), 0) AS raised,
COALESCE(SUM(IF(t.form_type = 'I' AND LOWER(t.transaction_last_name) = 'city and county of san francisco'
AND LOWER(COALESCE(t.transaction_description, '')) NOT LIKE '%refund%', t.calculated_amount, 0)), 0) AS public_financing,
-- Itemized payments plus the unitemized line (payments under $100); matches the official payments total.
COALESCE(SUM(IF((t.form_type = 'E' AND NOT t.memo_code) OR t.form_type = 'F460ELine2', t.calculated_amount, 0)), 0) AS spent
FROM `contextflo-datasets.sf_elections.candidate_committees` cc
LEFT JOIN t USING (filer_nid)
LEFT JOIN covered cv USING (filer_nid)
GROUP BY cc.contest, cc.candidate
)
SELECT m.contest, m.candidate,
ROUND(m.raised) AS raised,
ROUND(m.public_financing) AS public_financing,
ROUND(m.spent) AS spent,
r.votes AS first_choice_votes,
ROUND(m.spent / NULLIF(r.votes, 0), 2) AS spent_per_vote
FROM money m
JOIN `contextflo-datasets.sf_elections.contest_results` r
ON r.contest = m.contest AND r.choice = m.candidate
WHERE m.contest LIKE 'MEMBER, BOARD OF SUPERVISORS%'
ORDER BY m.contest, r.votes DESC
Try it yourself
The cleaned dataset, the definitions above (what counts as "raised", how shared committees are handled) and these reference queries are loaded in the Contextflo playground. No account needed: ask it about any measure or district and check its SQL against the queries here.
FAQ
Did the better-funded side win in San Francisco's November 2026 election? In preliminary results as of Nov 4, the better-funded side is ahead on 10 of 11 measures (local Measures A to J plus the regional transit measure). But only three measures (D, G and I) had recorded receipts on both sides among the committees in this data, which limits what the comparison can say about money's effect.
Why does summing SF campaign finance transactions give the wrong total? The transactions table mixes money received with money spent, consultant payments already counted in spending, unpaid bills, and 24-hour reports that repeat later contributions. Across the 13 November measure committees, summing every row gives $79.7M; money actually raised for this campaign is $26.7M.
Where does the SF campaign finance data come from? SF Ethics Commission filings published on DataSF (Campaign Finance - Transactions), joined to the Department of Elections' preliminary results. Committees that file only with the California Secretary of State are not included.
See it on your own data.
Connect your warehouse and your team can ask Claude about it the same day, without waiting on a data team. Want a walkthrough first? Grab 20 minutes.
Book a 20-minute callSee how teams use Contextflo
Related posts
Keep reading

Conversational analytics: 5 ways to set it up, compared
7 min read

How to connect Claude to BigQuery, and fix the errors you'll hit
8 min read

How to connect Claude to Postgres without giving it write access
9 min read

How to build a BI dashboard with Claude that your team can actually trust
8 min read

How to give Claude read-only access to your database (it isn't always the default)
7 min read

You connected your warehouse to Claude, now what?
5 min read

How Contextflo dashboards work
4 min read

ClickHouse MCP: how to connect ClickHouse to Claude, Cursor or Codex
6 min read

Query BigQuery from the Codex CLI with MCP
5 min read




