Repository navigation
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
Description
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:
-
Align SALT deduction to tax instructions #1409 asked to align SALT with the Schedule A instructions and noted that modeling withholding "should also address circularities". It was closed on 2026-07-21 with "Reopen if the alignment is off."
-
Replace state income tax with estimated withholding for all states in income tax used for SALT deduction #4024 / PR Replace state income tax with estimated withholding for all states in income tax used for SALT deduction #4045 moved every state to the estimate. @martinholmer objected that states with no circularity would be computed incorrectly. The reply was that withholding better approximates taxes paid, because states rarely build credits into withholding formulas. In the PR conversation: "We only model SINGLE."
-
Model state taxes on unearned income for SALT deduction #5042 / PR Use adjusted gross income in withheld state income taxes #5053: the wage-only estimate was about 40% below total state income tax, so the base became all of AGI ("Addressed by treating all AGI as withholding-taxable in Use adjusted gross income in withheld state income taxes #5053").
-
The cycle is real. Swapping liability in directly raises
CycleErrorfor the households above:- CA closes through
ca_non_refundable_credits → ca_cdcc → cdcc → cdcc_credit_limit → income_tax_before_credits - VA through
va_itemized_deductions → state_and_local_sales_or_income_tax - MA through
ma_part_b_taxable_income_exemption → tax_unit_itemizes - MD through
md_deductions → tax_unit_itemizes
A single-household PA simulation does not cycle.
- CA closes through
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)
- SALT income-or-sales term:
state_and_local_sales_or_income_tax.pyL11-L17 computesmax(state_withheld_income_tax + local_income_tax, state_sales_tax + local_sales_tax). - The estimate:
state_withheld_income_tax.pyL4-L10.- It has no formula; it adds the 42 variables in
state_withheld_income_tax.yaml. - Its label (L7) is word for word that of
state_income_tax_before_refundable_credits.pyL7.
- It has no formula; it adds the 42 variables in
- The per-state formulas: all 42 are Person-level, and line 13 of each reads only
adjusted_gross_income_person. Maryland also reads the county.- Each applies the single-filer (or the state's only) rate schedule.
- Each subtracts at most one SINGLE standard deduction or one personal exemption per person. CT, IL, IN, MA, NJ, OH and WV subtract an exemption.
- Examples:
ca_withheld_income_tax.pyL12-L17va_withheld_income_tax.pyL12-L17 (VA's only schedule, with the SINGLE standard deduction)ma_withheld_income_tax.pyL12-L19 (5% × (person AGI − $4,400))md_withheld_income_tax.pyL12-L47 (state plus county)
- What drives the gaps above (computed):
- CA and VA: the engine's own liability excludes taxable Social Security (
ca_agi= AGI − taxable SS), but the estimate's base includes it. - MA: the one-earner couple's spouse has zero AGI, so the second exemption is lost. The liability uses the joint and dependent exemptions.
- MD: the estimate uses the pre-2025
standard.max["SINGLE"](L16) where liability usesflat_deductionfrom 2025 (md_standard_deduction.pyL21-L22). - CT: a two-earner couple's liability exceeds two single-filer estimates.
- CA and VA: the engine's own liability excludes taxable Social Security (
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:
- Colorado and Missouri addbacks, which are meant to equal what was deducted federally:
co_state_addback.pyL18 andmo_net_state_income_taxes.pyL27. - Benefit programs that use it as a paycheck-withholding proxy:
spm_unit_paycheck_withholdings.pyL13, which feeds LA and Marin general relief- Santa Clara GA
- Riverside GR
- IL AABD
- The Montana federal-itemization copy (Montana: obsolete *_for_federal_itemization copies put a state-withholding proxy and the repealed federal-tax deduction into state_itemized_deductions (TAXSIM v35) #9806), which reaches only
state_itemized_deductions.
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
-
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.
-
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_deductionfrom 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.
-
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. -
Liability option behind a parameter, computed in a branch. A prototype computes
state_income_tax_for_saltin 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.pyL29 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
- Design history: Align SALT deduction to tax instructions #1409, Replace state income tax with estimated withholding for all states in income tax used for SALT deduction #4024, Replace state income tax with estimated withholding for all states in income tax used for SALT deduction #4045, Model state taxes on unearned income for SALT deduction #5042, Use adjusted gross income in withheld state income taxes #5053, Determine strategy to handle circular dependency in the IRC #1235, Improve simultaneous-tax-equations calculation #1827
- Arkansas sub-case: ar_withheld_income_tax ignores Arkansas's minus adjustments and exemption credit #9640
- Branch hazard: ctc_limiting_tax_liability depends on calculation order for itemizers (no_salt branch reuses the parent's cached values) #9740, Limit the CTC by actual tax liability and make formula branches calculate under their overrides #9741, Compute the CTC tax-liability limit from actual liability (fixes request-order dependence) #9649
- Other SALT gaps:
- Federal SALT omits mandatory employee state payroll contributions (SDI, TDI, PFML, PA UC) already computed in employee_state_payroll_tax #9807: mandatory employee state payroll contributions, which belong on the same income-tax side
- SALT sources omit state and local personal property taxes (§164(a)(2)) #9169, SALT: simulation taxable-income ceiling makes salt_deduction diverge from the §164(b)(7) amount at low AGI #9167, SALT cap phaseout uses AGI instead of §164(b)(7)(B)(iv) modified AGI (§§911/931/933 addbacks) #9168
- Montana copy: Montana: obsolete *_for_federal_itemization copies put a state-withholding proxy and the repealed federal-tax deduction into state_itemized_deductions (TAXSIM v35) #9806
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