Skip to content

Federal SALT's state income tax term is an AGI-formula withholding estimate that misses state liability in both directions (and is labeled as liability) #9837

Open

Description

@MaxGhenis

Summary

Federal SALT's state income tax term is state_withheld_income_tax. That variable is a per-person withholding estimate: one single-filer schedule applied to each person's federal AGI. It ignores:

  • filing status, dependents and age
  • state income exclusions such as Social Security and, in some states, retirement income
  • state itemized deductions
  • credits

It misses the engine's own state liability by thousands of dollars in both directions, and its label reads "state income tax before refundable credits", the label of the liability aggregate.

On main for 2026, putting the engine's own state liability in SALT for itemizers moves federal income_tax_before_refundable_credits by:

Household (2026, itemizes) Estimate Engine state liability Federal tax change
CA retired couple: $110k pension, $52k Social Security 8,686.49 1,073.16 +1,381.53
CA one-earner couple, 2 kids, $180k wages 12,555.24 5,288.39 +1,598.71
VA retired couple: $70k pension, $30k wages, $52k Social Security 6,770.25 3,138.55 +435.80
MA one-earner couple, 2 kids, $150k wages 7,280.00 6,860.00 +92.40
MD two-earner couple, 2 kids, Montgomery, $160k + $40k 15,591.30 13,315.70 (state + county) +500.63
CT two-earner couple, $80k + $70k 5,100.00 7,250.00 −473.00

So federal tax is understated in the first five cases and overstated in the last.

Why SALT uses an estimate (design history)

The withholding approach is deliberate, and the circularity it avoids is real:

Schedule A line 5a deducts state income tax paid during the year: withholding, estimated payments and prior-year balances. Neither liability nor this estimate is that amount, and real over-withholding is deductible. The problem is fidelity and labeling, not the choice of a paid measure.

Applying a wage-withholding schedule to Social Security and pensions does not model what retirees actually have withheld. That point is inferred, not computed.

Code on main (dfdf881; unchanged on db45d2c except a boundary fix in OH)

Reproduction (policyengine-us 2.25.0)

import copy
from policyengine_us import Simulation

TAX_YEAR = 2026


def situation(state, people, real_estate_taxes, mortgage_interest, county=None):
    names = [f"p{i}" for i in range(len(people))]
    persons = {n: {k: {TAX_YEAR: v} for k, v in p.items()} for n, p in zip(names, people)}
    persons["p0"]["real_estate_taxes"] = {TAX_YEAR: real_estate_taxes}
    persons["p0"]["deductible_mortgage_interest"] = {TAX_YEAR: mortgage_interest}
    household = {"members": names, "state_code": {TAX_YEAR: state}}
    if county:
        household["county_str"] = {TAX_YEAR: county}
    return {
        "people": persons,
        "tax_units": {"tu": {"members": names}},
        "marital_units": {"mu": {"members": names[:2]}},
        "households": {"hh": household},
    }


def get(sim, var):
    return float(sim.calculate(var, TAX_YEAR).sum())


def liability(sim, st):
    # The engine's own state income tax before refundable credits. MD county tax
    # is added because md_withheld_income_tax includes it.
    value = get(sim, f"{st}_income_tax_before_refundable_credits")
    if st == "md":
        value += get(sim, "md_local_income_tax_before_refundable_credits")
    return value


CASES = [
    ("CA retired couple: $110k pension, $52k Social Security", "ca", situation("CA", [
        {"age": 68, "taxable_private_pension_income": 110_000, "social_security_retirement": 32_000},
        {"age": 66, "social_security_retirement": 20_000},
    ], 11_000, 24_000)),
    ("CA one-earner couple, 2 kids: $180k wages", "ca", situation("CA", [
        {"age": 45, "employment_income": 180_000}, {"age": 43}, {"age": 10}, {"age": 8},
    ], 9_000, 22_000)),
    ("VA retired couple: $70k pension, $30k wages, $52k Social Security", "va", situation("VA", [
        {"age": 68, "taxable_private_pension_income": 70_000, "social_security_retirement": 32_000},
        {"age": 66, "employment_income": 30_000, "social_security_retirement": 20_000},
    ], 8_000, 25_000)),
    ("MA one-earner couple, 2 kids: $150k wages", "ma", situation("MA", [
        {"age": 45, "employment_income": 150_000}, {"age": 43}, {"age": 10}, {"age": 8},
    ], 9_000, 20_000)),
    ("MD two-earner couple, 2 kids, Montgomery: $160k + $40k wages", "md", situation("MD", [
        {"age": 45, "employment_income": 160_000}, {"age": 43, "employment_income": 40_000},
        {"age": 10}, {"age": 8},
    ], 8_000, 20_000, county="MONTGOMERY_COUNTY_MD")),
    ("CT two-earner couple: $80k + $70k wages", "ct", situation("CT", [
        {"age": 45, "employment_income": 80_000}, {"age": 43, "employment_income": 70_000},
    ], 9_000, 24_000)),
]

for label, st, sit in CASES:
    sim = Simulation(situation=sit)
    estimate, owed = get(sim, "state_withheld_income_tax"), liability(sim, st)
    base_fed = get(sim, "income_tax_before_refundable_credits")
    assert get(sim, "tax_unit_itemizes") == 1
    # Counterfactual: SALT's state income tax = the engine's state liability,
    # iterated to a fixed point because state tax can read federal values.
    value = owed
    for passes in range(1, 11):
        cf_sit = copy.deepcopy(sit)
        cf_sit["tax_units"]["tu"]["state_withheld_income_tax"] = {TAX_YEAR: value}
        cf = Simulation(situation=cf_sit)
        new = liability(cf, st)
        if abs(new - value) < 0.01:
            break
        value = new
    cf_fed = get(cf, "income_tax_before_refundable_credits")
    print(f"{label}\n  estimate {estimate:,.2f} | {st.upper()} liability {owed:,.2f} | "
          f"federal tax {base_fed:,.2f} -> {cf_fed:,.2f} ({cf_fed - base_fed:+,.2f}; "
          f"fixed point in {passes} pass)")
CA retired couple: $110k pension, $52k Social Security
  estimate 8,686.49 | CA liability 1,073.16 | federal tax 11,386.10 -> 12,767.63 (+1,381.53; fixed point in 1 pass)
CA one-earner couple, 2 kids: $180k wages
  estimate 12,555.24 | CA liability 5,288.39 | federal tax 15,041.85 -> 16,640.55 (+1,598.71; fixed point in 1 pass)
VA retired couple: $70k pension, $30k wages, $52k Social Security
  estimate 6,770.25 | VA liability 3,138.55 | federal tax 10,595.57 -> 11,031.37 (+435.80; fixed point in 1 pass)
MA one-earner couple, 2 kids: $150k wages
  estimate 7,280.00 | MA liability 6,860.00 | federal tax 10,042.40 -> 10,134.80 (+92.40; fixed point in 1 pass)
MD two-earner couple, 2 kids, Montgomery: $160k + $40k wages
  estimate 15,591.30 | MD liability 13,315.70 | federal tax 19,433.91 -> 19,934.54 (+500.63; fixed point in 1 pass)
CT two-earner couple: $80k + $70k wages
  estimate 5,100.00 | CT liability 7,250.00 | federal tax 14,042.00 -> 13,569.00 (-473.00; fixed point in 1 pass)

An independent check with a different counterfactual (overriding the per-state person inputs, or state_and_local_sales_or_income_tax directly) gives the same deltas to the cent. AMT is 0 and the $40,400 cap does not bind in any case.

How far off across states

Four archetypes were run in all 42 withholding states (2026). They don't itemize; they compare the estimate with liability only.

Archetype Estimate vs liability
Retired couple (SS $50k, pension $60k) Above in 42/42 states, by +$367 (ND) to +$6,375 (MD). Liability is $0 but the estimate is positive in 12: CT, GA, HI, IA, IL, ME, MI, MS, ND, NJ, PA and RI.
One-earner couple, 2 kids, $150k Above in 38/42, e.g. CA +$5,334, VT +$3,239, OR +$2,628
Two-earner couple, $80k + $70k Below in 11 states, e.g. CT −$2,150, SC −$1,725, WI −$1,227, UT −$967, WV −$843
Single, $60k Below in 7 states

Federal tax moves only for itemizers below the SALT cap who are not on AMT. The 2026 cap ($40,400) leaves many more itemizers exposed than the 2018–2024 $10,000 cap did.

Microsimulation: the default dataset on main (system.py L64, Populace populace-us-2024-spm-20260909) has no state_and_local_sales_or_income_tax or *_withheld_income_tax column. This was checked against the cached build for that tag, so the formula drives national SALT aggregates too.

Other readers of the estimate

Any change to state_withheld_income_tax also moves:

Tests override the aggregate as an input, for example hi_salt_deduction.yaml L209 and ny_pre_tcja_deductions.yaml L11. Setting it as an input is today the only way to supply actual payments, and doing so also moves the benefit-program readers.

Options, cheapest first

  1. Label and docs (moves no numbers). Relabel to something like "State income tax withheld (estimate)" and document the base: per-person federal AGI, the single-filer schedule, one deduction or exemption.

  2. Cheap estimate fixes, no branch needed:

    • Exclude taxable Social Security (and retirement income where the state's own liability does).
    • Compute at the tax-unit level with filing-status schedules, deductions, and personal and dependent exemptions.
    • For MD, use flat_deduction from 2025.
    • Review the states that understate for two-earner couples (CT, WI, SC, UT, WV) so the error isn't just shifted.
    • Fold in ar_withheld_income_tax ignores Arkansas's minus adjustments and exemption credit #9640 (AR).

    Reading a state's own AGI variable is cycle-free only where that AGI doesn't read federal SALT, itemization or taxable income; CO, SC and NM do.

  3. A stated-payments input, e.g. state_and_local_income_tax_paid (Schedule A line 5a). SALT would read it, it would default to the estimate, and users could override it without touching the benefit-program proxy. This implies splitting the SALT measure from the paycheck-withholding proxy.

  4. Liability option behind a parameter, computed in a branch. A prototype computes state_income_tax_for_salt in a branch where that variable is set to the estimate, so the branch's own SALT doesn't recurse.

    • It reproduced the fixed-point numbers above to the cent and gave the same answer in both calculation orders tried.
    • It adds a branch recomputation, with performance unmeasured.
    • It needs a rule when itemization flips. An MD two-earner couple ($160k + $40k, Montgomery, $8k property tax, $10k mortgage interest) has no fixed point. The liability oscillates between 14,142.20 and 15,084.38 because md_deductions.py L29 lets MD itemize only when the federal unit itemizes.
    • The CO and MO addbacks should read the same variable.
    • A per-state hybrid (liability only where the state's formulas are acyclic) is a variant.

Coordination: open PRs #9741 and #9649 remove the CTC no_salt workaround on the premise that SALT reads this cycle-free estimate (see #9740). A liability-based option must keep that premise or provide its own cycle-safe path. #9741's get_override_branch is the natural primitive.

Related

The local-tax aggregation that the original audit also flagged (NYC and MD county inside the state aggregate) was fixed by #9694.

Found while auditing PolicyBench references on policyengine-us 2.15.17. Every formula above is byte-identical between 2.15.17 and main.

Activity

  1. added
    bugSomething isn't working
    taxFederal and state tax policy
    on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstate-income-taxState income taxestaxFederal and state tax policy

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions