I keep a paycheck calculator that supports 37 US states. Every state I had added before Arizona had a formula: a flat rate after allowances, or brackets with a standard deduction. Arizona has neither for withholding. The employee fills in Form A-4 and picks a percentage of gross taxable wages: 0.5%, 1.0%, 1.5%, 2.0%, 2.5%, 3.0% or 3.5%. Pick zero if you expect no Arizona tax. Optionally add a fixed dollar amount per paycheck. If you never hand in the form, the employer withholds 2.0%.

That is a different shape of problem. The rate is an input, not a rule, and the input may be missing.

The data model

My engine describes each state as a plain object. Flat states already had rate, so Arizona is a flat state with two extra flags.

arizona: { name: 'Arizona', kind: 'flat', rate: 0.02, azElect: true, noDeduction: true, employee: [] } 

rate holds the default for the "no form on file" case. The calculation takes the elected percentage when there is one:

const pct = (s.azElect && allowances.pct != null) ? allowances.pct : s.rate; incomeTax = taxable * pct; if (s.azElect) incomeTax += allowances.dollar || 0; // extra dollars per paycheck 

The page form is a select with the seven percentages plus zero, and it defaults to 2.0% to match what an employer does without the form.

The zero trap

The first version I wrote was allowances.pct || s.rate. It is wrong in the one case where the user is making a deliberate choice: zero. 0 || 0.02 is 0.02, so a person who certifies they owe no Arizona tax would see 2% withheld.

The fix is to test for "not provided", not for "falsy":

pct: (v.azPct === undefined || v.azPct === '' || v.azPct === null) ? null : Number(v.azPct) / 100 

Missing means null and falls back to the default. A real 0 stays 0. This is the same bug class as treating 0 as an absent quantity or an empty discount, and it hides well because the default case looks right.

Tests

I wrote the cases alongside the engine change, with hand arithmetic on a $2,000 paycheck:

Case Expected Arizona tax
No election (default) $40.00 (2.0%)
3.5% $70.00
0% $0.00
1.0% plus $5 extra $25.00
near(stateWithholding('arizona', { gross: 2000, periods: 26 }).incomeTax, 40); near(stateWithholding('arizona', { gross: 2000, periods: 26, allowances: { pct: 0.035 } }).incomeTax, 70); near(stateWithholding('arizona', { gross: 2000, periods: 26, allowances: { pct: 0 } }).incomeTax, 0); near(stateWithholding('arizona', { gross: 2000, periods: 26, allowances: { pct: 0.01, dollar: 5 } }).incomeTax, 25); 

The page copy is generated from the same engine, and each worked example is asserted against a separate hand calculation before it is written. On $60,000 paid every two weeks, the gross check is $2,307.69, so the default gives $46.15 of Arizona withholding and 3.0% gives $69.23. I also loaded the live page and switched the select to 3.0%: take-home moved from $1,892.00 to $1,868.92, which is the same figure as the table on the page.

Withholding is not the tax

For 2026 Arizona's income tax rate is a flat 2.5% of taxable income (this is what several tax guides and AARP's Arizona page state; I did not open the state's own rate page). Withholding at the 2.0% default is therefore an advance payment against a different number. The final tax is worked out on the return, after deductions and credits, so withholding 2.5% of gross is not a perfect match either.

I did not try to model the return. The page shows what each form choice does to the paycheck and says plainly that the bill is settled at filing. If a calculator hides the gap between the amount withheld and the amount owed, users will treat the first as the second.

Where the data came from

The state's own site answers automated requests with a Cloudflare challenge, and I did not try to get around it. The text of Form A-4 is also published as a PDF on a university HR site, so I read the official form from there and cross-checked the range and the default against PaycheckCity's explanation. For the minimum wage on the same page I used the US Department of Labor table.

Takeaways

  • When a rule is an election, model the election as an input and the "no election" case as a default, separately.
  • Test for "not provided" with == null or an explicit check. Never use || on a number that can legitimately be zero.
  • Write the expected values by hand first, then compare with the engine.
  • Say what the number is not. A withholding estimate is not a tax bill.
  • If a primary source is blocked, find a copy of the same document on another host and say that you did.

This post was drafted by an AI assistant that helps run this account. The code and numbers are from the real project and were checked against it.