Summary for stakeholders

Mohammed Beheiry · FEC Contributions Analysis · 2017–2020

Key findings

Under the WinRed-exclusion scenario, itemized contribution records total about USD 15.25 billion. Individuals account for about 83% of that amount. Contributions peaked in October 2020, just before the presidential election. Committee contributions are concentrated: the top 10% of candidates received about 60% of the attributed amount, while the median candidate received about USD 16,000.

  • Contributions roughly doubled from the 2018 cycle (about USD 5.09 billion) to the 2020 cycle (about USD 10.16 billion). Individual contributions more than doubled, while committee contributions grew by about 40%.
  • The reported-record measure is about USD 1.55 billion higher; the difference is the WinRed-reported records excluded from this scenario. One candidate’s USD 49.6 million committee total falls to about USD 1.2 million without them.
  • The source needed cleanup before counting. The 2020 individual table repeats records up to five times (195.5 million rows for 88.8 million unique IDs), and the table labeled 2020 operating expenditures contains 2017–2018 reporting years, so I did not use it.
  • Small donations are undercounted. The data contains itemized records, and donors who give USD 200 or less to a committee generally do not have to be itemized.

These figures exclude WinRed conduit reporting to reduce overlap. They are not a verified count of unique donations. ActBlue and WinRed report differently, so comparisons between parties require care. The technical report shows the alternative reported-record measure.

Key numbers

ResultWinRed-exclusion scenario
Individual contributions57.5 million records · USD 12.67 billion
Committee contributions653 thousand records · USD 2.58 billion
Busiest monthOctober 2020 · up 4.4% from September
Candidates receiving committee contributions2,603
Median / average received per candidateUSD 16,000 / USD 405,000
Top 10 businesses per statePreliminary version: 4 hours; validated version: estimated 3–4 working days

Decisions to confirm

Agree on the contribution measure to use for future reporting. For the business ranking, confirm whether to use the payee’s or committee’s state, gross payments or payments net of refunds, whether to combine subsidiaries with parent companies, and how to treat payment processors and other intermediaries.

Next steps and data needed

Next stepPlanning estimateData and availability
Confirm ranking scope30 minutesStakeholder decisions needed
Build a preliminary ranking4 hours, including scopeExpenditure tables available; coverage still needs validation
Validate businesses and intermediary payments3–4 working days totalAvailable records plus manual review; further evidence may be needed
Check completeness of 2018 expensesHalf a day initiallyOfficial bulk files: not included in this analysis; need to be obtained
Compare party and incumbent groups1 hour initiallyCandidate tables available; attribution coverage needs checking
Assess unitemized contributionsEstimate after source reviewCommittee summary reports: not included in this analysis; need to be obtained

Estimates depend on source coverage and the number of unresolved businesses. They are planning allowances, not measured delivery times, and should not be added together. Summary reports could extend aggregate coverage but would not provide a complete transaction-level list of small donations.

Technical report

The stakeholder summary uses the WinRed-exclusion scenario. Q1 and Q2 below show the original reported-record results and explain how that scenario changes them. Both use the 2018 and 2020 cycles, covering January 2017 through December 2020.

Answers by question

Question Reported-record measure WinRed-exclusion scenario
1a. Individual contributions 79,847,961 records 57,464,005 records
1b. Committee contributions 1,750,504 records 652,869 records
1c. Individual / committee amounts USD 14.08B / USD 2.72B USD 12.67B / USD 2.58B
1d. Peak month / growth from prior month October 2020 / 12.50% October 2020 / 4.44%
2a. Candidate IDs receiving committee contributions 2,614 2,603
2b. Median / average per candidate ID USD 15,800 / USD 428,635 USD 15,750 / USD 404,860
2b. Share received by the top 10% of candidate IDs 61.91% 59.61%

Amounts combine both cycles. Medians are approximate; averages and amounts are rounded. The scenario reduces overlapping reporting but is not a verified count of unique donations.

Q1: sources · Q2: candidates · Q3: analysis plan · QA and model · Conduit comparison · Scope and next steps · Appendix

Q1. Contribution counts and amounts

Source Cycle Contribution records Gross reported amount
Individuals 2018 15,088,198 USD 4,021,953,507
Individuals 2020 64,759,763 USD 10,058,325,757
Individuals Both cycles 79,847,961 USD 14,080,279,264
Committees 2018 334,553 USD 1,067,216,231
Committees 2020 1,415,951 USD 1,653,263,736
Committees Both cycles 1,750,504 USD 2,720,479,967
SQL: 16_model_committee_q1 — answer statement

Open complete SQL file

SELECT
  cycle,
  COUNT(*) AS n_committee_contributions,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  SUM(amount) AS gross_committee_amount,
  MIN(transaction_dt) AS earliest_included_date,
  MAX(transaction_dt) AS latest_included_date
FROM `fec-contributions-analysis.fec_analysis.stg_committee_q1`
WHERE is_q1_in_scope
GROUP BY cycle
ORDER BY cycle;
SQL: 17_model_individual_q1 — answer statement

Open complete SQL file

SELECT
  cycle,
  COUNT(*) AS n_individual_contributions,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  SUM(amount) AS gross_individual_amount,
  MIN(transaction_dt) AS earliest_included_date,
  MAX(transaction_dt) AS latest_included_date
FROM `fec-contributions-analysis.fec_analysis.stg_individual_q1`
WHERE is_q1_in_scope
GROUP BY cycle
ORDER BY cycle;

Each included record ID is unique within its source and cycle. The counts describe transactions, not distinct donors. Committee activity includes payments to candidate committees and other registered committees.

The table uses the reported-record measure. Under the WinRed-exclusion scenario, individual contributions fall to 57,464,005 records totaling USD 12,667,245,189 and committee contributions fall to 652,869 records totaling USD 2,579,862,501. The stakeholder summary uses these figures. I explain the scenario and its limits in Intermediary reporting and overlap.

Peak month

October 2020 recorded USD 2,017,565,781 in combined activity, compared with USD 1,793,420,123 in September. The month-over-month increase is 12.50%.

SQL: 18_model_monthly_contributions — peak-month statement

Open complete SQL file

WITH calendar AS (
  SELECT contribution_month
  FROM UNNEST(
    GENERATE_DATE_ARRAY(
      DATE '2017-01-01',
      DATE '2020-12-01',
      INTERVAL 1 MONTH
    )
  ) AS contribution_month
),
monthly AS (
  SELECT
    c.contribution_month,
    COALESCE(SUM(m.gross_amount), 0) AS total_amount
  FROM calendar AS c
  LEFT JOIN
    `fec-contributions-analysis.fec_analysis.agg_contributions_monthly` AS m
    USING (contribution_month)
  GROUP BY c.contribution_month
),
growth AS (
  SELECT
    contribution_month,
    total_amount,
    LAG(total_amount) OVER (
      ORDER BY contribution_month
    ) AS prior_month_amount
  FROM monthly
)
SELECT
  contribution_month,
  total_amount,
  prior_month_amount,
  100 * SAFE_DIVIDE(
    total_amount - prior_month_amount,
    prior_month_amount
  ) AS month_over_month_growth_pct
FROM growth
QUALIFY DENSE_RANK() OVER (ORDER BY total_amount DESC) = 1;

I aggregated the records by calendar month and used a complete monthly calendar before calculating the prior-month comparison. The monthly table reconciles exactly to the source counts and amounts, with 48 distinct months per source. It also keeps chart queries small.

The peak and growth rate apply to the reported-record definition. Excluding WinRed’s 15E conduit receipts and its outgoing committee contributions leaves October 2020 as the peak, at USD 1,636,196,210 versus USD 1,566,597,035 in September, an increase of 4.44%. This tests the effect of the reporting choice; it is not a fully deduplicated donation estimate.

Source queries: 16_model_committee_q1, 17_model_individual_q1, 18_model_monthly_contributions, 19_qa_monthly_reconciliation and 21_monthly_chart_data.

Definitions and caveats

I used gross positive amounts so the contribution measure has a consistent meaning across sources. Refunds, zero amounts, nonblank memo codes, and records with missing or out-of-cycle dates are excluded. Both memo codes X and Y are excluded by the same rule. Amounts are cast to NUMERIC for aggregation.

Individual receipts require entity type IND and transaction types 10, 15, 15E, 30, 30E, 31, 31E, 32, or 32E. This excludes candidate self-contributions, loans and intermediary disbursements. The source contains itemized records; it is not a complete measure of all donations.

Committee contributions use outgoing transaction types 24K and 24Z. For Q1, I used other_committee_tx_2018 and other_committee_tx_2020. For Q2, I used committee_contributions_2018 and committee_contributions_2020 because they contain candidate attribution. I did not add the two committee sources together.

The FEC transaction code definitions informed these filters. The unit of analysis is a qualifying record within a cycle; candidate statistics combine records by candidate ID across both cycles.

In [1]:
import pandas as pd
import matplotlib.pyplot as plt

# In Colab, I uploaded the CSV exported from BigQuery using files.upload().
# I changed this version to read the accompanying CSV directly so it also runs outside Colab.
monthly = pd.read_csv("21_monthly_chart_data.csv")
monthly["contribution_month"] = pd.to_datetime(
    monthly["contribution_month"]
)

monthly = monthly.sort_values("contribution_month")
amounts = monthly.set_index("contribution_month")[
    ["individual_amount", "committee_amount"]
]

# Convert dollars to billions for the chart.
# AI helped me choose the chart colors.
ax = (amounts / 1e9).plot.area(
    figsize=(11, 5),
    color=["#2563EB", "#F59E0B"]
)
ax.set(
    title="Monthly contributions by source, 2017–2020",
    xlabel="Month",
    ylabel="Gross contributions ($ billions)"
)
from matplotlib.patches import Patch

ax.legend(handles=[
    Patch(color="#2563EB", label="Individuals"),
    Patch(color="#F59E0B", label="Committees")
])
plt.tight_layout()
plt.show()
No description has been provided for this image

Individual receipts make up most of the reported amount at the October 2020 peak. The stacked areas show the contribution of each source to the combined monthly total. Source: 21_monthly_chart_data.csv.

SQL: 21_monthly_chart_data

Open complete SQL file

-- 21_monthly_chart_data
-- I use the saved monthly summary to compare the two sources
-- over time without scanning the original contribution tables again.
WITH monthly AS (
  SELECT
    contribution_month,
    SUM(
      IF(source_type = 'individual', gross_amount, 0)
    ) AS individual_amount,
    SUM(
      IF(source_type = 'committee', gross_amount, 0)
    ) AS committee_amount,
    SUM(gross_amount) AS total_amount
  FROM
    `fec-contributions-analysis.fec_analysis.agg_contributions_monthly`
  GROUP BY contribution_month
)
SELECT
  contribution_month,
  individual_amount,
  committee_amount,
  total_amount,
  100 * SAFE_DIVIDE(
    total_amount - LAG(total_amount) OVER (
      ORDER BY contribution_month
    ),
    LAG(total_amount) OVER (
      ORDER BY contribution_month
    )
  ) AS month_over_month_growth_pct
FROM monthly
ORDER BY contribution_month;

Q2. Candidate coverage and distribution

Qualifying committee records identify 2,614 candidate IDs and USD 1,120,452,295 in attributed contributions across both cycles. The median total per candidate ID is approximately USD 15,800, compared with a mean of USD 428,635. The top 262 IDs receive 61.91% of the attributed amount.

Measure Total per candidate ID What it means
Minimum USD 8 The lowest total received by a candidate ID in this analysis.
25% approximately USD 3,500 About 25% of candidate IDs received this amount or less.
Median (50% approximately) USD 15,800 About half received this amount or less, and half received this amount or more.
Average (mean) USD 428,635 Total attributed contributions divided by the number of candidate IDs. Large totals pull the average up.
75% approximately USD 369,013 About 75% of candidate IDs received this amount or less.
90% approximately USD 1,494,038 About 90% of candidate IDs received this amount or less.
99% approximately USD 3,946,544 About 99% of candidate IDs received this amount or less.
Maximum USD 49,608,977 The highest total received by a candidate ID in this analysis.
Standard deviation USD 1,271,873 Measures how spread out the totals are around the average, using all candidate IDs in this analysis. A larger value means more variation.
SQL: 12_q2_candidate_summary

Open complete SQL file

-- 12_q2_candidate_summary
-- I total qualifying contributions for each reported candidate ID
-- across both cycles, then describe the distribution of those totals.
-- Missing candidate IDs remain separate; I cannot tell how many
-- candidates those unidentified records represent.
WITH eligible AS (
  SELECT cand_id, amount
  FROM
    `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
  WHERE is_q2_in_scope
),
candidate_totals AS (
  SELECT
    cand_id,
    SUM(amount) AS total_received
  FROM eligible
  WHERE cand_id IS NOT NULL
  GROUP BY cand_id
),
ranked AS (
  SELECT
    cand_id,
    total_received,
    ROW_NUMBER() OVER (
      ORDER BY total_received DESC, cand_id
    ) AS candidate_rank,
    COUNT(*) OVER () AS n_candidates
  FROM candidate_totals
),
summary AS (
  SELECT
    COUNT(*) AS n_reported_candidate_ids,
    SUM(total_received) AS total_attributed_amount,
    MIN(total_received) AS minimum_received,
    AVG(total_received) AS mean_received,
    APPROX_QUANTILES(total_received, 100) AS percentiles,
    MAX(total_received) AS maximum_received,
    STDDEV_POP(total_received) AS population_stddev,
    100 * SAFE_DIVIDE(
      SUM(IF(
        candidate_rank <= CEIL(n_candidates * 0.10),
        total_received, 0
      )),
      SUM(total_received)
    ) AS top_10_percent_share_pct
  FROM ranked
)
SELECT
  n_reported_candidate_ids,
  total_attributed_amount,
  minimum_received,
  percentiles[OFFSET(25)] AS approximate_p25,
  percentiles[OFFSET(50)] AS approximate_median,
  mean_received,
  percentiles[OFFSET(75)] AS approximate_p75,
  percentiles[OFFSET(90)] AS approximate_p90,
  percentiles[OFFSET(99)] AS approximate_p99,
  maximum_received,
  population_stddev,
  top_10_percent_share_pct,
  (
    SELECT COUNTIF(cand_id IS NULL)
    FROM eligible
  ) AS n_contributions_without_candidate_id,
  (
    SELECT SUM(amount)
    FROM eligible
    WHERE cand_id IS NULL
  ) AS amount_without_candidate_id
FROM summary;

Definitions and caveats

I aggregated by candidate ID before calculating the distribution. The large difference between the mean and median reflects concentration at the upper end.

Populated IDs remain in the analysis even where the same-cycle candidate reference has no match: 138 IDs in 2018 and 125 in 2020. The result is therefore a count of reported candidate IDs, not a verified count of unique people. Another 2,938 qualifying records, totaling USD 3,098,774, have no candidate ID and are excluded from the distribution.

Largest candidate total

The USD 49,608,977 maximum belongs to Donald Trump's candidate ID, P80001571. Of that amount, USD 48,407,633 was reported by WinRed, C00694323, in 2020: 257,625 records, all coded 24K, with a largest record of USD 5,600. WinRed accounts for 97.58% of this candidate's total. The full breakdown reconciles to the original maximum.

The WinRed records identify recipient committee C00580100, use entity type CCM, and have no memo text. They establish the reported amount and recipient attribution, but do not identify the original funding source of each payment. I have retained them in the stated committee-record measure. I would not describe the result as USD 49.6 million of independently funded PAC support.

Excluding WinRed from the committee-source measure reduces coverage from 2,614 to 2,603 candidate IDs and the attributed amount to USD 1,053,851,132. The approximate median is USD 15,750 and the mean is USD 404,860. The largest total becomes USD 6,283,681, for candidate ID S4GA11285; Trump’s remaining total is USD 1,201,344. The top 261 IDs still account for 59.61% of the amount. Concentration therefore persists, but the original maximum is heavily affected by conduit reporting. The stakeholder summary uses these figures. Other intermediaries may still be included.

Candidate and committee identities were checked against the FEC candidate record and FEC committee record. Source queries: 11_model_committee_staging, 12_q2_candidate_summary, 22_qa_largest_candidate, 24_qa_winred_candidate_reporting and 27_qa_winred_sensitivity.

In [2]:
import json
from pathlib import Path
import matplotlib.pyplot as plt

# Use the saved Q2 scenario result; no new source scan is needed.
results = json.loads(Path("QA_results/27_qa_winred_sensitivity.json").read_text())
q2 = json.loads(next(row["details"] for row in results
                     if row["result_type"] == "q2_without_winred"))
top_share = float(q2["top_ten_percent_share_pct"])
fig, ax = plt.subplots(figsize=(9, 2.4))
# AI helped me choose the chart colors.
ax.barh([0], [top_share], color="#2563EB", label="Top 261 candidate IDs")
ax.barh([0], [100 - top_share], left=[top_share], color="#F59E0B",
        label="Remaining 2,342 candidate IDs")
ax.text(top_share / 2, 0, f"{top_share:.1f}%", ha="center", va="center", color="white")
ax.text(top_share + (100 - top_share) / 2, 0, f"{100 - top_share:.1f}%",
        ha="center", va="center")
ax.set(xlim=(0, 100), yticks=[], xlabel="Share of attributed committee contributions (%)",
       title="Top 10% of candidate IDs received about 60% of the amount")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.35), ncol=2, frameon=False)
plt.tight_layout()
plt.show()
No description has been provided for this image

WinRed-exclusion scenario, both cycles. The top group contains 261 of 2,603 candidate IDs, rounding 10% up to a whole candidate. Source: query 27. This shows concentration; the table above describes the original distribution.

Q3. Analysis plan: top 10 business payees by state

A preliminary ranking is feasible within four hours. A validated ranking is not: I estimate about 26 hours, or three to four working days. The aggregation itself is straightforward. Business identification, name standardization and payments through intermediaries take most of the time, and all three depend on manual review.

Source Records Dates in cycle Missing dates Reporting years
opex_2018 1,800,080 0 1,800,080 2017–2018
opex_2020 1,800,080 0 1,800,080 2017–2018
oppexp18 1,800,000 1,795,535 252 2017–2018
oppexp20 2,315,382 2,310,905 15 2019–2020

The query below produced the source assessment. Q3 asks for an analysis plan; I have not produced a business-payee ranking.

SQL: 20_qa_operating_expenditures

Open complete SQL file

-- 20_qa_operating_expenditures
-- Before proposing a business-payee ranking, I need to check whether
-- the expenditure sources cover the intended period and contain
-- usable payee names and states.
-- The two descriptive tables had identical inventory counts,
-- so I am comparing their actual date and reporting-year coverage.
WITH records AS (
  SELECT
    'opex_2018' AS source_table,
    2018 AS cycle,
    sub_id, rpt_yr, transaction_dt,
    transaction_amt, name, state, entity_tp
  FROM `bigquery-public-data.fec.opex_2018`
  UNION ALL
  SELECT
    'opex_2020', 2020,
    sub_id, rpt_yr, transaction_dt,
    transaction_amt, name, state, entity_tp
  FROM `bigquery-public-data.fec.opex_2020`
  UNION ALL
  SELECT
    'oppexp18', 2018,
    sub_id, rpt_yr, transaction_dt,
    transaction_amt, name, state, entity_tp
  FROM `bigquery-public-data.fec.oppexp18`
  UNION ALL
  SELECT
    'oppexp20', 2020,
    sub_id, rpt_yr, transaction_dt,
    transaction_amt, name, state, entity_tp
  FROM `bigquery-public-data.fec.oppexp20`
)
SELECT
  source_table,
  COUNT(*) AS n_rows,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  COUNTIF(sub_id IS NULL) AS n_missing_ids,
  MIN(rpt_yr) AS earliest_report_year,
  MAX(rpt_yr) AS latest_report_year,
  MIN(transaction_dt) AS earliest_date,
  MAX(transaction_dt) AS latest_date,
  COUNTIF(transaction_dt IS NULL) AS n_missing_dates,
  COUNTIF(
    transaction_dt BETWEEN
      DATE(cycle - 1, 1, 1) AND DATE(cycle, 12, 31)
  ) AS n_dates_in_cycle,
  COUNTIF(NULLIF(TRIM(name), '') IS NULL) AS n_missing_payee_names,
  COUNTIF(NULLIF(TRIM(state), '') IS NULL) AS n_missing_states,
  COUNTIF(NULLIF(TRIM(entity_tp), '') IS NULL) AS n_missing_entity_types,
  COUNTIF(transaction_amt < 0) AS n_negative_amounts,
  SUM(CAST(transaction_amt AS NUMERIC)) AS raw_total_amount
FROM records
GROUP BY source_table
ORDER BY source_table;

Approach and limitations

I would start with oppexp18 and oppexp20. Their reporting years match the requested cycles, and most dates fall within those periods. I ruled out opex_2020 for the 2020 analysis: its reporting years are 2017–2018 and every transaction date is missing. Its summary statistics also match opex_2018.

The alternative sources have missing names and states, negative amounts and implausible dates. oppexp18 contains exactly 1,800,000 records. That is a reason to verify coverage, although that count alone is not proof of truncation. opex_2018 has 80 more records and USD 377,626 more than oppexp18, so the two 2018 versions differ even apart from the missing dates.

I would first check record IDs, dates, memo entries and adjustments. Records without usable names or states would be accounted for separately. I would then standardize payee names and review likely matches among the largest recipients, using address details where available. Similar names alone would not justify merging businesses.

Intermediary payments need particular care. A processor or payroll provider may be the reported payee without being the final recipient. Supporting itemization must not be counted again as an additional payment. After applying those rules, I would aggregate accepted amounts by business and state, rank within each state, and review the leading results.

Questions for the stakeholder

I would confirm these before building the ranking:

  • Should "state" mean the payee’s reported address or the paying committee’s state?
  • Should the ranking use gross payments, or payments net of refunds and adjustments?
  • Should a business be combined with its subsidiaries or parent company?
  • Does a payment processor, payroll provider or conduit count as the business receiving the money?
  • Should the two cycles be combined or ranked separately?

Until those are settled, my starting assumption would be total gross payments across both cycles, grouped by the payee’s reported state.

Time estimate

Step Preliminary (hours) Validated (hours) What takes the extra time
Confirm scope with the stakeholder 0.25 0.5 Agreeing on the questions above and a few edge cases
Record checks: IDs, dates, memo entries, negative amounts and coverage 0.75 3 Comparing oppexp18 with opex_2018 by record ID to confirm whether oppexp18 is complete, and deciding how to treat 38,481 negative amounts and dates such as 9201-10-02
Identify business payees 0.5 4 Entity type ORG covers any organization, including government agencies and nonprofits, and 167,828 oppexp18 records have no entity type
Standardize payee names 1 8 Basic cleanup is quick, but merging spelling variants of the same business needs address checks and a review of each proposed match
Intermediary payments 0.5 6 Credit card, payroll and processor payments have to be traced to their supporting itemization so the money is not credited to the processor or counted twice
Aggregate and rank within each state 0.25 0.5 The ranking query itself is simple
Review the results and document exclusions 0.75 4 Reviewing the top 10 in every state, not just the largest ones, and walking the stakeholder through unresolved cases
Total 4 about 26

The four-hour version would contain the preliminary ranking, the SQL, the definitions and the value of excluded or unresolved records. I would spend the review time on cases large enough to change a top 10, and I would label the result as a ranking of identified business payees in the available records, not a complete ranking of all businesses.

Source query: 20_qa_operating_expenditures.

Source validation and modeling decisions

I compared table coverage before choosing sources. Similar names and matching totals were not sufficient evidence that two versions contained the same records.

Check Finding Decision
Individual record IDs 2018 has 21,628,973 unique IDs. 2020 has 195,482,945 rows and 88,823,302 unique IDs. Neither has missing IDs. Removed repeated copies using stable analysis fields within each cycle.
Repeated-ID differences 104,323 IDs differ in amendment indicator; the other checked fields agree. Kept the agreed analysis fields without choosing an amendment indicator.
Amount and date profile Positive refunds, memo records, non-individual entities and invalid dates occur in the sources. Applied type, entity, memo, sign and date filters together.
Alternative 2020 candidate sources 885,084 shared IDs; 2,745 descriptive-only and 4,081 pas220-only IDs. Kept the versions separate.
Candidate attribution 164 contribution records, worth USD 443,400 before scope filters, have candidate IDs in the descriptive source but not pas220. One USD 1,000 contribution has different populated IDs. Selected the descriptive source for Q2; attribution differences remain disclosed.
Committee-source overlap 275,881 qualifying IDs overlap in 2018 and 734,239 in 2020; amount, date and committee agree. Used the broader source alone for Q1.
Unmatched committee records 25 qualifying candidate records worth USD 39,000 are absent from the broader 2020 source. Disclosed the coverage difference and did not append the unmatched records.
Conduit reporting ActBlue’s side of earmarked donations is coded 24T and falls outside the filters; WinRed’s side is coded 15E and passes them. 1,916,040 one-to-one field matches between WinRed and recipient reports; other matches are ambiguous. Used a WinRed-exclusion scenario in the stakeholder summary and kept the reported-record measure alongside it. The scenario reduces overlap but does not prove each donation is counted once.
Monthly reconciliation Counts and amounts match for both sources; each has 48 distinct monthly rows. Used the reconciled monthly aggregate for charts and comparisons.

Deduplicating record IDs does not identify every repeated underlying donation. Likewise, retaining a populated candidate ID does not validate every attribution. These are separate questions from whether the SQL preserves the selected records.

Staging rows retain eligibility flags. Query 29 saves the deduplicated individual rows in a table clustered by cycle and committee; the staging view now reads that snapshot. The monthly aggregate remains the reporting layer for charts.

The repeated-ID comparison processed 14.73 GB in 13.821 seconds. I used job information to monitor execution cost and runtime; the reconciliation checks provide the evidence for aggregation accuracy.

Source queries: 01_source_inventory through 10_qa_committee_profile; 13_qa_other_committee_profile through 19_qa_monthly_reconciliation.

Model Grain Purpose Build query
stg_committee_contributions Source record and cycle Candidate attribution and Q2 eligibility 11
stg_committee_q1 Source record and cycle Broader committee activity and Q1 eligibility 16
individual_records_cached / stg_individual_q1 Selected record fields and cycle Saved deduplicated rows, including excluded records and eligibility flags 17, 29
fct_contributions Source type, cycle and record ID Q1 amounts, reporting and recipient committees, candidate attribution where available, and reporting-platform flag 18, 29
dim_committee Committee ID and cycle Committee names, types, party and state 29
dim_candidate Candidate ID and cycle Candidate names, party, office and incumbent status 29
agg_contributions_monthly Month and source type Materialized counts and gross amounts 18

The views preserve the selected source fields, not every original column. The monthly table supports repeated reporting without rescanning the individual source. Adding cycle to the record key keeps election-cycle extracts separate.

The dimensions have one row per ID and cycle. Multiple source labels are retained together rather than silently choosing one. Candidate attribution is attached only to matching committee records; individual receipts are not assigned to candidates through an assumed committee relationship. The platform flag identifies the reporting committee, not the original donor. Query 29 checks the richer fact view against the existing monthly counts and amounts before it is used for reporting.

The snapshot is a fixed extract, not an automatic refresh pipeline. Query 29 rebuilds it directly from the original sources. Query 18 can rebuild the monthly table; query 29 should run last to restore the enriched fact view. This keeps the original analysis reproducible while supporting repeated investigation without repeating the large deduplication.

For conduit-reported individual receipts, recipient_committee_id is the reporting conduit’s ID, not necessarily the final recipient committee.

Intermediary reporting and overlap

The committee-source comparison found 1,010,120 shared qualifying record IDs. Using one source for Q1 avoids adding those records twice. I also checked a different issue: the same donation appearing under separate IDs in a conduit’s report and a recipient’s report.

ActBlue and WinRed report their side of an earmarked donation with different transaction codes, so my filters treat them differently.

2020 individual records Reported by the conduit Reported by the recipient Counted under my filters
ActBlue 20,733,195 records coded 24T, USD 2,140,427,718 18,626,946 records coded 15E, USD 1,801,148,249 Recipient side only
WinRed 22,383,956 records coded 15E, USD 1,413,034,075 4,125,031 records coded 15E, USD 584,456,022 Both sides

My transaction filter excludes 24T, so ActBlue donations are counted through the recipient’s report. WinRed codes its own receipts 15E, the same as the recipient, so both sides pass the transaction and memo filters. ActBlue follows the same pattern in 2018, and WinRed has no records in the 2018 data. A blank memo code alone does not resolve conduit reporting.

WinRed’s other_id field identifies an intended recipient on all but 83 of its included records. I compared that ID with the receiving committee’s ID, together with the donor’s name, five-digit ZIP code, date and amount. Names were trimmed and converted to uppercase. The comparison returned 1,916,040 groups with exactly one record on each side, totaling USD 341,092,327 per side. Another 177,252 groups contained multiple records on one or both sides. Many records had no match under these fields.

These matches are strong evidence of repeated reporting, but the matching fields are not a unique donation identifier. I did not automatically delete matching records or treat unmatched records as proof of separate donations. The FEC’s reporting guidance explains that recipients disclose the original contributor and the date the conduit received the contribution. WinRed’s conduit guidance also describes reporting by both parties.

To measure the effect on the answers, I excluded WinRed-reported 15E receipts and WinRed’s outgoing committee contributions while retaining recipient-reported individual receipts. All other filters stayed the same.

Measure, both cycles Reported-record measure WinRed-exclusion scenario
Individual records 79,847,961 57,464,005
Individual amount USD 14,080,279,264 USD 12,667,245,189
Committee records 1,750,504 652,869
Committee amount USD 2,720,479,967 USD 2,579,862,501
Peak month October 2020 October 2020
Peak-month growth 12.50% 4.44%
Candidate IDs in Q2 2,614 2,603
Largest candidate total USD 49,608,977 USD 6,283,681

The timing of the peak and the concentration of candidate receipts hold under this test. Their size is sensitive to how conduit activity is counted.

The summary uses the WinRed-exclusion scenario. It reduces overlapping reporting but also removes unmatched conduit records. A missing match can reflect differences in fields, reporting coverage or itemization; it is not proof that a recipient never reported the donation. The scenario is therefore not a fully deduplicated measure of original donor funding.

Committee-side follow-up

Query 28 checked reporting committees whose names contain ActBlue or WinRed in both committee sources. ActBlue has six included Q1 records totaling USD 237,000: five records in 2018 worth USD 37,000 and one in 2020 worth USD 200,000. It has no included Q2 records. WinRed has 1,097,635 included Q1 records worth USD 140,617,466 and 472,869 included Q2 records worth USD 66,674,728. The Q1 excluded amounts match query 27 exactly. The Q2 amount is USD 73,565 higher than the USD 66,601,163 removed in query 27, which restricts candidate totals to records with a populated candidate ID.

This closes the platform-coding check for the two sources. It does not trace the original funding behind ActBlue’s six records or payments routed through other committees. I retained those records under the stated filters and made no further adjustment to the totals.

Source queries: 23_qa_intermediary_reporting through 28_qa_committee_platform_reporting. Results reviewed September 14, 2026.

SQL: 27_qa_winred_sensitivity

Open complete SQL file

-- 27_qa_winred_sensitivity
-- Measure the effect of excluding WinRed conduit receipts and outgoing payments.
-- This is a reporting-side sensitivity test, not a complete donation deduplication.
WITH excluded AS (
  SELECT
    'individual' AS source_type,
    transaction_dt,
    amount
  FROM `fec-contributions-analysis.fec_analysis.stg_individual_q1`
  WHERE is_q1_in_scope
    AND cmte_id = 'C00694323'
    AND transaction_tp = '15E'
  UNION ALL
  SELECT
    'committee',
    transaction_dt,
    amount
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_q1`
  WHERE is_q1_in_scope
    AND cmte_id = 'C00694323'
),
monthly AS (
  SELECT
    source_type,
    DATE_TRUNC(transaction_dt, MONTH) AS contribution_month,
    COUNT(*) AS n_records,
    SUM(amount) AS amount
  FROM excluded
  GROUP BY 1, 2
),
candidate_totals AS (
  SELECT
    cand_id,
    SUM(amount) AS amount
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
  WHERE is_q2_in_scope
    AND cand_id IS NOT NULL
    AND cmte_id != 'C00694323'
  GROUP BY 1
),
ranked AS (
  SELECT
    *,
    ROW_NUMBER() OVER (ORDER BY amount DESC, cand_id) AS ranking,
    COUNT(*) OVER () AS n_candidates
  FROM candidate_totals
),
candidate_summary AS (
  SELECT
    COUNT(*) AS n_candidates,
    SUM(amount) AS amount,
    MIN(amount) AS minimum,
    AVG(amount) AS mean,
    APPROX_QUANTILES(amount, 100)[OFFSET(50)] AS approximate_median,
    MAX(amount) AS maximum,
    STDDEV_POP(amount) AS population_stddev,
    100 * SAFE_DIVIDE(
      SUM(IF(ranking <= CEIL(n_candidates * .1), amount, 0)),
      SUM(amount)
    ) AS top_ten_percent_share_pct,
    ARRAY_AGG(STRUCT(cand_id, amount) ORDER BY ranking LIMIT 1) AS largest_candidate
  FROM ranked
)
SELECT
  'excluded_q1_monthly' AS result_type,
  TO_JSON_STRING(m) AS details
FROM monthly AS m
UNION ALL
SELECT
  'q2_without_winred',
  TO_JSON_STRING(c)
FROM candidate_summary AS c;

Scope and next steps

Scope

I focused the analysis on the three questions, source quality and checks that could change the answers. The report separates the findings from the detailed SQL and QA appendix so a reviewer can read the conclusions first and inspect the supporting work where needed.

Completed refinements

I materialized the deduplicated individual records so repeated analysis no longer reruns that step. I also added recipient and reporting-committee fields, a reporting-platform flag, candidate attribution where available, and committee and candidate dimensions. The updated model reconciles to the original totals. Q2 includes a concentration chart showing the share received by the top 10% of candidate IDs.

With more time

I would add a full Q2 distribution chart using candidate-level totals, with a log scale to make the smaller amounts visible alongside the largest recipients. The current chart shows concentration, not the shape of the full distribution. I would also automate refreshes and reconciliation for the saved individual table, and extend the conduit fields beyond the named reporting platforms to trace more of the path from the original donor to the recipient. Those additions would support recurring reporting and further analysis beyond this submission.

Technical appendix: complete SQL and captured results

Appendix: SQL and detailed results

The complete setup and QA scripts are below. Answer queries 12, 20 and 21, and the sensitivity query 27, appear with their analyses above. All 29 queries are also available as separate files in the SQL folder. Results are captured outputs from the analysis; their provenance is included with each table.

The detailed appendix is available in this HTML and in the SQL and results folders. It is omitted from the printed reading copy.

01_source_inventory

Lists source tables, row counts and rounded sizes.

SQL: 01_source_inventory

Open complete SQL file

SELECT
  table_id,
  row_count,
  ROUND(size_bytes / POW(1024, 3), 2) AS size_gib
FROM `bigquery-public-data.fec.__TABLES__`
WHERE REGEXP_CONTAINS(table_id, r'(18|20)$')
ORDER BY table_id;
Full results: 01_source_inventory (27 rows)

Initial source-inventory result screenshots; rounded GiB

table_idrow_countsize_gib
candidate_201873710
candidate_202077580
candidate_committee_201871800
candidate_committee_202070550
ccl1871800
ccl2070610
cm18190220
cm20182880
cn1873710
cn2077710
committee_2018190220
committee_2020182860
committee_contributions_20183498200.05
committee_contributions_20208878290.14
indiv18216289733.82
indiv2019548294533.77
individuals_ingest_2020344841836.04
opex_201818000800.29
opex_202018000800.29
oppexp1818000000.3
oppexp2023153820.38
oth1810151980.17
oth2074550261.31
other_committee_tx_201810151980.17
other_committee_tx_202074016611.3
pas2183498200.05
pas2208891650.14

02_source_schema

Lists column names and data types.

SQL: 02_source_schema

Open complete SQL file

-- 02_source_schema: inspect all 2018/2020 table schemas
SELECT
  table_name,
  COUNT(*) AS n_columns,
  STRING_AGG(
    CONCAT(column_name, ' (', data_type, ')'),
    ', ' ORDER BY ordinal_position
  ) AS columns_and_types
FROM `bigquery-public-data.fec.INFORMATION_SCHEMA.COLUMNS`
WHERE REGEXP_CONTAINS(table_name, r'(18|20)$')
GROUP BY table_name
ORDER BY table_name;
Full results: 02_source_schema (27 rows)

Original JSON export supplied during analysis

table_namen_columnscolumns_and_types
candidate_201815cand_id (STRING), cand_name (STRING), cand_pty_affiliation (STRING), cand_election_yr (INT64), cand_office_st (STRING), cand_office (STRING), cand_office_district (STRING), cand_ici (STRING), cand_status (STRING), cand_pcc (STRING), cand_st1 (STRING), cand_st2 (STRING), cand_city (STRING), cand_st (STRING), cand_zip (STRING)
candidate_202015cand_id (STRING), cand_name (STRING), cand_pty_affiliation (STRING), cand_election_yr (INT64), cand_office_st (STRING), cand_office (STRING), cand_office_district (STRING), cand_ici (STRING), cand_status (STRING), cand_pcc (STRING), cand_st1 (STRING), cand_st2 (STRING), cand_city (STRING), cand_st (STRING), cand_zip (STRING)
candidate_committee_20187cand_id (STRING), cand_election_yr (INT64), fec_election_yr (INT64), cmte_id (STRING), cmte_tp (STRING), cmte_design (STRING), linkage_id (INT64)
candidate_committee_20207cand_id (STRING), cand_election_yr (INT64), fec_election_yr (INT64), cmte_id (STRING), cmte_tp (STRING), cmte_design (STRING), linkage_id (INT64)
ccl187cand_id (STRING), cand_election_yr (INT64), fec_election_yr (INT64), cmte_id (STRING), cmte_tp (STRING), cmte_design (STRING), linkage_id (INT64)
ccl207cand_id (STRING), cand_election_yr (INT64), fec_election_yr (INT64), cmte_id (STRING), cmte_tp (STRING), cmte_design (STRING), linkage_id (INT64)
cm1815cmte_id (STRING), cmte_nm (STRING), tres_nm (STRING), cmte_st1 (STRING), cmte_st2 (STRING), cmte_city (STRING), cmte_st (STRING), cmte_zip (STRING), cmte_dsgn (STRING), cmte_tp (STRING), cmte_pty_affiliation (STRING), cmte_filing_freq (STRING), org_tp (STRING), connected_org_nm (STRING), cand_id (STRING)
cm2015cmte_id (STRING), cmte_nm (STRING), tres_nm (STRING), cmte_st1 (STRING), cmte_st2 (STRING), cmte_city (STRING), cmte_st (STRING), cmte_zip (STRING), cmte_dsgn (STRING), cmte_tp (STRING), cmte_pty_affiliation (STRING), cmte_filing_freq (STRING), org_tp (STRING), connected_org_nm (STRING), cand_id (STRING)
cn1815cand_id (STRING), cand_name (STRING), cand_pty_affiliation (STRING), cand_election_yr (INT64), cand_office_st (STRING), cand_office (STRING), cand_office_district (STRING), cand_ici (STRING), cand_status (STRING), cand_pcc (STRING), cand_st1 (STRING), cand_st2 (STRING), cand_city (STRING), cand_st (STRING), cand_zip (STRING)
cn2015cand_id (STRING), cand_name (STRING), cand_pty_affiliation (STRING), cand_election_yr (INT64), cand_office_st (STRING), cand_office (STRING), cand_office_district (STRING), cand_ici (STRING), cand_status (STRING), cand_pcc (STRING), cand_st1 (STRING), cand_st2 (STRING), cand_city (STRING), cand_st (STRING), cand_zip (STRING)
committee_201815cmte_id (STRING), cmte_nm (STRING), tres_nm (STRING), cmte_st1 (STRING), cmte_st2 (STRING), cmte_city (STRING), cmte_st (STRING), cmte_zip (STRING), cmte_dsgn (STRING), cmte_tp (STRING), cmte_pty_affiliation (STRING), cmte_filing_freq (STRING), org_tp (STRING), connected_org_nm (STRING), cand_id (STRING)
committee_202015cmte_id (STRING), cmte_nm (STRING), tres_nm (STRING), cmte_st1 (STRING), cmte_st2 (STRING), cmte_city (STRING), cmte_st (STRING), cmte_zip (STRING), cmte_dsgn (STRING), cmte_tp (STRING), cmte_pty_affiliation (STRING), cmte_filing_freq (STRING), org_tp (STRING), connected_org_nm (STRING), cand_id (STRING)
committee_contributions_201822cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (INT64), other_id (STRING), cand_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
committee_contributions_202022cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (INT64), other_id (STRING), cand_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
indiv1821cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
indiv2021cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
individuals_ingest_202021cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
opex_201825cmte_id (STRING), amndt_ind (STRING), rpt_yr (INT64), rpt_tp (STRING), image_num (INT64), line_num (STRING), form_tp_cd (STRING), sched_tp_cd (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), transaction_pgi (STRING), purpose (STRING), category (STRING), category_desc (STRING), memo_cd (STRING), memo_text (STRING), entity_tp (STRING), sub_id (INT64), file_num (INT64), tran_id (STRING), back_ref_tran_id (STRING)
opex_202025cmte_id (STRING), amndt_ind (STRING), rpt_yr (INT64), rpt_tp (STRING), image_num (INT64), line_num (STRING), form_tp_cd (STRING), sched_tp_cd (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), transaction_pgi (STRING), purpose (STRING), category (STRING), category_desc (STRING), memo_cd (STRING), memo_text (STRING), entity_tp (STRING), sub_id (INT64), file_num (INT64), tran_id (STRING), back_ref_tran_id (STRING)
oppexp1825cmte_id (STRING), amndt_ind (STRING), rpt_yr (INT64), rpt_tp (STRING), image_num (INT64), line_num (STRING), form_tp_cd (STRING), sched_tp_cd (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), transaction_pgi (STRING), purpose (STRING), category (STRING), category_desc (STRING), memo_cd (STRING), memo_text (STRING), entity_tp (STRING), sub_id (INT64), file_num (INT64), tran_id (STRING), back_ref_tran_id (STRING)
oppexp2025cmte_id (STRING), amndt_ind (STRING), rpt_yr (INT64), rpt_tp (STRING), image_num (INT64), line_num (STRING), form_tp_cd (STRING), sched_tp_cd (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), transaction_pgi (STRING), purpose (STRING), category (STRING), category_desc (STRING), memo_cd (STRING), memo_text (STRING), entity_tp (STRING), sub_id (INT64), file_num (INT64), tran_id (STRING), back_ref_tran_id (STRING)
oth1821cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
oth2021cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
other_committee_tx_201821cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
other_committee_tx_202021cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (FLOAT64), other_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
pas21822cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (INT64), other_id (STRING), cand_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)
pas22022cmte_id (STRING), amndt_ind (STRING), rpt_tp (STRING), transaction_pgi (STRING), image_num (INT64), transaction_tp (STRING), entity_tp (STRING), name (STRING), city (STRING), state (STRING), zip_code (STRING), employer (STRING), occupation (STRING), transaction_dt (DATE), transaction_amt (INT64), other_id (STRING), cand_id (STRING), tran_id (STRING), file_num (INT64), memo_cd (STRING), memo_text (STRING), sub_id (INT64)

03_indiv_id_check

Counts missing and repeated individual record IDs.

SQL: 03_indiv_id_check

Open complete SQL file

-- 03_indiv_id_check
-- Before counting contributions, I want to check whether any records
-- have missing or repeated IDs, since that could affect my totals.
-- A repeated ID needs investigation; it does not automatically mean
-- the row should be removed.
WITH id_counts AS (
  SELECT
    'indiv18' AS source_table,
    COUNT(*) AS n_rows,
    COUNTIF(sub_id IS NULL) AS n_null_ids,
    COUNT(sub_id) AS n_nonnull_ids,
    COUNT(DISTINCT sub_id) AS n_distinct_ids
  FROM `bigquery-public-data.fec.indiv18`
  UNION ALL
  SELECT
    'indiv20' AS source_table,
    COUNT(*) AS n_rows,
    COUNTIF(sub_id IS NULL) AS n_null_ids,
    COUNT(sub_id) AS n_nonnull_ids,
    COUNT(DISTINCT sub_id) AS n_distinct_ids
  FROM `bigquery-public-data.fec.indiv20`
)
SELECT
  source_table,
  n_rows,
  n_null_ids,
  n_nonnull_ids,
  n_distinct_ids,
  n_nonnull_ids - n_distinct_ids AS n_repeated_id_rows
FROM id_counts
ORDER BY source_table;
Full results: 03_indiv_id_check (2 rows)

Result supplied during the analysis, September 14, 2026

source_tablen_rowsn_null_idsn_nonnull_idsn_distinct_idsn_repeated_id_rows
indiv1821628973021628973216289730
indiv20195482945019548294588823302106659643

04_qa_indiv_repeated_ids

Compares selected fields within repeated 2020 IDs.

SQL: 04_qa_indiv_repeated_ids

Open complete SQL file

-- 04_qa_indiv_repeated_ids
-- My previous check found repeated IDs in the 2020 table.
-- Before removing any records, I want to see whether rows with
-- the same ID agree on their amount, date, committee, and other
-- details used in this analysis.
-- This checks the fields below, not every column in the table.
WITH records AS (
  SELECT
    sub_id,
    TO_JSON_STRING(
      STRUCT(
        cmte_id,
        transaction_dt,
        transaction_amt,
        transaction_tp,
        entity_tp,
        memo_cd,
        amndt_ind,
        other_id,
        tran_id,
        file_num
      )
    ) AS contribution_details
  FROM `bigquery-public-data.fec.indiv20`
),
id_checks AS (
  SELECT
    sub_id,
    COUNT(*) AS n_copies,
    COUNT(DISTINCT contribution_details) AS n_detail_versions
  FROM records
  GROUP BY sub_id
)
SELECT
  COUNT(*) AS n_distinct_ids,
  COUNTIF(n_copies = 1) AS n_ids_seen_once,
  COUNTIF(n_copies > 1) AS n_repeated_ids,
  COUNTIF(
    n_copies > 1 AND n_detail_versions = 1
  ) AS n_repeated_ids_matching_checked_fields,
  COUNTIF(
    n_detail_versions > 1
  ) AS n_ids_with_conflicting_details,
  MAX(n_copies) AS max_copies_per_id,
  SUM(n_copies - 1) AS n_extra_id_occurrences
FROM id_checks;
Full results: 04_qa_indiv_repeated_ids (1 row)

Result supplied during the analysis, September 14, 2026

n_distinct_idsn_ids_seen_oncen_repeated_idsn_repeated_ids_matching_checked_fieldsn_ids_with_conflicting_detailsmax_copies_per_idn_extra_id_occurrences
8882330294836287874940877706171043235106659643

05_qa_indiv_conflicting_fields

Identifies the fields that differ for repeated IDs.

SQL: 05_qa_indiv_conflicting_fields

Open complete SQL file

-- 05_qa_indiv_conflicting_fields
-- I found 104,323 IDs with conflicting details.
-- I now want to identify which fields differ, especially amounts,
-- dates, and committees, before deciding how to handle these records.
-- I include nulls in the comparison so missing values are not overlooked.
WITH field_checks AS (
  SELECT
    sub_id,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      transaction_amt AS value
    ))) AS amount_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      transaction_dt AS value
    ))) AS date_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      cmte_id AS value
    ))) AS committee_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      transaction_tp AS value
    ))) AS transaction_type_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      entity_tp AS value
    ))) AS entity_type_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      memo_cd AS value
    ))) AS memo_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      amndt_ind AS value
    ))) AS amendment_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      other_id AS value
    ))) AS other_id_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      tran_id AS value
    ))) AS transaction_id_versions,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      file_num AS value
    ))) AS file_versions
  FROM `bigquery-public-data.fec.indiv20`
  GROUP BY sub_id
)
SELECT
  COUNTIF(amount_versions > 1) AS ids_with_amount_changes,
  COUNTIF(date_versions > 1) AS ids_with_date_changes,
  COUNTIF(committee_versions > 1) AS ids_with_committee_changes,
  COUNTIF(transaction_type_versions > 1) AS ids_with_type_changes,
  COUNTIF(entity_type_versions > 1) AS ids_with_entity_changes,
  COUNTIF(memo_versions > 1) AS ids_with_memo_changes,
  COUNTIF(amendment_versions > 1) AS ids_with_amendment_changes,
  COUNTIF(other_id_versions > 1) AS ids_with_other_id_changes,
  COUNTIF(transaction_id_versions > 1) AS ids_with_tran_id_changes,
  COUNTIF(file_versions > 1) AS ids_with_file_changes
FROM field_checks;
Full results: 05_qa_indiv_conflicting_fields (1 row)

Result supplied during the analysis, September 14, 2026

ids_with_amount_changesids_with_date_changesids_with_committee_changesids_with_type_changesids_with_entity_changesids_with_memo_changesids_with_amendment_changesids_with_other_id_changesids_with_tran_id_changesids_with_file_changes
000000104323000

06_qa_indiv_profile

Profiles individual transaction types, memo flags, amounts and dates.

SQL: 06_qa_indiv_profile

Open complete SQL file

-- 06_qa_indiv_profile
-- I want to understand the transaction categories before deciding
-- which records belong in the individual contribution totals.
-- For 2020, I remove repeated copies using the record ID and the
-- analysis fields that my previous checks showed were consistent.
-- I keep negative amounts, memo rows, and unusual dates visible
-- here so I can assess them before applying filters.
WITH records AS (
  SELECT
    2018 AS cycle,
    sub_id,
    entity_tp,
    transaction_tp,
    memo_cd,
    transaction_dt,
    transaction_amt
  FROM `bigquery-public-data.fec.indiv18`
  UNION ALL
  SELECT DISTINCT
    2020 AS cycle,
    sub_id,
    entity_tp,
    transaction_tp,
    memo_cd,
    transaction_dt,
    transaction_amt
  FROM `bigquery-public-data.fec.indiv20`
)
SELECT
  cycle,
  entity_tp,
  transaction_tp,
  memo_cd,
  COUNT(*) AS n_records,
  COUNTIF(transaction_amt > 0) AS n_positive,
  COUNTIF(transaction_amt < 0) AS n_negative,
  COUNTIF(transaction_amt = 0) AS n_zero,
  COUNTIF(transaction_amt IS NULL) AS n_missing_amount,
  SUM(CAST(transaction_amt AS NUMERIC)) AS total_amount,
  COUNTIF(transaction_dt IS NULL) AS n_missing_date,
  COUNTIF(
    transaction_dt < DATE(cycle - 1, 1, 1)
    OR transaction_dt > DATE(cycle, 12, 31)
  ) AS n_outside_cycle_dates
FROM records
GROUP BY cycle, entity_tp, transaction_tp, memo_cd
ORDER BY cycle, n_records DESC;
Full results: 06_qa_indiv_profile (248 rows)

Original JSON export supplied during analysis

cycleentity_tptransaction_tpmemo_cdn_recordsn_positiven_negativen_zeron_missing_amounttotal_amountn_missing_daten_outside_cycle_dates
2018IND15NULL10032679964244516432373802023210533821619189
2018IND24TNULL575944153252594338253570673295304038
2018IND15ENULL5259480524553062357715070344027101094
2018IND22YNULL238222224836200111385064543054115
2018IND10NULL200529200470518092600966304
2018IND15X59060401411846945002409004048236
2018CAN15CNULL1230712258940010648508707
2018IND24IX1132411317700965569800
2018IND24INULL82008198200551989600
2018IND22YX79207909110010265560644
2018ORG10NULL503350041613034533282200
2018ORG15NULL4843482021201710510801
2018ORG15EX2946294420020670800
2018IND15EX2434212031130163049001
2018ORG11NULL23192317200830356300
2018NULL15ENULL1927192700048531400
2018NULL15NULL186618590702601068125
2018ORG15X1823181661037125600
2018IND31NULL175817479204094523600
2018IND15CNULL16331620112039438379019
2018PAC15EX1488148404070223200
2018PAC24IX13521352000209832400
2018IND24TX96596500062667600
2018ORG22YNULL8923845071029214300
2018CAN15CX7787733203463560604
2018PAC10NULL77276822011380430100
2018COM10NULL6746695005601565800
2018NULL11NULL590590000271475000
2018IND20YNULL576574200192021601
2018IND32NULL5655650001520446700
2018CAN15NULL48948810028452500
2018IND11NULL43643510095537600
2018CCM24IX40540500058427500
2018NULL15EX38938900063531200
2018IND10X315312030656935921
2018IND15CX281273170559029000
2018NULL22YNULL23121984051852910
2018ORG15ENULL209208100127674800
2018IND30NULL188187100761078600
2018NULL22YX1301300001490409
2018NULL15X886818201057810
2018IND31X8383000127732900
2018ORG11X8160210011109701
2018NULL10NULL736904017009413
2018COM22YNULL68680005692000
2018CCM24INULL636300016299500
2018CCM10NULL5656000128737500
2018ORG21YNULL524930010140700
2018CCM15EX48480005551400
2018ORG20YNULL434300079042200
2018ORG10X414100042953900
2018CAN22YNULL393900050488501
2018NULL11X382216001450000
2018CCM15NULL38370101875600
2018PAC15X38380002843400
2018COM15EX38380004677500
2018CAN15ENULL37370003239400
2018PAC15NULL37370007666000
2018CCM15ENULL37370006030000
2018ORG31TNULL333210062510000
2018COM15NULL33330002423700
2018NULL15CNULL33310203167600
2018PAC15ENULL313010021210800
2018CCM24TNULL25241002412800
2018PTY10NULL242400035922100
2018CCM22YNULL21201003375000
2018PTY15NULL21210001662700
2018PAC11NULL21192003990000
2018COM15X1816020790200
2018ORG31NULL171700047632700
2018PAC24TNULL16160004140000
2018NULL21YNULL14140002590000
2018COM20YNULL1414000154467000
2018IND32X131300044427502
2018CAN15X13130001390300
2018IND20YX11110003946700
2018COM11NULL10100003320000
2018COM10X10100006051500
2018PAC10X9801064427900
2018ORG31TX8800013990000
2018CAN10NULL880005930000
2018COM15ENULL88000630800
2018PTY15EX660002804400
2018CAN22YX55000124700
2018NULL10X550005569900
2018PAC22YNULL55000747900
2018ORG32TNULL4400021430000
2018PAC20YNULL4400010446900
2018ORG24IX44000300000
2018IND21YNULL44000970000
2018CCM15CNULL44000222900
2018PAC24TX33000210000
2018NULL32NULL3300013320600
2018COM24INULL33000475000
2018PAC24INULL32100-300000
2018ORG24INULL30300-400000
2018ORG24TNULL33000106500
2018COM31NULL2200017890000
2018CCM15X22000150000
2018ORG32NULL2200014780000
2018IND11X22000730000
2018PTY15ENULL220001027600
2018ORG15CX22000393400
2018NULL31NULL2200010670000
2018ORG30NULL2200016780000
2018NULL15CX22000412300
2018ORG32TX2200011570000
2018CAN15EX2200027300
2018CCM32NULL220004550000
2018ORG22YX22000370000
2018COM15CX110002500
2018ORG24TX110002500
2018COM21YNULL110002000000
2018PTY24INULL11000100000
2018COM11X11000196100
2018CCM11NULL110001475000
2018ORG21YX11000270000
2018IND31TX11000110000
2018NULL24TNULL1100020000
2018ORG30TNULL110001425800
2020IND15ENULL512165095039225812773669651504399939509012818
2020IND24TNULL2105787120831325206574199720216337554700
2020IND15NULL139196821358940966157264116034397908907123
2020IND22YNULL11978191168180213827501024313875501
2020IND10NULL7821227818172703502027155959081
2020IND15X540609274293244776215400343995340136
2020IND15EX17085139923051420628155405
2020IND24INULL1320426340105700128936600
2020IND24IX12726127180801249797700
2020CAN15CNULL126411259174301457760539030
2020ORG10NULL7213720427089933590508
2020IND31NULL6666661748104201787000
2020ORG15NULL6463645111103011508800
2020IND20YNULL470347020101683987200
2020COM15EX3770377000030784694900
2020ORG15X3459344216107410589302
2020ORG11NULL27032697600979539300
2020IND32NULL211421104002982356800
2020PAC15EX179117900106091720300
2020PAC10NULL1528152620033972755400
2020CAN15NULL1460145802054919900
2020IND10X100999018101391242400
2020CAN15CX9999943205599235308
2020PAC24IX867867000150913800
2020COM10NULL8458441008152074700
2020PAC15NULL794692010208336800
2020IND24TX671671000216451400
2020ORG22YNULL6295923430222974000
2020ORG15EX5535510201888468000
2020CCM15CNULL5015010003734980176
2020COM24TX4794790003942601100
2020ORG15ENULL35635060094485400
2020IND11NULL34434400076092500
2020IND30NULL3073061001314338100
2020IND15CNULL256254020101596106
2020COM15X2262260005110400
2020IND22YX19419013026985000
2020CCM24TNULL1621620009702500
2020IND32X1401400003886632019
2020PAC15ENULL13413400040050200
2020CCM15NULL13213200015019900
2020IND31X122606200105182006
2020CCM15ENULL999801014100200
2020CCM10NULL999900079240900
2020CCM24IX797900010138500
2020ORG11X7860180022344600
2020IND42YNULL707000015466600
2020ORG10X6262000274913800
2020ORG20YNULL5755200336004600
2020CAN22YNULL5451210192592600
2020ORG21YX494900024229400
2020PAC10X494810032233800
2020PAC24TNULL464600013022800
2020NULL10NULL4545000106396200
2020ORG21YNULL44440009480500
2020ORG24TNULL434300015800000
2020IND15CX404000035840900
2020IND15Y38371001550300
2020COM15NULL37370002603400
2020PTY10NULL292801010835300
2020IND20YX27270005490300
2020CAN15X25250002548100
2020CAN15ENULL2222000482300
2020ORG24IX2121000966000
2020NULL15NULL20200004803900
2020PTY15NULL19190007001100
2020ORG31NULL191900083515300
2020IND11X17116001190000
2020PAC32NULL1616000123982500
2020COM11NULL15150004230000
2020ORG32TNULL141400041510000
2020PAC15X13130001245900
2020ORG31TNULL101000055980000
2020IND30X10910049103000
2020COM15ENULL99000324500
2020IND15CY9900012900
2020CAN10NULL99000885000
2020ORG32NULL9900041105500
2020IND21YNULL86200760000
2020CCM24INULL871002327500
2020ORG30NULL8800025358600
2020PAC11NULL770001580000
2020PAC31NULL7700028750000
2020ORG30TNULL6600049050000
2020IND41YNULL6600022500
2020NULL22YY6600050000
2020ORG32TX550006610000
2020COM20YNULL5500016772600
2020COM32NULL44000316286200
2020PTY15EX44000433500
2020COM22YNULL44000374300
2020ORG15CNULL3300077000
2020PTY15ENULL33000275000
2020IND15EY3300030000
2020CCM32NULL330003013200
2020NULL22YNULL330001020000
2020NULL10X3300089400
2020ORG42YNULL33000148200
2020PAC20YNULL2200017687300
2020PAC21YX220001500000
2020CCM15CX2200024100
2020CCM15EX220007500
2020PTY24INULL220001000000
2020CCM22YNULL21100475000
2020NULL15CNULL22000754200
2020CCM31NULL2200010000000
2020CAN24IX22000250000
2020ORG31TX21100230000
2020CCM30NULL2200012500000
2020COM21YX220002000000
2020PAC24INULL220005200000
2020COM24TNULL11000165900
2020PTY15CNULL110005000
2020IND21YX11000341400
2020PTY24TNULL11000200000
2020PTY15X1100025000
2020ORG22YX110002000
2020CCM24TX1100010000
2020PAC11X11000149300
2020CCM21YX11000300000
2020NULL15X11000200000
2020NULL20YNULL1100010000
2020IND30TNULL110003550000
2020COM31TNULL11000795400
2020ORG15CX110005000
2020PTY22YNULL11000375000
2020ORG24INULL11000100100
2020CAN15EX10100-280000

07_qa_committee_source_comparison

Compares the four candidate-contribution source versions.

SQL: 07_qa_committee_source_comparison

Open complete SQL file

-- 07_qa_committee_source_comparison
-- Similar committee tables have different row counts.
-- I am comparing their IDs, dates, and filing numbers before
-- choosing a source. More rows alone do not prove a better source.
WITH sources AS (
  SELECT
    'pas218' AS source_table,
    sub_id, transaction_dt, file_num, transaction_amt
  FROM `bigquery-public-data.fec.pas218`
  UNION ALL
  SELECT
    'committee_contributions_2018',
    sub_id, transaction_dt, file_num, transaction_amt
  FROM `bigquery-public-data.fec.committee_contributions_2018`
  UNION ALL
  SELECT
    'pas220',
    sub_id, transaction_dt, file_num, transaction_amt
  FROM `bigquery-public-data.fec.pas220`
  UNION ALL
  SELECT
    'committee_contributions_2020',
    sub_id, transaction_dt, file_num, transaction_amt
  FROM `bigquery-public-data.fec.committee_contributions_2020`
)
SELECT
  source_table,
  COUNT(*) AS n_rows,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  COUNTIF(sub_id IS NULL) AS n_missing_ids,
  COUNTIF(transaction_dt IS NULL) AS n_missing_dates,
  MIN(transaction_dt) AS earliest_date,
  MAX(transaction_dt) AS latest_date,
  MAX(file_num) AS highest_file_number,
  SUM(CAST(transaction_amt AS NUMERIC)) AS raw_total_amount
FROM sources
GROUP BY source_table
ORDER BY source_table;
Full results: 07_qa_committee_source_comparison (4 rows)

Result supplied during the analysis, September 14, 2026

source_tablen_rowsn_distinct_idsn_missing_idsn_missing_datesearliest_datelatest_datehighest_file_numberraw_total_amount
committee_contributions_2018349820349820020402010-09-302117-08-2316647041881597097
committee_contributions_2020887829887829056062015-05-072022-10-2318515184042274870
pas218349820349820020402010-09-302117-08-2316331931881597097
pas220889165889165055832015-05-072022-10-2316342154040793861

08_qa_committee_2020_overlap

Compares IDs and fields between the two 2020 candidate sources.

SQL: 08_qa_committee_2020_overlap

Open complete SQL file

-- 08_qa_committee_2020_overlap
-- I want to distinguish extra records from changed records.
-- Both tables passed the unique-ID check, so joining on sub_id
-- will not multiply rows.
WITH compared AS (
  SELECT
    CASE
      WHEN p.sub_id IS NULL THEN 'descriptive_only'
      WHEN d.sub_id IS NULL THEN 'pas220_only'
      ELSE 'shared_id'
    END AS match_status,
    p.transaction_amt AS pas_amount,
    d.transaction_amt AS descriptive_amount,
    p.transaction_amt IS DISTINCT FROM d.transaction_amt
      AS amount_differs,
    p.transaction_dt IS DISTINCT FROM d.transaction_dt
      AS date_differs,
    p.cand_id IS DISTINCT FROM d.cand_id
      AS candidate_differs,
    p.cmte_id IS DISTINCT FROM d.cmte_id
      AS committee_differs,
    p.transaction_tp IS DISTINCT FROM d.transaction_tp
      AS type_differs,
    p.memo_cd IS DISTINCT FROM d.memo_cd
      AS memo_differs,
    p.file_num IS DISTINCT FROM d.file_num
      AS file_differs
  FROM `bigquery-public-data.fec.pas220` AS p
  FULL OUTER JOIN
    `bigquery-public-data.fec.committee_contributions_2020` AS d
    ON p.sub_id = d.sub_id
)
SELECT
  match_status,
  COUNT(*) AS n_ids,
  SUM(CAST(pas_amount AS NUMERIC)) AS pas_total,
  SUM(CAST(descriptive_amount AS NUMERIC)) AS descriptive_total,
  COUNTIF(match_status = 'shared_id' AND amount_differs)
    AS shared_ids_amount_differs,
  COUNTIF(match_status = 'shared_id' AND date_differs)
    AS shared_ids_date_differs,
  COUNTIF(match_status = 'shared_id' AND candidate_differs)
    AS shared_ids_candidate_differs,
  COUNTIF(match_status = 'shared_id' AND committee_differs)
    AS shared_ids_committee_differs,
  COUNTIF(match_status = 'shared_id' AND type_differs)
    AS shared_ids_type_differs,
  COUNTIF(match_status = 'shared_id' AND memo_differs)
    AS shared_ids_memo_differs,
  COUNTIF(match_status = 'shared_id' AND file_differs)
    AS shared_ids_file_differs
FROM compared
GROUP BY match_status
ORDER BY match_status;
Full results: 08_qa_committee_2020_overlap (3 rows)

Result supplied during the analysis, September 14, 2026

match_statusn_idspas_totaldescriptive_totalshared_ids_amount_differsshared_ids_date_differsshared_ids_candidate_differsshared_ids_committee_differsshared_ids_type_differsshared_ids_memo_differsshared_ids_file_differs
descriptive_only2745NULL921882850000000
pas220_only408190707276NULL0000000
shared_id88508439500865853950086585003290000

09_qa_candidate_id_differences

Examines candidate attribution differences.

SQL: 09_qa_candidate_id_differences

Open complete SQL file

-- 09_qa_candidate_id_differences
-- The shared records agree on amounts, but 329 candidate IDs differ.
-- I want to see whether one source fills missing IDs or whether
-- the sources assign the records to different candidates.
WITH differences AS (
  SELECT
    NULLIF(TRIM(p.cand_id), '') AS pas_candidate_id,
    NULLIF(TRIM(d.cand_id), '') AS descriptive_candidate_id,
    p.transaction_tp,
    p.transaction_amt
  FROM `bigquery-public-data.fec.pas220` AS p
  JOIN `bigquery-public-data.fec.committee_contributions_2020` AS d
    ON p.sub_id = d.sub_id
  WHERE p.cand_id IS DISTINCT FROM d.cand_id
)
SELECT
  CASE
    WHEN pas_candidate_id IS NOT DISTINCT FROM descriptive_candidate_id
      THEN 'same_after_normalizing_blanks'
    WHEN pas_candidate_id IS NULL
      THEN 'missing_in_pas220'
    WHEN descriptive_candidate_id IS NULL
      THEN 'missing_in_descriptive'
    ELSE 'different_populated_ids'
  END AS difference_type,
  pas_candidate_id,
  descriptive_candidate_id,
  transaction_tp,
  COUNT(*) AS n_records,
  SUM(CAST(transaction_amt AS NUMERIC)) AS raw_total_amount
FROM differences
GROUP BY
  difference_type,
  pas_candidate_id,
  descriptive_candidate_id,
  transaction_tp
ORDER BY n_records DESC;
Full results: 09_qa_candidate_id_differences (147 rows)

Original JSON export supplied during analysis

difference_typepas_candidate_iddescriptive_candidate_idtransaction_tpn_recordsraw_total_amount
different_populated_idsH6CA19113H0MI0121124A413609611
different_populated_idsH6CA19113H0MI0121124E33211344
different_populated_idsH6TX18133H0TX3501524A2251011
different_populated_idsH8LA03124H8IA0312424A8398060
different_populated_idsH8LA01094H8IA0109424A8695830
different_populated_idsH0CA42209H2CA3702324A8817
different_populated_idsH0LA02156H0IA0215624A71080171
different_populated_idsH8MI09068S4MI0035524A71724
missing_in_pas220NULLH0CA2515424K416500
missing_in_pas220NULLH8NH0121024K48500
different_populated_idsH8MI09068S4MI0035524E47435
different_populated_idsH0AR02131H0AR0220624C395225
missing_in_pas220NULLH8CA4803524K37500
different_populated_idsS2TN00249H4SC0311124C312895
missing_in_pas220NULLH8MI1125424K34000
missing_in_pas220NULLH8CA0110924K33500
missing_in_pas220NULLH6CA4410324K2-500
missing_in_pas220NULLH6WI0309924K26000
missing_in_pas220NULLH8TX2312124K25500
missing_in_pas220NULLH8CA5009824K27000
missing_in_pas220NULLH8TX2231324K26000
different_populated_idsH4MI01130H0MI0121124E2698
different_populated_idsH8NV03200H6NV0402024C21067
missing_in_pas220NULLH0MO0225424K27000
different_populated_idsH2SD00076H2HI0212824E21446
missing_in_pas220NULLH8MN0127924K25000
different_populated_idsH6CA19113H8CA1016724C2202
missing_in_pas220NULLH8IA0109424K24000
missing_in_pas220NULLH0IN0521924K27000
missing_in_pas220NULLH0NY0118624K29000
missing_in_pas220NULLH8GA0720124K210000
missing_in_pas220NULLH0NC0212524K29000
missing_in_pas220NULLH8NC1306724K210000
different_populated_idsH0AK00048H6AK0004524C293681
missing_in_pas220NULLH0VA0514524K25500
missing_in_pas220NULLH2OH1710924K27000
missing_in_pas220NULLH2IL1707124K210000
missing_in_pas220NULLH0PA1011024K25500
missing_in_pas220NULLH8NY2215124K24500
missing_in_pas220NULLH0NY0219224K26500
missing_in_pas220NULLH8NY1111324K26000
missing_in_pas220NULLH8NJ0722324K28000
missing_in_pas220NULLH0NJ0217124K29000
missing_in_pas220NULLH8NJ1114224K26000
missing_in_pas220NULLH8FL2603924K22500
different_populated_idsH6WA03234H0WA0318724C294225
missing_in_pas220NULLH8CA0125724K25000
missing_in_pas220NULLH8CA1012624K25400
missing_in_pas220NULLH6AZ0119924K27000
missing_in_pas220NULLS0UT0016524K210000
missing_in_pas220NULLH0TX2417524K22500
missing_in_pas220NULLH8TX3209824K25000
missing_in_pas220NULLS0ND0009324K210000
missing_in_pas220NULLH0NH0218124K12000
missing_in_pas220NULLH8AK0014024K14000
missing_in_pas220NULLS2SD0006824K12000
missing_in_pas220NULLH0WI0317524K14500
missing_in_pas220NULLH2IL1006824K12500
missing_in_pas220NULLH8IL0413424K1500
missing_in_pas220NULLS0KS0031524K15000
missing_in_pas220NULLS0IA0017624K11000
different_populated_idsS0TX00308S0NE0017124C11300
missing_in_pas220NULLH2OH1406424K11500
missing_in_pas220NULLH0UT0407624K12500
missing_in_pas220NULLH4AZ0704324K1500
missing_in_pas220NULLH8AZ0904024K1500
missing_in_pas220NULLS0OH0013324K11000
missing_in_pas220NULLS0AZ0035024K11000
missing_in_pas220NULLS2NM0008824K15000
missing_in_pas220NULLS0NM0012424K15000
missing_in_pas220NULLH0CA5310724K15000
different_populated_idsS4TX00417S0NE0017124C11300
different_populated_idsS8ME00130H0ME0206724C117521
different_populated_idsH0IA02180H8IA0204324C146900
missing_in_pas220NULLH2CA5208924K15000
different_populated_idsH0CA42209H2CA3702324K11000
missing_in_pas220NULLH0CA2316724K11500
missing_in_pas220NULLH6TN0906824K1500
missing_in_pas220NULLS0TN0016924K15000
missing_in_pas220NULLS2WA0018924K11000
missing_in_pas220NULLH0WA0208024K11000
missing_in_pas220NULLH8WA0319824K1500
missing_in_pas220NULLH6FL1112624K12000
different_populated_idsH6TX18133H0TX3501524E18
missing_in_pas220NULLH0GA0530124K15000
missing_in_pas220NULLS0NC0020224K12000
missing_in_pas220NULLH6IL1808824K12000
missing_in_pas220NULLS4PA0012124K1500
missing_in_pas220NULLH0NY1516024K12500
missing_in_pas220NULLH0NY1717424K12500
missing_in_pas220NULLH4NY1113824K12500
missing_in_pas220NULLH6MD0329224K1-2000
missing_in_pas220NULLH0CA1014924K1500
missing_in_pas220NULLH8PA1818124K12500
missing_in_pas220NULLS8NC0032024K12500
missing_in_pas220NULLH0PA0132524K12000
missing_in_pas220NULLH8CA0503524K15000
missing_in_pas220NULLH8VA1010624K12500
missing_in_pas220NULLH8LA0205424K11000
missing_in_pas220NULLH8NJ0216624K12000
missing_in_pas220NULLH0CA0810124K11500
missing_in_pas220NULLP8000072224K12500
missing_in_pas220NULLH6PA0827724K1500
missing_in_pas220NULLS4NC0016224K11000
missing_in_pas220NULLH0NC0825424K13000
missing_in_pas220NULLH0NC1117524K15000
missing_in_pas220NULLH4NY2205124K12500
missing_in_pas220NULLH8GA0639324K12000
missing_in_pas220NULLH6FL1320524K1-5000
missing_in_pas220NULLH0FL1609424K14000
missing_in_pas220NULLH8OH1114124K12000
missing_in_pas220NULLH0OH0109924K14000
missing_in_pas220NULLH6VT0016024K11000
missing_in_pas220NULLH6MI0722324K12000
missing_in_pas220NULLH8MI0810224K12000
missing_in_pas220NULLH0MI0331624K13000
missing_in_pas220NULLS0NH0021924K12500
missing_in_pas220NULLH0IA0215624K12500
missing_in_pas220NULLH6MN0213124K14000
missing_in_pas220NULLH2MA0907224K1500
missing_in_pas220NULLH8MT0123224K12000
missing_in_pas220NULLH8IL0613924K14000
missing_in_pas220NULLH8IL1417424K12000
missing_in_pas220NULLH8IL1315024K14000
different_populated_idsH2TX36126H2HI0212824E1785
missing_in_pas220NULLH4MO0523424K12000
missing_in_pas220NULLH8KS0315524K12500
missing_in_pas220NULLH8OK0510924K12000
missing_in_pas220NULLH8TX0714024K12500
different_populated_idsS2MI00136S8MI0037224E124
different_populated_idsS4MI00371S8MI0037224E118
missing_in_pas220NULLH8TX2515924K15000
missing_in_pas220NULLH8TX1011024K15000
missing_in_pas220NULLH8CO0622924K14000
missing_in_pas220NULLS4CO0039524K11000
missing_in_pas220NULLH8UT0405324K12000
missing_in_pas220NULLH8AZ0808324K15000
missing_in_pas220NULLH0NM0310224K12500
missing_in_pas220NULLH6NV0402024K11000
missing_in_pas220NULLH8CA3426624K15000
missing_in_pas220NULLH0CA4013824K12000
missing_in_pas220NULLH8CA4907424K14000
missing_in_pas220NULLH8CA4905824K12500
missing_in_pas220NULLH8CA3917424K12000
missing_in_pas220NULLH8ME0218524K12500
missing_in_pas220NULLH0WA1003424K12500
missing_in_pas220NULLS0MN0032824K12500

10_qa_committee_profile

Profiles candidate-contribution transaction categories.

SQL: 10_qa_committee_profile

Open complete SQL file

-- 10_qa_committee_profile
-- I have selected the descriptive committee-to-candidate tables.
-- Before calculating totals, I want to separate contribution types
-- from other spending and check amounts, dates, and candidate IDs.
-- These are diagnostic totals; no contribution filters are applied.
WITH records AS (
  SELECT
    2018 AS cycle,
    transaction_tp,
    memo_cd,
    transaction_amt,
    transaction_dt,
    cand_id
  FROM `bigquery-public-data.fec.committee_contributions_2018`
  UNION ALL
  SELECT
    2020 AS cycle,
    transaction_tp,
    memo_cd,
    transaction_amt,
    transaction_dt,
    cand_id
  FROM `bigquery-public-data.fec.committee_contributions_2020`
)
SELECT
  cycle,
  transaction_tp,
  memo_cd,
  COUNT(*) AS n_records,
  COUNTIF(transaction_amt > 0) AS n_positive,
  COUNTIF(transaction_amt < 0) AS n_negative,
  COUNTIF(transaction_amt = 0) AS n_zero,
  COUNTIF(transaction_amt IS NULL) AS n_missing_amount,
  SUM(CAST(transaction_amt AS NUMERIC)) AS raw_total_amount,
  COUNTIF(transaction_dt IS NULL) AS n_missing_date,
  COUNTIF(
    transaction_dt < DATE(cycle - 1, 1, 1)
    OR transaction_dt > DATE(cycle, 12, 31)
  ) AS n_outside_cycle_dates,
  COUNTIF(NULLIF(TRIM(cand_id), '') IS NULL)
    AS n_missing_candidate
FROM records
GROUP BY cycle, transaction_tp, memo_cd
ORDER BY cycle, n_records DESC;
Full results: 10_qa_committee_profile (28 rows)

Original JSON export supplied during analysis

cycletransaction_tpmemo_cdn_recordsn_positiven_negativen_zeron_missing_amountraw_total_amountn_missing_daten_outside_cycle_datesn_missing_candidate
201824KNULL2810492719007722142705152672957101218
201824ENULL351693476969331033008806833730
201824ANULL203672023031106096936797926720
201824ZNULL4021399416110156866700136
201824EX194818684238012765853843210
201824KX172215112110021226270314
201824FNULL132513200504286820710
201824ZX1279126118001288948025107
201824AX1267121324300414030657990
201824CNULL1260125262039092197000
201824CX3793780101520574010
201824NNULL292900076763000
201824RNULL4310012000000
201824PNULL10100-1000000
202024KNULL788654731456454571174105649818690111716
202024ENULL538365225946011170915457258720140
202024ANULL26973266491361880233738127740600
202024EX57915528911720904992792716250
202024KX376635761855020769691303
202024ZNULL2859281729130113582800104
202024AX233522741744034835289175920
202024CNULL1246124051087045042000
202024FNULL100410020205638350000
202024CX7227220002426895020
202024ZX62059717607577774944
202024NNULL181800038267000
202024KY40400-4230000
202024RX110005000000

11_model_committee_staging

Creates stg_committee_contributions; returns candidate-reference coverage.

SQL: 11_model_committee_staging

Open complete SQL file

-- 11_model_committee_staging
-- I keep the source records and add a flag for my Q2 definition.
-- This lets me reuse the same rules without deleting excluded records.
CREATE OR REPLACE VIEW
  `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
AS
WITH source_records AS (
  SELECT
    2018 AS cycle,
    sub_id,
    cmte_id,
    cand_id,
    transaction_dt,
    transaction_tp,
    memo_cd,
    transaction_amt
  FROM `bigquery-public-data.fec.committee_contributions_2018`
  UNION ALL
  SELECT
    2020 AS cycle,
    sub_id,
    cmte_id,
    cand_id,
    transaction_dt,
    transaction_tp,
    memo_cd,
    transaction_amt
  FROM `bigquery-public-data.fec.committee_contributions_2020`
)
SELECT
  cycle,
  sub_id,
  cmte_id,
  NULLIF(TRIM(cand_id), '') AS cand_id,
  transaction_dt,
  transaction_tp,
  memo_cd,
  CAST(transaction_amt AS NUMERIC) AS amount,
  COALESCE(
    transaction_tp IN ('24K', '24Z')
    AND transaction_amt > 0
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_dt BETWEEN
      DATE(cycle - 1, 1, 1) AND DATE(cycle, 12, 31),
    FALSE
  ) AS is_q2_in_scope
FROM source_records;
-- I use one reference row per cycle and candidate ID so the join
-- cannot multiply contribution records.
WITH candidates AS (
  SELECT DISTINCT 2018 AS cycle, cand_id
  FROM `bigquery-public-data.fec.candidate_2018`
  UNION ALL
  SELECT DISTINCT 2020 AS cycle, cand_id
  FROM `bigquery-public-data.fec.candidate_2020`
)
SELECT
  s.cycle,
  CASE
    WHEN s.cand_id IS NULL THEN 'missing_candidate_id'
    WHEN c.cand_id IS NULL THEN 'not_in_candidate_reference'
    ELSE 'matched_candidate'
  END AS candidate_status,
  COUNT(*) AS n_contributions,
  COUNT(DISTINCT s.cand_id) AS n_candidate_ids,
  SUM(s.amount) AS gross_amount
FROM
  `fec-contributions-analysis.fec_analysis.stg_committee_contributions` AS s
LEFT JOIN candidates AS c
  ON s.cycle = c.cycle
  AND s.cand_id = c.cand_id
WHERE s.is_q2_in_scope
GROUP BY s.cycle, candidate_status
ORDER BY s.cycle, candidate_status;
Full results: 11_model_committee_staging (6 rows)

Result supplied during the analysis, September 14, 2026

cyclecandidate_statusn_contributionsn_candidate_idsgross_amount
2018matched_candidate2732571593529515202
2018missing_candidate_id125401711645
2018not_in_candidate_reference13701382727735
2020matched_candidate7311231565586126034
2020missing_candidate_id168401387129
2020not_in_candidate_reference14571252083324

12_q2_candidate_summary

Returns the Q2 candidate count and distribution.

The full SQL appears inline above and in 12_q2_candidate_summary.sql.

Full results: 12_q2_candidate_summary (1 row)

Result supplied during the analysis, September 14, 2026

n_reported_candidate_idstotal_attributed_amountminimum_receivedapproximate_p25approximate_medianmean_receivedapproximate_p75approximate_p90approximate_p99maximum_receivedpopulation_stddevtop_10_percent_share_pctn_contributions_without_candidate_idamount_without_candidate_id
261411204522958350015800428635.15493496636901314940383946544496089771271873.262600980761.909951629383098774

13_qa_other_committee_profile

Profiles the broader committee transaction sources.

SQL: 13_qa_other_committee_profile

Open complete SQL file

-- 13_qa_other_committee_profile
-- Q1 asks about contributions originating from committees, not just
-- those directed to candidates. I need to inspect the other committee
-- transactions before deciding which belong in that broader measure.
-- I also check record IDs before trusting the counts.
WITH records AS (
  SELECT
    2018 AS cycle,
    sub_id,
    transaction_tp,
    memo_cd,
    transaction_amt
  FROM `bigquery-public-data.fec.other_committee_tx_2018`
  UNION ALL
  SELECT
    2020 AS cycle,
    sub_id,
    transaction_tp,
    memo_cd,
    transaction_amt
  FROM `bigquery-public-data.fec.other_committee_tx_2020`
)
SELECT
  cycle,
  transaction_tp,
  memo_cd,
  COUNT(*) AS n_rows,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  COUNTIF(sub_id IS NULL) AS n_missing_ids,
  COUNTIF(transaction_amt > 0) AS n_positive,
  COUNTIF(transaction_amt < 0) AS n_negative,
  COUNTIF(transaction_amt = 0) AS n_zero,
  COUNTIF(transaction_amt IS NULL) AS n_missing_amount,
  SUM(CAST(transaction_amt AS NUMERIC)) AS raw_total_amount
FROM records
GROUP BY cycle, transaction_tp, memo_cd
ORDER BY cycle, n_rows DESC;
Full results: 13_qa_other_committee_profile (158 rows)

Original JSON export supplied during analysis

cycletransaction_tpmemo_cdn_rowsn_distinct_idsn_missing_idsn_positiven_negativen_zeron_missing_amountraw_total_amount
201815JX48291348291304628905986140370340640595
201824KNULL34100134100103300519461148901039249490
201818KNULL5721757217056748584110408130787
201824ENULL3562635626035224693330332016725
201824ANULL2039020390020251311080971606865
201818GNULL170851708501701327450592332590
201824GNULL170781707801694267690662342907
201818JX12465124650122572053042130339
201824ZNULL4550455004521181103261978
201816CNULL45434543045175210250324566
201824KX2107210701881226003946606
201824EX19501950018704238012775853
201820CNULL183718370183214056335080
201824ZX171917190170118007004296
201824FNULL13871387013810604533306
201824GX13741374013740004348035
201824AX1267126701213243004140306
201824CNULL126012600125262039092197
201822ZNULL118811880111962703988071
201818GX110311030108841103397040
201831JX102410240101860032208721
201815JNULL93093009291001632627
201842NULL882882087039018466751
201831KNULL763763076210023582220
201842X4434430434900800439
201824CX37937903780101520574
201832JX291291029010010432297
201820CX244244024400034687375
201811JX24224202384001075136
201818JNULL21521502150006311297
201831GNULL214214021310033577584
201815ZNULL18118101810003860163
201832KNULL17717701770005715702
201818KX16516501471800374584
201816GNULL13113101290201629431
201831FX12512501250001645000
201820GNULL10810801080002390961
201832GNULL909009000010173714
201841NULL74740740008452326
201824PX656506500023269
201822ZX5353049400168367
201816CX45450450006078188
201830JX45450441002600150
201830KNULL4343043000847088
201820FNULL363603600021857642
201824NNULL3434034000110883
201832FX2929029000971750
201831FNULL1515015000530475
201831GX15150150001028932
201816FNULL131301300032314435
201840NULL9909000234485
201824PNULL77052002500
201811JNULL660600021400
201830GNULL66060001891030
201831JNULL550500026612
201831KX5505000116000
201824RNULL550410027000
201830FX4404000254350
201832GX4404000311134
201841ZNULL2202000117342
201820NULL2202000101910
201816GX22020002519
201820GX22010101026
201815ZX11010001171
201823YNULL11010002700
201832KX11010005000
201810JNULL1101000290000
201830GX1101000211208
201832FNULL11010009114
201842ZNULL11010005000
201816FX110100060
202015JX56276095627609052056611927440267401465264073
202024KNULL1532175153217501412164934022660901606838874
202024ENULL544455444505286646011190917775979
202018KNULL532805328005317496100550607854
202024ANULL270072700702668313618802337900002
202018GNULL22463224630223171133302175457260
202024GNULL2133721337021233931102444802031
202018JX1286112861012788694048187962
202024EX582958290556691172090542818
202041NULL4894489404830595057388182
202016CNULL46234623046053150239981302
202024KX4498449804283210505114015
202024ZNULL3853385303803311908206971
202042NULL2614261402594713091417965
202024AX23372337022761744034855299
202031JX2181218102158230091580392
202020CNULL214021400213046071594119
202041X203320330199337305097413
202024GX16521652016520007090403
202022ZNULL1515151501380133204036067
202032JX147914790147720060570552
202015JNULL1424141701207207281071841
202024CNULL124612460124051087045042
202018GX11501150011418102808355
202024FNULL10041004010020205638350
202024ZX988988096418604166978
202024CX72272207220002426895
202031KNULL585585058500025871164
202032KNULL340340033820011297027
202015ZNULL33533503340101060648
202030JX290290029000015875009
202018KX270270024723001251911
202011JX26126102592001037473
202020CX248248024800046371442
202031GNULL224224022130076634640
202018JNULL179179017900053648896
202016GNULL1651650164010323774
202032GNULL152152014930059905430
202018UNULL146146014600034830039
202016CX119119011900037142444
202020GNULL1191190118010985142
202022ZX1091090104500517311
202042X1071070102500249696
202040NULL96960960008088673
202032FX72720720001672008
202030KNULL69690690003804191
202031FX65650650001369481
202031JNULL606006000048535
202031GX56560533002140609
202032KX45450321300389950
202020FNULL23230230004341271
202030FX1818018000786820
202024NNULL181801800038267
202031KX1717031400-266050
202030GNULL171701700017688340
202040X1616015100325580
202016FNULL88070102118335
202040ZNULL6606000929647
202020NULL6606000266809
202041ZNULL6606000135130
202024KY6602400-230
202032GX5505000124100
202015ZX440400072897
202032JNULL4404000146500
202024RNULL440310066258
202042ZNULL4404000210692
202031FNULL44040001515698
202032FNULL4404000350200
202011JNULL22020003500
202011AI-000793757NULL1100001NULL
202030JNULL11010001000
202010JX11010005000
202011AI-000793709NULL1100001NULL
202015ZY110100021
202022HNULL11010001
202018KY1101000367
20201403652NULL1100001NULL
202011AI-000793708NULL1100001NULL
202042ZX1101000107500
202024RX11010005000
2020AC0BDB869538D4EF39C1NULL1100001NULL
202020RNULL110100025000
202016CY11010002556
202011AI-000791467NULL1100001NULL
2020A207714E2F8BD47E48C7NULL1100001NULL
202030FNULL11010001000
202011AI-000791387NULL1100001NULL

14_qa_committee_overlap

Checks overlap between broader and candidate committee sources.

SQL: 14_qa_committee_overlap

Open complete SQL file

-- 14_qa_committee_overlap
-- I want to know whether the broader committee source already
-- contains the candidate contributions.
-- I group the broader source by ID first so repeated copies
-- cannot multiply records during the comparison.
WITH other_records AS (
  SELECT
    2018 AS cycle,
    sub_id, cmte_id, transaction_dt,
    transaction_tp, memo_cd, transaction_amt
  FROM `bigquery-public-data.fec.other_committee_tx_2018`
  UNION ALL
  SELECT
    2020 AS cycle,
    sub_id, cmte_id, transaction_dt,
    transaction_tp, memo_cd, transaction_amt
  FROM `bigquery-public-data.fec.other_committee_tx_2020`
),
other_by_id AS (
  SELECT
    cycle,
    sub_id,
    COUNT(*) AS n_copies,
    COUNT(DISTINCT TO_JSON_STRING(STRUCT(
      cmte_id, transaction_dt, transaction_tp, transaction_amt
    ))) AS n_versions,
    MIN(CAST(transaction_amt AS NUMERIC)) AS amount,
    MIN(transaction_dt) AS transaction_dt,
    MIN(cmte_id) AS cmte_id
  FROM other_records
  WHERE transaction_tp IN ('24K', '24Z')
    AND transaction_amt > 0
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_dt BETWEEN
      DATE(cycle - 1, 1, 1) AND DATE(cycle, 12, 31)
  GROUP BY cycle, sub_id
),
candidate_records AS (
  SELECT cycle, sub_id, amount, transaction_dt, cmte_id
  FROM
    `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
  WHERE is_q2_in_scope
)
SELECT
  COALESCE(o.cycle, c.cycle) AS cycle,
  CASE
    WHEN o.cycle IS NULL THEN 'candidate_source_only'
    WHEN c.cycle IS NULL THEN 'broader_source_only'
    ELSE 'shared_id'
  END AS source_coverage,
  COUNT(*) AS n_ids,
  SUM(COALESCE(o.n_copies - 1, 0)) AS extra_broader_copies,
  COUNTIF(o.n_versions > 1) AS broader_ids_with_conflicts,
  COUNTIF(
    o.cycle IS NOT NULL AND c.cycle IS NOT NULL
    AND (
      o.amount IS DISTINCT FROM c.amount
      OR o.transaction_dt IS DISTINCT FROM c.transaction_dt
      OR o.cmte_id IS DISTINCT FROM c.cmte_id
    )
  ) AS shared_ids_with_amount_date_or_committee_conflict
FROM other_by_id AS o
FULL OUTER JOIN candidate_records AS c
  ON o.cycle = c.cycle
  AND o.sub_id = c.sub_id
GROUP BY cycle, source_coverage
ORDER BY cycle, source_coverage;
Full results: 14_qa_committee_overlap (5 rows)

Result supplied during the analysis, September 14, 2026

cyclesource_coveragen_idsextra_broader_copiesbroader_ids_with_conflictsshared_ids_with_amount_date_or_committee_conflict
2018broader_source_only58672000
2018shared_id275881000
2020broader_source_only681712000
2020candidate_source_only25000
2020shared_id734239000

15_qa_committee_unmatched_records

Inspects the 25 unmatched candidate records.

SQL: 15_qa_committee_unmatched_records

Open complete SQL file

-- 15_qa_committee_unmatched_records
-- The filtered comparison left 25 candidate records unmatched.
-- I check the original broader table to see whether they are absent
-- or present with details that fail my contribution filters.
WITH qualifying_other_ids AS (
  SELECT sub_id
  FROM `bigquery-public-data.fec.other_committee_tx_2020`
  WHERE transaction_tp IN ('24K', '24Z')
    AND transaction_amt > 0
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_dt BETWEEN DATE '2019-01-01' AND DATE '2020-12-31'
),
unmatched_candidates AS (
  SELECT
    c.sub_id,
    c.cmte_id,
    c.amount,
    c.transaction_dt,
    c.transaction_tp,
    c.memo_cd
  FROM
    `fec-contributions-analysis.fec_analysis.stg_committee_contributions` AS c
  WHERE c.cycle = 2020
    AND c.is_q2_in_scope
    AND NOT EXISTS (
      SELECT 1
      FROM qualifying_other_ids AS o
      WHERE o.sub_id = c.sub_id
    )
)
SELECT
  c.sub_id,
  CASE
    WHEN o.sub_id IS NULL THEN 'absent_from_broader_source'
    ELSE 'present_with_different_details'
  END AS finding,
  c.amount AS candidate_source_amount,
  o.transaction_amt AS broader_source_amount,
  c.transaction_dt AS candidate_source_date,
  o.transaction_dt AS broader_source_date,
  c.transaction_tp AS candidate_source_type,
  o.transaction_tp AS broader_source_type,
  c.memo_cd AS candidate_source_memo,
  o.memo_cd AS broader_source_memo,
  c.cmte_id AS candidate_source_committee,
  o.cmte_id AS broader_source_committee
FROM unmatched_candidates AS c
LEFT JOIN `bigquery-public-data.fec.other_committee_tx_2020` AS o
  ON c.sub_id = o.sub_id
ORDER BY c.sub_id;
Full results: 15_qa_committee_unmatched_records (25 rows)

Original JSON export supplied during analysis

sub_idfindingcandidate_source_amountbroader_source_amountcandidate_source_datebroader_source_datecandidate_source_typebroader_source_typecandidate_source_memobroader_source_memocandidate_source_committeebroader_source_committee
4120220241074206716absent_from_broader_source2000NULL2020-02-19NULL24KNULLNULLNULLC00652883NULL
4120220241074206718absent_from_broader_source2000NULL2020-02-19NULL24KNULLNULLNULLC00652883NULL
4120220241074206719absent_from_broader_source2000NULL2020-02-19NULL24KNULLNULLNULLC00652883NULL
4121620241077036353absent_from_broader_source1000NULL2020-12-04NULL24KNULLNULLNULLC00652883NULL
4121620241077036354absent_from_broader_source1000NULL2020-12-16NULL24KNULLNULLNULLC00652883NULL
4121620241077036428absent_from_broader_source1000NULL2020-09-30NULL24KNULLNULLNULLC00652883NULL
4121620241077036429absent_from_broader_source2000NULL2020-07-22NULL24KNULLNULLNULLC00652883NULL
4121620241077036430absent_from_broader_source1000NULL2020-07-22NULL24KNULLNULLNULLC00652883NULL
4121620241077036432absent_from_broader_source1000NULL2020-09-09NULL24KNULLNULLNULLC00652883NULL
4121620241077036433absent_from_broader_source2000NULL2020-05-07NULL24KNULLNULLNULLC00652883NULL
4121620241077036434absent_from_broader_source1000NULL2020-05-07NULL24KNULLNULLNULLC00652883NULL
4121620241077036724absent_from_broader_source2000NULL2019-10-17NULL24KNULLNULLNULLC00652883NULL
4121620241077036726absent_from_broader_source2000NULL2019-07-09NULL24KNULLNULLNULLC00652883NULL
4121620241077036728absent_from_broader_source1000NULL2019-10-17NULL24KNULLNULLNULLC00652883NULL
4121620241077036729absent_from_broader_source1000NULL2019-08-26NULL24KNULLNULLNULLC00652883NULL
4121620241077036731absent_from_broader_source2000NULL2019-09-16NULL24KNULLNULLNULLC00652883NULL
4121620241077036732absent_from_broader_source2000NULL2019-09-16NULL24KNULLNULLNULLC00652883NULL
4121620241077036733absent_from_broader_source2000NULL2019-09-16NULL24KNULLNULLNULLC00652883NULL
4121620241077036735absent_from_broader_source1000NULL2019-10-17NULL24KNULLNULLNULLC00652883NULL
4121620241077036737absent_from_broader_source2000NULL2019-12-16NULL24KNULLNULLNULLC00652883NULL
4121620241077036738absent_from_broader_source1000NULL2019-12-16NULL24KNULLNULLNULLC00652883NULL
4121620241077036772absent_from_broader_source2000NULL2019-02-18NULL24KNULLNULLNULLC00652883NULL
4121620241077036774absent_from_broader_source2000NULL2019-02-18NULL24KNULLNULLNULLC00652883NULL
4121620241077036775absent_from_broader_source2000NULL2019-02-18NULL24KNULLNULLNULLC00652883NULL
4121620241077036776absent_from_broader_source1000NULL2019-06-17NULL24KNULLNULLNULLC00652883NULL

16_model_committee_q1

Creates stg_committee_q1; returns eligible committee totals by cycle.

SQL: 16_model_committee_q1

Open complete SQL file

-- 16_model_committee_q1
-- I use the broader committee source for Q1 and keep the same
-- contribution definition across both cycles.
-- The view preserves excluded records so the rules remain reviewable.
CREATE OR REPLACE VIEW
  `fec-contributions-analysis.fec_analysis.stg_committee_q1`
AS
WITH records AS (
  SELECT
    2018 AS cycle,
    sub_id,
    cmte_id,
    other_id,
    transaction_dt,
    transaction_tp,
    memo_cd,
    transaction_amt
  FROM `bigquery-public-data.fec.other_committee_tx_2018`
  UNION ALL
  SELECT
    2020 AS cycle,
    sub_id,
    cmte_id,
    other_id,
    transaction_dt,
    transaction_tp,
    memo_cd,
    transaction_amt
  FROM `bigquery-public-data.fec.other_committee_tx_2020`
)
SELECT
  cycle,
  sub_id,
  cmte_id,
  other_id,
  transaction_dt,
  transaction_tp,
  memo_cd,
  CAST(transaction_amt AS NUMERIC) AS amount,
  COALESCE(
    transaction_tp IN ('24K', '24Z')
    AND transaction_amt > 0
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_dt BETWEEN
      DATE(cycle - 1, 1, 1) AND DATE(cycle, 12, 31),
    FALSE
  ) AS is_q1_in_scope
FROM records;
-- The qualifying records passed the ID check in query 14.
-- I check the counts again here to verify the saved view.
SELECT
  cycle,
  COUNT(*) AS n_committee_contributions,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  SUM(amount) AS gross_committee_amount,
  MIN(transaction_dt) AS earliest_included_date,
  MAX(transaction_dt) AS latest_included_date
FROM `fec-contributions-analysis.fec_analysis.stg_committee_q1`
WHERE is_q1_in_scope
GROUP BY cycle
ORDER BY cycle;
Full results: 16_model_committee_q1 (2 rows)

Result supplied during the analysis, September 14, 2026

cyclen_committee_contributionsn_distinct_idsgross_committee_amountearliest_included_datelatest_included_date
201833455333455310672162312017-01-012018-12-31
20201415951141595116532637362019-01-012020-12-31

17_model_individual_q1

Creates stg_individual_q1; returns eligible individual totals by cycle.

SQL: 17_model_individual_q1

Open complete SQL file

-- 17_model_individual_q1
-- I count individual contribution receipts, rather than every
-- transaction involving an individual.
-- I remove repeated 2020 copies using the fields verified earlier.
-- Excluded records remain in the view for review.
CREATE OR REPLACE VIEW
  `fec-contributions-analysis.fec_analysis.stg_individual_q1`
AS
WITH records AS (
  SELECT
    2018 AS cycle,
    sub_id,
    cmte_id,
    entity_tp,
    transaction_tp,
    memo_cd,
    transaction_dt,
    transaction_amt
  FROM `bigquery-public-data.fec.indiv18`
  UNION ALL
  SELECT DISTINCT
    2020 AS cycle,
    sub_id,
    cmte_id,
    entity_tp,
    transaction_tp,
    memo_cd,
    transaction_dt,
    transaction_amt
  FROM `bigquery-public-data.fec.indiv20`
)
SELECT
  cycle,
  sub_id,
  cmte_id,
  entity_tp,
  transaction_tp,
  memo_cd,
  transaction_dt,
  CAST(transaction_amt AS NUMERIC) AS amount,
  COALESCE(
    entity_tp = 'IND'
    AND transaction_tp IN (
      '10', '15', '15E',
      '30', '30E', '31', '31E', '32', '32E'
    )
    AND transaction_amt > 0
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_dt BETWEEN
      DATE(cycle - 1, 1, 1) AND DATE(cycle, 12, 31),
    FALSE
  ) AS is_q1_in_scope
FROM records;
SELECT
  cycle,
  COUNT(*) AS n_individual_contributions,
  COUNT(DISTINCT sub_id) AS n_distinct_ids,
  SUM(amount) AS gross_individual_amount,
  MIN(transaction_dt) AS earliest_included_date,
  MAX(transaction_dt) AS latest_included_date
FROM `fec-contributions-analysis.fec_analysis.stg_individual_q1`
WHERE is_q1_in_scope
GROUP BY cycle
ORDER BY cycle;
Full results: 17_model_individual_q1 (2 rows)

Result supplied during the analysis, September 14, 2026

cyclen_individual_contributionsn_distinct_idsgross_individual_amountearliest_included_datelatest_included_date
2018150881981508819840219535072017-01-012018-12-31
20206475976364759763100583257572019-01-012020-12-31

18_model_monthly_contributions

Creates fct_contributions and agg_contributions_monthly; returns the peak month.

SQL: 18_model_monthly_contributions

Open complete SQL file

-- 18_model_monthly_contributions
-- I combine the two defined contribution measures in a reusable view.
-- Each record is identified by its source, cycle, and record ID.
-- I then save monthly totals to avoid rescanning the large sources
-- whenever I need a chart or a monthly comparison.
CREATE OR REPLACE VIEW
  `fec-contributions-analysis.fec_analysis.fct_contributions`
AS
SELECT
  'individual' AS source_type,
  cycle,
  sub_id,
  transaction_dt,
  amount
FROM `fec-contributions-analysis.fec_analysis.stg_individual_q1`
WHERE is_q1_in_scope
UNION ALL
SELECT
  'committee' AS source_type,
  cycle,
  sub_id,
  transaction_dt,
  amount
FROM `fec-contributions-analysis.fec_analysis.stg_committee_q1`
WHERE is_q1_in_scope;
CREATE OR REPLACE TABLE
  `fec-contributions-analysis.fec_analysis.agg_contributions_monthly`
AS
SELECT
  DATE_TRUNC(transaction_dt, MONTH) AS contribution_month,
  source_type,
  COUNT(*) AS n_contributions,
  SUM(amount) AS gross_amount
FROM `fec-contributions-analysis.fec_analysis.fct_contributions`
GROUP BY contribution_month, source_type;
-- I include every calendar month so the previous value really is
-- the previous month, even if a month has no contribution records.
WITH calendar AS (
  SELECT contribution_month
  FROM UNNEST(
    GENERATE_DATE_ARRAY(
      DATE '2017-01-01',
      DATE '2020-12-01',
      INTERVAL 1 MONTH
    )
  ) AS contribution_month
),
monthly AS (
  SELECT
    c.contribution_month,
    COALESCE(SUM(m.gross_amount), 0) AS total_amount
  FROM calendar AS c
  LEFT JOIN
    `fec-contributions-analysis.fec_analysis.agg_contributions_monthly` AS m
    USING (contribution_month)
  GROUP BY c.contribution_month
),
growth AS (
  SELECT
    contribution_month,
    total_amount,
    LAG(total_amount) OVER (
      ORDER BY contribution_month
    ) AS prior_month_amount
  FROM monthly
)
SELECT
  contribution_month,
  total_amount,
  prior_month_amount,
  100 * SAFE_DIVIDE(
    total_amount - prior_month_amount,
    prior_month_amount
  ) AS month_over_month_growth_pct
FROM growth
QUALIFY DENSE_RANK() OVER (ORDER BY total_amount DESC) = 1;
Full results: 18_model_monthly_contributions (1 row)

Result supplied during the analysis, September 14, 2026

contribution_monthtotal_amountprior_month_amountmonth_over_month_growth_pct
2020-10-012017565781179342012312.4982237

19_qa_monthly_reconciliation

Reconciles monthly aggregates to recorded source totals.

SQL: 19_qa_monthly_reconciliation

Open complete SQL file

-- 19_qa_monthly_reconciliation
-- I want to confirm that grouping records by month did not change
-- the contribution counts or amounts calculated in the earlier steps.
-- Expected values come from queries 16 and 17.
WITH expected AS (
  SELECT
    'individual' AS source_type,
    79847961 AS expected_count,
    NUMERIC '14080279264' AS expected_amount
  UNION ALL
  SELECT
    'committee',
    1750504,
    NUMERIC '2720479967'
),
actual AS (
  SELECT
    source_type,
    SUM(n_contributions) AS actual_count,
    SUM(gross_amount) AS actual_amount,
    COUNT(*) AS n_month_rows,
    COUNT(DISTINCT contribution_month) AS n_distinct_months,
    MIN(contribution_month) AS first_month,
    MAX(contribution_month) AS last_month
  FROM
    `fec-contributions-analysis.fec_analysis.agg_contributions_monthly`
  GROUP BY source_type
)
SELECT
  COALESCE(e.source_type, a.source_type) AS source_type,
  e.expected_count,
  a.actual_count,
  e.expected_amount,
  a.actual_amount,
  a.n_month_rows,
  a.n_distinct_months,
  a.first_month,
  a.last_month,
  COALESCE(
    a.actual_count = e.expected_count
    AND a.actual_amount = e.expected_amount
    AND a.n_month_rows = 48
    AND a.n_distinct_months = 48
    AND a.first_month = DATE '2017-01-01'
    AND a.last_month = DATE '2020-12-01',
    FALSE
  ) AS check_passed
FROM expected AS e
FULL OUTER JOIN actual AS a
  USING (source_type)
ORDER BY source_type;
Full results: 19_qa_monthly_reconciliation (2 rows)

Result supplied during the analysis, September 14, 2026

source_typeexpected_countactual_countexpected_amountactual_amountn_month_rowsn_distinct_monthsfirst_monthlast_monthcheck_passed
committee175050417505042720479967272047996748482017-01-012020-12-01true
individual7984796179847961140802792641408027926448482017-01-012020-12-01true

20_qa_operating_expenditures

Profiles operating-expenditure coverage for the Q3 plan.

The full SQL appears inline above and in 20_qa_operating_expenditures.sql.

Full results: 20_qa_operating_expenditures (4 rows)

Result supplied during the analysis, September 14, 2026

source_tablen_rowsn_distinct_idsn_missing_idsearliest_report_yearlatest_report_yearearliest_datelatest_daten_missing_datesn_dates_in_cyclen_missing_payee_namesn_missing_statesn_missing_entity_typesn_negative_amountsraw_total_amount
opex_201818000801800080020172018NULLNULL1800080016356515167889128784584149613.83
opex_202018000801800080020172018NULLNULL1800080016356515167889128784584149613.83
oppexp18180000018000000201720181930-02-189201-10-02252179553515686486167828128784583771987.38
oppexp20231538223153820201920201919-06-192029-12-11152310905149645745932560312442584070.620000003

21_monthly_chart_data

Returns the 48 monthly chart rows and growth rates.

The full SQL appears inline above and in 21_monthly_chart_data.sql.

Full results: 21_monthly_chart_data (48 rows)

Original monthly CSV export

contribution_monthindividual_amountcommittee_amounttotal_amountmonth_over_month_growth_pct
2017-01-01495674732430848473875957
2017-02-01762718363198829210826012846.5431142
2017-03-011318475275710197618894950374.532865
2017-04-018781039820541094108351492-42.655847
2017-05-011031273643058382513371118923.405028
2017-06-011447105544880431919351487344.7260132
2017-07-01706725501956739690239946-53.3679533
2017-08-01980570101749366711555067728.0482559
2017-09-011326456974073885017338454750.0506544
2017-10-0110978382531356735141140560-18.5968055
2017-11-0111031268128797568139110249-1.4385029
2017-12-011666714543996104620663250048.5386602
2018-01-019843331532105970130539285-36.8253856
2018-02-011374181953188300116930119629.693675
2018-03-012292377575884426128808201870.1594701
2018-04-0116117527536718946197894221-31.3062917
2018-05-012141081774776279926187097632.3287637
2018-06-012370707896973009130680088017.1572676
2018-07-0118481151844256017229067535-25.3367412
2018-08-012904892435343400234392324550.1405448
2018-09-0142826671011368035354194706357.5779104
2018-10-0158398396615627675874026072436.5928104
2018-11-0111352366220113688133637350-81.9472592
2018-12-01619565311116709373123624-45.2820458
2019-01-015801121121653498796647098.9452418
2019-02-01965496992961917612616887558.3748646
2019-03-011795341816014598023968016189.9677405
2019-04-0113429893620261056154559992-35.5140653
2019-05-01137346503304697501678162538.5767739
2019-06-012056886946277562026846431459.9751569
2019-07-0112559423821929119147523357-45.0491744
2019-08-01144125750161096141602353648.6169453
2019-09-012249924024585396327084636569.0303303
2019-10-0119605490730881371226936278-16.2121751
2019-11-0119466605027292097221958147-2.193625
2019-12-012968472784512507634197235454.0706474
2020-01-0124232435544241458286565813-16.2020527
2020-02-013807557547241859945317435358.1397126
2020-03-0132728273377474130404756863-10.6840755
2020-04-0125001839224915800274934192-32.0742359
2020-05-013235229383279764635632058429.6021355
2020-06-015950858877692991767201580488.598648
2020-07-01648978358744551477234335057.6512637
2020-08-01102065650996715737111737224654.454036
2020-09-011566972414226447709179342012360.5033711
2020-10-011742781090274784691201756578112.4982237
2020-11-0185087161885630193936501811-53.5825885
2020-12-01115365860154336389269702249-71.2010969

22_qa_largest_candidate

Breaks down the largest candidate total by committee and cycle.

SQL: 22_qa_largest_candidate

Open complete SQL file

-- 22_qa_largest_candidate
-- The largest candidate total in Q2 is far above anything a single PAC
-- could give, so I break it down by cycle, committee and transaction type
-- to see where the money is reported from.
WITH totals AS (
  SELECT
    cand_id,
    SUM(amount) AS total_amount
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
  WHERE is_q2_in_scope
    AND cand_id IS NOT NULL
  GROUP BY cand_id
),
top_candidate AS (
  SELECT *
  FROM totals
  QUALIFY DENSE_RANK() OVER (ORDER BY total_amount DESC) = 1
)
SELECT
  s.cand_id,
  s.cycle,
  s.cmte_id,
  s.transaction_tp,
  COUNT(*) AS n_records,
  SUM(s.amount) AS amount,
  MAX(s.amount) AS largest_record
FROM `fec-contributions-analysis.fec_analysis.stg_committee_contributions` AS s
JOIN top_candidate AS t
  USING (cand_id)
WHERE s.is_q2_in_scope
GROUP BY 1, 2, 3, 4
ORDER BY amount DESC;
Full results: 22_qa_largest_candidate (213 rows)

BigQuery follow-up run; job IDs in query_jobs.json

cand_idcyclecmte_idtransaction_tpn_recordsamountlargest_record
P800015712020C0069432324K257625484076335600
P800015712020C0068806924Z712909545601
P800015712020C0057094524K5250005000
P800015712020C0029552724K4200005000
P800015712020C0032608224K3150005000
P800015712020C0040927624K3150005000
P800015712020C0059208924K3150005000
P800015712020C0069379624K31440010000
P800015712020C0071997124K4128005000
P800015712020C0039559024K11259612596
P800015712020C0003850524K7116402852
P800015712020C0056438524K2100005000
P800015712020C0054320724K2100005000
P800015712020C0034464824K2100005000
P800015712020C0036869624K2100005000
P800015712020C0024453324K11000010000
P800015712020C0041520824K2100005000
P800015712020C0054507924K2100005000
P800015712020C0060737424K6100002500
P800015712020C0042805224K2100005000
P800015712020C0041062124K2100005000
P800015712020C0069116224K2100005000
P800015712020C0049784224K2100005000
P800015712020C0049625724K11000010000
P800015712020C0044003224K2100005000
P800015712020C0063289324K2100005000
P800015712020C0044537924K2100005000
P800015712020C0056992124K2100005000
P800015712020C0046743124K2100005000
P800015712020C0054789324K5100002000
P800015712020C0049307224K2100005000
P800015712020C0043952124K2100005000
P800015712020C0050002524K2100005000
P800015712020C0057077024K2100005000
P800015712020C0052182324K2100005000
P800015712020C0032840124K2100005000
P800015712020C0049392424K2100005000
P800015712020C0050315124K2100005000
P800015712020C0053654024K2100005000
P800015712020C0059706224K2100005000
P800015712020C0046942924K2100005000
P800015712020C0039016124K2100005000
P800015712020C0048942724K2100005000
P800015712020C0044236824K2100005000
P800015712020C0058272624K2100005000
P800015712020C0057120824K2100005000
P800015712020C0031645524K490002600
P800015712020C0033088624K360002000
P800015712020C0069114724K256002800
P800015712020C0056563024K256002800
P800015712020C0068032224K256002800
P800015712018C0064066424K254002700
P800015712020C0061140024K150005000
P800015712020C0058999424K250002500
P800015712020C0065230524K250002800
P800015712020C0057121624K250002800
P800015712020C0053176424K150005000
P800015712020C0046113724K250002800
P800015712020C0072672924K250002800
P800015712020C0045912324K150005000
P800015712020C0046716724K150005000
P800015712020C0044837324K150005000
P800015712020C0039875024K150005000
P800015712020C0052671524K150005000
P800015712020C0071111924K150005000
P800015712020C0072622424K150005000
P800015712020C0029800024K150005000
P800015712020C0068702024K150005000
P800015712020C0001042124K150005000
P800015712020C0027933124K150005000
P800015712020C0013065824K150005000
P800015712020C0051024824K250002800
P800015712020C0055914624K150005000
P800015712020C0069208724K250002800
P800015712020C0016536524K150005000
P800015712020C0005355324K150005000
P800015712020C0057152124K250002800
P800015712020C0071258824K250002800
P800015712020C0044869624K150005000
P800015712020C0001555224K150005000
P800015712020C0069367124K250002800
P800015712020C0052521224K150005000
P800015712020C0057624924K250002800
P800015712020C0021498124K150005000
P800015712020C0037603824K150005000
P800015712020C0058315324K250002800
P800015712020C0009457324K150005000
P800015712020C0054014624K150005000
P800015712020C0064748724K150005000
P800015712020C0069191524K150005000
P800015712020C0075428324K150005000
P800015712020C0056324724K150005000
P800015712020C0004159024K150005000
P800015712020C0032507624K150005000
P800015712020C0055118424K150005000
P800015712020C0001079324K150005000
P800015712020C0048605024K150005000
P800015712020C0022892424K150005000
P800015712020C0048153124K150005000
P800015712020C0064066424K246002300
P800015712020C0070799224K345003000
P800015712020C0018745024K244173280
P800015712020C0072991324K240002000
P800015712020C0060783824K240002000
P800015712020C0045846324K240002000
P800015712020C0063225724K240002000
P800015712020C0047682024K240002000
P800015712020C0045929724K240002000
P800015712020C0044462024K240002000
P800015712020C0047337124K240002000
P800015712020C0044321824K240002000
P800015712020C0039334824K140004000
P800015712020C0030475824K240002000
P800015712020C0065314724K240002000
P800015712020C0049834524K240002000
P800015712020C0048273724K240002000
P800015712020C0049676024K140004000
P800015712020C0054186224K240002000
P800015712020C0054574924K240002000
P800015712020C0031324724K240002000
P800015712020C0035903424K240002000
P800015712020C0048554024K240002800
P800015712020C0043638624K140004000
P800015712020C0039495724K240002000
P800015712020C0065322024K240002000
P800015712020C0063206724K240002000
P800015712020C0041659424K240002000
P800015712020C0061045124K240002000
P800015712020C0049278524K240002000
P800015712020C0029664024K140004000
P800015712020C0050470424K240002000
P800015712020C0050020724K140004000
P800015712020C0060869524K240002000
P800015712020C0025733724K240002000
P800015712020C0066639624K240002000
P800015712020C0048298424K240002000
P800015712020C0037693924K240002000
P800015712020C0037253224K240002000
P800015712020C0044502324K240002000
P800015712020C0065840124K140004000
P800015712020C0058230424K240002000
P800015712020C0046237424K240002000
P800015712020C0043791324K240002000
P800015712020C0067189124K240002000
P800015712020C0031104324K240002000
P800015712020C0045867924K240002000
P800015712020C0054396724K240002000
P800015712020C0046857924K240002000
P800015712020C0042093524K240002000
P800015712020C0066849124K240002000
P800015712020C0070317324K240002800
P800015712020C0072876624K236782800
P800015712020C0075873024K934002042
P800015712020C0032546424K128772877
P800015712020C0010028924K128002800
P800015712020C0056754524K128002800
P800015712020C0074526524K128002800
P800015712020C0037675624K228001800
P800015712020C0070817224K128002800
P800015712020C0036768024K128002800
P800015712020C0064114224K128002800
P800015712020C0024132324K127002700
P800015712020C0049979824K125002500
P800015712020C0066391424K225002000
P800015712020C0069162624K125002500
P800015712020C0048990624K125002500
P800015712020C0068164324K125002500
P800015712020C0060772124K125002500
P800015712020C0040439224K125002500
P800015712020C0033229624K321921235
P800015712020C0064742024K120002000
P800015712020C0054828924K120002000
P800015712020C0065472324K120002000
P800015712020C0049336124K220001000
P800015712020C0046006324K120002000
P800015712020C0049380924K120002000
P800015712020C0055070724K120002000
P800015712020C0049223124K120002000
P800015712020C0054818024K120002000
P800015712020C0036852224K120002000
P800015712020C0047774524K120002000
P800015712020C0055494924K120002000
P800015712020C0054444524K120002000
P800015712020C0046065924K120002000
P800015712020C0013080724K110001000
P800015712018C0052727524K110001000
P800015712020C0072422924K110001000
P800015712020C0076060324K110001000
P800015712020C0071795924K110001000
P800015712020C0057767624K110001000
P800015712020C0052431424K110001000
P800015712020C0068040524K110001000
P800015712020C0076025624K21000500
P800015712020C0066906924K110001000
P800015712020C0063316424K110001000
P800015712020C0030330524K110001000
P800015712020C0048794224K110001000
P800015712020C0072374224K1750750
P800015712020C0004123624K1650650
P800015712020C0032114124K1634634
P800015712020C0003108824K1633633
P800015712020C0070608524K1600600
P800015712020C0007819624K1571571
P800015712020C0051220224K1500500
P800015712020C0054426224K1500500
P800015712020C0001409224K1398398
P800015712020C0025727924K1313313
P800015712020C0069732624K1250250
P800015712020C0071168924K2130100
P800015712018C0035579224K1100100
P800015712020C0046114524K15050
P800015712020C0035579224K13535
P800015712020C0000460624K13535

23_qa_intermediary_reporting

Separates conduit and recipient reporting groups.

SQL: 23_qa_intermediary_reporting

Open complete SQL file

-- 23_qa_intermediary_reporting
-- Earmarked donations can be reported by the conduit (ActBlue or WinRed)
-- and again by the recipient committee. I split the individual records
-- by who reported them and by transaction type, and flag which rows pass
-- my Q1 transaction filter.
WITH committees AS (
  SELECT
    2018 AS cycle,
    cmte_id,
    STRING_AGG(DISTINCT cmte_nm, '; ') AS cmte_nm
  FROM `bigquery-public-data.fec.committee_2018`
  GROUP BY 1, 2
  UNION ALL
  SELECT
    2020,
    cmte_id,
    STRING_AGG(DISTINCT cmte_nm, '; ')
  FROM `bigquery-public-data.fec.committee_2020`
  GROUP BY 1, 2
),
records AS (
  SELECT DISTINCT
    2018 AS cycle,
    sub_id,
    cmte_id,
    other_id,
    transaction_tp,
    transaction_dt,
    CAST(transaction_amt AS NUMERIC) AS amount
  FROM `bigquery-public-data.fec.indiv18`
  WHERE entity_tp = 'IND'
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_amt > 0
    AND transaction_dt BETWEEN '2017-01-01' AND '2018-12-31'
  UNION ALL
  SELECT DISTINCT
    2020,
    sub_id,
    cmte_id,
    other_id,
    transaction_tp,
    transaction_dt,
    CAST(transaction_amt AS NUMERIC)
  FROM `bigquery-public-data.fec.indiv20`
  WHERE entity_tp = 'IND'
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_amt > 0
    AND transaction_dt BETWEEN '2019-01-01' AND '2020-12-31'
)
SELECT
  r.cycle,
  CASE
    WHEN REGEXP_CONTAINS(UPPER(c.cmte_nm), r'ACTBLUE|WINRED')
      THEN CONCAT('reported by ', c.cmte_nm)
    WHEN REGEXP_CONTAINS(UPPER(o.cmte_nm), r'ACTBLUE|WINRED')
      THEN CONCAT('other_id: ', o.cmte_nm)
    ELSE 'other reporting'
  END AS flow,
  r.transaction_tp,
  r.transaction_tp IN (
    '10', '15', '15E',
    '30', '30E', '31', '31E', '32', '32E'
  ) AS included_in_q1,
  COUNT(*) AS n_records,
  SUM(amount) AS amount
FROM records AS r
LEFT JOIN committees AS c
  ON r.cycle = c.cycle
  AND r.cmte_id = c.cmte_id
LEFT JOIN committees AS o
  ON r.cycle = o.cycle
  AND r.other_id = o.cmte_id
GROUP BY 1, 2, 3, 4
ORDER BY 1, 2, amount DESC;
Full results: 23_qa_intermediary_reporting (49 rows)

BigQuery follow-up run; job IDs in query_jobs.json

cycleflowtransaction_tpincluded_in_q1n_recordsamount
2018other reporting15true91365702322716093
2018other reporting10true200467926105496
2018other reporting22Yfalse16180365475860
2018other reporting15Etrue11756447468311
2018other reporting31true174741003691
2018other reporting15Cfalse160139425560
2018other reporting24Tfalse7017823081690
2018other reporting32true56515204467
2018other reporting30true1877610886
2018other reporting24Ifalse75163796105
2018other reporting20Yfalse5541919861
2018other reporting11false435956376
2018other reporting21Yfalse49700
2018other_id: ACTBLUE15Etrue5126876657259968
2018other_id: ACTBLUE24Ifalse6821724291
2018other_id: ACTBLUE24Tfalse1412224603
2018reported by ACTBLUE24Tfalse5253631687633986
2018reported by ACTBLUE15true5042224584595
2018reported by ACTBLUE22Yfalse63017835660
2018reported by ACTBLUE20Yfalse192982
2020other reporting15true115289563448112551
2020other reporting10true7817372027223601
2020other reporting15Etrue5243558683335696
2020other reporting22Yfalse283435128371836
2020other reporting24Tfalse9744549799710
2020other reporting31true661742428188
2020other reporting32true211029941173
2020other reporting20Yfalse470216839872
2020other reporting30true30613157881
2020other reporting24Ifalse25201211340
2020other reporting15Cfalse2481011367
2020other reporting11false344760925
2020other reporting42Yfalse70154666
2020other reporting30Tfalse135500
2020other reporting21Yfalse69200
2020other reporting41Yfalse6225
2020other_id: ACTBLUE15Etrue186269461801148249
2020other_id: ACTBLUE24Tfalse46787842
2020other_id: ACTBLUE24Ifalse5045014
2020other_id: WINRED15Etrue4125031584456022
2020other_id: WINRED24Ifalse6433012
2020other_id: WINRED24Tfalse10417002
2020reported by ACTBLUE24Tfalse207331952140427718
2020reported by ACTBLUE15true206032315355265
2020reported by ACTBLUE22Yfalse1087712154974
2020reported by ACTBLUE15Etrue223133056
2020reported by WINRED15Etrue223839561413034075
2020reported by WINRED22Yfalse775973115635612
2020reported by WINRED24Tfalse114105787

24_qa_winred_candidate_reporting

Inspects WinRed reporting for the largest candidate.

SQL: 24_qa_winred_candidate_reporting

Open complete SQL file

-- 24_qa_winred_candidate_reporting
-- WinRed reports most of the largest candidate total. I look at the
-- entity type, memo text and other_id on those records to see whether
-- they identify where the money came from.
SELECT
  entity_tp,
  memo_text,
  other_id,
  COUNT(*) AS n_records,
  SUM(transaction_amt) AS amount
FROM `bigquery-public-data.fec.committee_contributions_2020`
WHERE cand_id = 'P80001571'
  AND cmte_id = 'C00694323'
  AND transaction_tp IN ('24K', '24Z')
  AND transaction_amt > 0
  AND COALESCE(TRIM(memo_cd), '') = ''
  AND transaction_dt BETWEEN '2019-01-01' AND '2020-12-31'
GROUP BY 1, 2, 3
ORDER BY amount DESC;
Full results: 24_qa_winred_candidate_reporting (1 row)

BigQuery follow-up run; job IDs in query_jobs.json

entity_tpmemo_textother_idn_recordsamount
CCMNULLC0058010025762548407633

25_qa_winred_linkage

Profiles the available WinRed linkage fields.

SQL: 25_qa_winred_linkage

Open complete SQL file

-- 25_qa_winred_linkage
-- Inspect linkage fields before deciding how to handle conduit reporting.
WITH records AS (
  SELECT DISTINCT
    sub_id,
    cmte_id,
    other_id,
    memo_text,
    tran_id,
    transaction_dt,
    CAST(transaction_amt AS NUMERIC) AS amount
  FROM `bigquery-public-data.fec.indiv20`
  WHERE (cmte_id = 'C00694323' OR other_id = 'C00694323')
    AND entity_tp = 'IND'
    AND transaction_tp = '15E'
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_amt > 0
    AND transaction_dt BETWEEN DATE '2019-01-01' AND DATE '2020-12-31'
)
SELECT
  IF(cmte_id = 'C00694323', 'conduit_report', 'recipient_report') AS reporting_side,
  COUNT(*) AS n_records,
  SUM(amount) AS amount,
  COUNTIF(NULLIF(TRIM(other_id), '') IS NOT NULL) AS with_other_id,
  COUNTIF(NULLIF(TRIM(memo_text), '') IS NOT NULL) AS with_memo,
  ARRAY_AGG(DISTINCT COALESCE(other_id, 'NULL') LIMIT 8) AS example_other_ids,
  ARRAY_AGG(DISTINCT COALESCE(memo_text, 'NULL') LIMIT 8) AS example_memos
FROM records
GROUP BY 1;
Full results: 25_qa_winred_linkage (2 rows)

BigQuery follow-up run; job IDs in query_jobs.json

reporting_siden_recordsamountwith_other_idwith_memoexample_other_idsexample_memos
conduit_report223839561413034075223838730["C00027466", "C00075820", "C00193342", "C00580100", "C00618371", "C00633362", "C00420935", "C00314575"]["NULL"]
recipient_report412503158445602241250312527085["C00694323"]["NULL", "EARMARKED FROM WINRED", "EARMARKED FROM WIN RED", "EARMARKED FROM WINRED - CONDUIT", "EARMARKED FROM WIN RED; SEE REDESIGNATION", "EARMARKED THROUGH WINRED RECOUNT RECEIPT AS PER AO 2006-24", "EARMARKED THROUGH WINRED", "EARMARKED FROM WINRED; REFUNDED $30.00 ON 09/24/2020"]

26_qa_winred_matching_records

Compares WinRed and recipient records using five matching fields.

SQL: 26_qa_winred_matching_records

Open complete SQL file

-- 26_qa_winred_matching_records
-- Matching fields indicate possible duplicate reporting, not a unique donation ID.
WITH records AS (
  SELECT DISTINCT
    sub_id,
    cmte_id,
    other_id,
    UPPER(TRIM(name)) AS donor_name,
    SUBSTR(TRIM(zip_code), 1, 5) AS zip5,
    transaction_dt,
    CAST(transaction_amt AS NUMERIC) AS amount
  FROM `bigquery-public-data.fec.indiv20`
  WHERE (cmte_id = 'C00694323' OR other_id = 'C00694323')
    AND entity_tp = 'IND'
    AND transaction_tp = '15E'
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_amt > 0
    AND transaction_dt BETWEEN DATE '2019-01-01' AND DATE '2020-12-31'
),
grouped AS (
  SELECT
    IF(cmte_id = 'C00694323', other_id, cmte_id) AS recipient,
    donor_name,
    zip5,
    transaction_dt,
    amount,
    COUNTIF(cmte_id = 'C00694323') AS conduit_rows,
    COUNTIF(cmte_id != 'C00694323') AS recipient_rows
  FROM records
  GROUP BY 1, 2, 3, 4, 5
),
classified AS (
  SELECT
    *,
    CASE
      WHEN NULLIF(TRIM(recipient), '') IS NULL
        OR NULLIF(donor_name, '') IS NULL
        OR NULLIF(zip5, '') IS NULL
        THEN 'incomplete_match_fields'
      WHEN conduit_rows = 1 AND recipient_rows = 1
        THEN 'one_to_one_field_match'
      WHEN conduit_rows > 0 AND recipient_rows > 0
        THEN 'multiple_records_share_fields'
      WHEN conduit_rows > 0
        THEN 'conduit_only'
      ELSE 'recipient_only'
    END AS match_status
  FROM grouped
)
SELECT
  match_status,
  COUNT(*) AS n_groups,
  SUM(conduit_rows) AS conduit_rows,
  SUM(recipient_rows) AS recipient_rows,
  SUM(conduit_rows * amount) AS conduit_amount,
  SUM(recipient_rows * amount) AS recipient_amount,
  SUM(LEAST(conduit_rows, recipient_rows) * amount) AS potential_shared_amount
FROM classified
GROUP BY 1
ORDER BY 1;
Full results: 26_qa_winred_matching_records (5 rows)

BigQuery follow-up run; job IDs in query_jobs.json

match_statusn_groupsconduit_rowsrecipient_rowsconduit_amountrecipient_amountpotential_shared_amount
conduit_only18919555200793710103982933200
incomplete_match_fields3258425917188304450
multiple_records_share_fields177252388461389931320952283266223630716064
one_to_one_field_match191604019160401916040341092327341092327341092327
recipient_only16502090181880102106710140

27_qa_winred_sensitivity

Measures the effect of excluding WinRed conduit reporting.

The full SQL appears inline above and in 27_qa_winred_sensitivity.sql.

Full results: 27_qa_winred_sensitivity (27 rows)

BigQuery follow-up run; job IDs in query_jobs.json

result_typedetails
q2_without_winred{"n_candidates":2603,"amount":1053851132,"minimum":8,"mean":"404860.212063004","approximate_median":15750,"maximum":6283681,"population_stddev":799105.12097680452,"top_ten_percent_share_pct":"59.608895","largest_candidate":[{"cand_id":"S4GA11285","amount":6283681}]}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-12-01","n_records":320995,"amount":31070942}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-10-01","n_records":123309,"amount":17725295}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-11-01","n_records":4340295,"amount":263210282}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-07-01","n_records":1773527,"amount":124377745}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-02-01","n_records":323785,"amount":21960984}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2019-07-01","n_records":2964,"amount":960293}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-05-01","n_records":21082,"amount":1483993}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-11-01","n_records":316397,"amount":34628733}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-03-01","n_records":462201,"amount":27867494}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-04-01","n_records":12872,"amount":1032537}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-07-01","n_records":80751,"amount":16176747}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-10-01","n_records":6336381,"amount":363644276}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-05-01","n_records":980038,"amount":47943731}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2019-11-01","n_records":127158,"amount":8940130}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-09-01","n_records":2992174,"amount":210012778}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-01-01","n_records":148253,"amount":13254619}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2019-10-01","n_records":76778,"amount":6529533}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-06-01","n_records":51552,"amount":6495995}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-06-01","n_records":1549632,"amount":99386766}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2019-08-01","n_records":13762,"amount":2421456}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2019-12-01","n_records":232118,"amount":18681134}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-08-01","n_records":82467,"amount":15192914}
excluded_q1_monthly{"source_type":"committee","contribution_month":"2020-09-01","n_records":88210,"amount":16810310}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-08-01","n_records":2261994,"amount":161413156}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2020-04-01","n_records":715844,"amount":35311083}
excluded_q1_monthly{"source_type":"individual","contribution_month":"2019-09-01","n_records":47052,"amount":7118615}

28_qa_committee_platform_reporting

SQL: 28_qa_committee_platform_reporting

Complete SQL file

-- 28_qa_committee_platform_reporting
-- Check the named platforms in both committee sources before assuming
-- that the individual-source transaction pattern also applies here.
WITH names AS (
  SELECT 2018 AS cycle, cmte_id,
    STRING_AGG(DISTINCT cmte_nm, '; ') AS name
  FROM `bigquery-public-data.fec.committee_2018`
  GROUP BY cycle, cmte_id
  UNION ALL
  SELECT 2020 AS cycle, cmte_id,
    STRING_AGG(DISTINCT cmte_nm, '; ') AS name
  FROM `bigquery-public-data.fec.committee_2020`
  GROUP BY cycle, cmte_id
), records AS (
  SELECT 'Q1 broader source' AS source, cycle, cmte_id,
    transaction_tp, amount, is_q1_in_scope AS included
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_q1`
  UNION ALL
  SELECT 'Q2 candidate source', cycle, cmte_id,
    transaction_tp, amount, is_q2_in_scope
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
)
SELECT r.source, r.cycle, n.name, r.cmte_id, r.transaction_tp, r.included,
  COUNT(*) AS n_records, SUM(r.amount) AS amount
FROM records AS r
JOIN names AS n ON r.cycle = n.cycle AND r.cmte_id = n.cmte_id
WHERE REGEXP_CONTAINS(UPPER(n.name), r'ACTBLUE|WINRED')
GROUP BY r.source, r.cycle, n.name, r.cmte_id, r.transaction_tp, r.included
ORDER BY r.source, r.cycle, n.name, r.transaction_tp, r.included;
Captured results: 28_qa_committee_platform_reporting
[
  {
    "source": "Q1 broader source",
    "cycle": "2018",
    "name": "ACTBLUE",
    "cmte_id": "C00401224",
    "transaction_tp": "24K",
    "included": "true",
    "n_records": "5",
    "amount": "37000"
  },
  {
    "source": "Q1 broader source",
    "cycle": "2020",
    "name": "ACTBLUE",
    "cmte_id": "C00401224",
    "transaction_tp": "24K",
    "included": "false",
    "n_records": "1",
    "amount": "-65"
  },
  {
    "source": "Q1 broader source",
    "cycle": "2020",
    "name": "ACTBLUE",
    "cmte_id": "C00401224",
    "transaction_tp": "24K",
    "included": "true",
    "n_records": "1",
    "amount": "200000"
  },
  {
    "source": "Q1 broader source",
    "cycle": "2020",
    "name": "WINRED",
    "cmte_id": "C00694323",
    "transaction_tp": "24K",
    "included": "false",
    "n_records": "109752",
    "amount": "-13961280"
  },
  {
    "source": "Q1 broader source",
    "cycle": "2020",
    "name": "WINRED",
    "cmte_id": "C00694323",
    "transaction_tp": "24K",
    "included": "true",
    "n_records": "1097635",
    "amount": "140617466"
  },
  {
    "source": "Q2 candidate source",
    "cycle": "2020",
    "name": "ACTBLUE",
    "cmte_id": "C00401224",
    "transaction_tp": "24K",
    "included": "false",
    "n_records": "1",
    "amount": "-65"
  },
  {
    "source": "Q2 candidate source",
    "cycle": "2020",
    "name": "WINRED",
    "cmte_id": "C00694323",
    "transaction_tp": "24K",
    "included": "false",
    "n_records": "48771",
    "amount": "-7880358"
  },
  {
    "source": "Q2 candidate source",
    "cycle": "2020",
    "name": "WINRED",
    "cmte_id": "C00694323",
    "transaction_tp": "24K",
    "included": "true",
    "n_records": "472869",
    "amount": "66674728"
  }
]

29_model_reporting_context

SQL: 29_model_reporting_context

Complete SQL file

-- 29_model_reporting_context
-- Save the verified individual staging rows once for repeated analysis.
-- Cluster only: historical dates must not inherit sandbox partition expiry.
-- Query 17 remains the original build; run this upgrade after query 18.
CREATE OR REPLACE TABLE `fec-contributions-analysis.fec_analysis.individual_records_cached`
CLUSTER BY cycle, cmte_id
AS
WITH records AS (
  SELECT
    2018 AS cycle,
    sub_id,
    cmte_id,
    entity_tp,
    transaction_tp,
    memo_cd,
    transaction_dt,
    transaction_amt
  FROM `bigquery-public-data.fec.indiv18`
  UNION ALL
  SELECT DISTINCT
    2020 AS cycle,
    sub_id,
    cmte_id,
    entity_tp,
    transaction_tp,
    memo_cd,
    transaction_dt,
    transaction_amt
  FROM `bigquery-public-data.fec.indiv20`
)
SELECT
  cycle,
  sub_id,
  cmte_id,
  entity_tp,
  transaction_tp,
  memo_cd,
  transaction_dt,
  CAST(transaction_amt AS NUMERIC) AS amount,
  COALESCE(
    entity_tp = 'IND'
    AND transaction_tp IN (
      '10', '15', '15E',
      '30', '30E', '31', '31E', '32', '32E'
    )
    AND transaction_amt > 0
    AND COALESCE(TRIM(memo_cd), '') = ''
    AND transaction_dt BETWEEN
      DATE(cycle - 1, 1, 1) AND DATE(cycle, 12, 31),
    FALSE
  ) AS is_q1_in_scope
FROM records;
CREATE OR REPLACE VIEW `fec-contributions-analysis.fec_analysis.stg_individual_q1`
AS SELECT * FROM `fec-contributions-analysis.fec_analysis.individual_records_cached`;
-- One row per committee and cycle. Keep conflicting labels visible.
CREATE OR REPLACE TABLE `fec-contributions-analysis.fec_analysis.dim_committee` AS
WITH records AS (
  SELECT 2018 AS cycle, cmte_id, cmte_nm, cmte_tp, cmte_pty_affiliation, cmte_st
  FROM `bigquery-public-data.fec.committee_2018`
  UNION ALL
  SELECT 2020, cmte_id, cmte_nm, cmte_tp, cmte_pty_affiliation, cmte_st
  FROM `bigquery-public-data.fec.committee_2020`
)
SELECT cycle, cmte_id,
  STRING_AGG(DISTINCT cmte_nm, '; ' ORDER BY cmte_nm) AS committee_name,
  STRING_AGG(DISTINCT cmte_tp, '; ' ORDER BY cmte_tp) AS committee_type,
  STRING_AGG(DISTINCT cmte_pty_affiliation, '; ' ORDER BY cmte_pty_affiliation) AS party,
  STRING_AGG(DISTINCT cmte_st, '; ' ORDER BY cmte_st) AS state,
  COUNT(*) AS source_rows,
  LOGICAL_OR(REGEXP_CONTAINS(UPPER(COALESCE(cmte_nm, '')), r'ACTBLUE|WINRED')) AS is_named_platform
FROM records
WHERE cmte_id IS NOT NULL
GROUP BY cycle, cmte_id;
CREATE OR REPLACE TABLE `fec-contributions-analysis.fec_analysis.dim_candidate` AS
WITH records AS (
  SELECT 2018 AS cycle, cand_id, cand_name, cand_pty_affiliation, cand_office, cand_ici
  FROM `bigquery-public-data.fec.candidate_2018`
  UNION ALL
  SELECT 2020, cand_id, cand_name, cand_pty_affiliation, cand_office, cand_ici
  FROM `bigquery-public-data.fec.candidate_2020`
)
SELECT cycle, cand_id,
  STRING_AGG(DISTINCT cand_name, '; ' ORDER BY cand_name) AS candidate_name,
  STRING_AGG(DISTINCT cand_pty_affiliation, '; ' ORDER BY cand_pty_affiliation) AS party,
  STRING_AGG(DISTINCT cand_office, '; ' ORDER BY cand_office) AS office,
  STRING_AGG(DISTINCT cand_ici, '; ' ORDER BY cand_ici) AS incumbent_status,
  COUNT(*) AS source_rows
FROM records
WHERE cand_id IS NOT NULL
GROUP BY cycle, cand_id;
-- Enrich Q1 without adding the overlapping Q2 contribution records again.
-- A platform flag identifies the reporting committee, not the ultimate donor.
CREATE OR REPLACE VIEW `fec-contributions-analysis.fec_analysis.fct_contributions` AS
WITH attribution AS (
  SELECT cycle, sub_id,
    IF(COUNT(DISTINCT cand_id) = 1, MIN(cand_id), NULL) AS candidate_id
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_contributions`
  GROUP BY cycle, sub_id
), records AS (
  SELECT 'individual' AS source_type, cycle, sub_id, transaction_dt, amount,
    cmte_id AS reporting_committee_id, cmte_id AS recipient_committee_id,
    CAST(NULL AS STRING) AS candidate_id, transaction_tp
  FROM `fec-contributions-analysis.fec_analysis.stg_individual_q1`
  WHERE is_q1_in_scope
  UNION ALL
  SELECT 'committee', s.cycle, s.sub_id, s.transaction_dt, s.amount,
    s.cmte_id, s.other_id, a.candidate_id, s.transaction_tp
  FROM `fec-contributions-analysis.fec_analysis.stg_committee_q1` AS s
  LEFT JOIN attribution AS a ON s.cycle = a.cycle AND s.sub_id = a.sub_id
  WHERE s.is_q1_in_scope
)
SELECT r.*, COALESCE(d.is_named_platform, FALSE) AS is_platform_reported,
  d.committee_name AS reporting_committee_name
FROM records AS r
LEFT JOIN `fec-contributions-analysis.fec_analysis.dim_committee` AS d
  ON r.cycle = d.cycle AND r.reporting_committee_id = d.cmte_id;
-- Counts and amounts must remain equal to the previously reconciled model.
WITH actual AS (
  SELECT source_type, COUNT(*) AS n_records, SUM(amount) AS amount
  FROM `fec-contributions-analysis.fec_analysis.fct_contributions`
  GROUP BY source_type
), expected AS (
  SELECT source_type, SUM(n_contributions) AS n_records, SUM(gross_amount) AS amount
  FROM `fec-contributions-analysis.fec_analysis.agg_contributions_monthly`
  GROUP BY source_type
)
SELECT e.source_type, a.n_records, a.amount,
  COALESCE(a.n_records = e.n_records AND a.amount = e.amount, FALSE) AS check_passed
FROM expected AS e LEFT JOIN actual AS a USING (source_type)
ORDER BY source_type;
Captured results: 29_model_reporting_context
[
  {
    "source_type": "committee",
    "n_records": "1750504",
    "amount": "2720479967",
    "check_passed": "true"
  },
  {
    "source_type": "individual",
    "n_records": "79847961",
    "amount": "14080279264",
    "check_passed": "true"
  }
]