Ordering Pre-Tax 401(k) and Section 125 Deductions Before the Tax Basis
When a payroll engine subtracts every “pre-tax” deduction from a single flat taxable-wage variable, it silently under-withholds Social Security and Medicare tax, because a Section 125 cafeteria-plan election and a 401(k) elective deferral do not reduce the same tax bases. This ordering problem is part of the Deduction Mapping Rules topic within the Payroll Calculation Engines & Validation Rules framework, and it isolates the two deduction types into their own deterministic stage so each one only ever touches the tax bases the Internal Revenue Code actually assigns to it.
Problem Framing
Most payroll engines model “pre-tax deductions” as one bucket subtracted once from gross pay to produce one taxable-wage figure. That model is wrong for two specific deduction types that are frequently mapped side by side in the same deduction table:
- Section 125 cafeteria-plan elections — health, dental, vision, FSA, and (in a cafeteria-plan-eligible arrangement) HSA contributions under IRC § 125 — are excluded from “wages” for federal income tax (FIT) withholding and for FICA under IRC § 3121(a)(5). One election reduces three tax bases at once: FIT, Social Security, and Medicare.
- 401(k) elective deferrals under IRC § 401(k) reduce taxable income for FIT purposes, but IRC § 3121(v)(1) explicitly treats deferred amounts as FICA wages at the time of deferral. A 401(k) deferral reduces exactly one tax base — FIT — and leaves Social Security and Medicare wages untouched.
An engine that applies both deductions to a single combined taxable-wage variable either over-reduces FICA wages (understating Social Security and Medicare tax, which surfaces as a discrepancy against Enforcing the FICA Social Security Wage-Base Cap and requires a Form 941-X correction) or under-reduces FIT wages (over-withholding income tax and misstating W-2 Box 1). The crux this page isolates: Section 125 reduces all three bases; 401(k) reduces only one. Getting the order and the base assignment right is not a stylistic choice — it is the difference between a correct paycheck and a quarter-end reconciliation failure.
Ordering also matters for a second, less obvious reason. Most 401(k) plan documents define “plan compensation” — the base against which a percentage-of-pay election is calculated — as compensation net of cafeteria-plan reductions, consistent with how IRC § 414(s) permits compensation definitions to exclude § 125 amounts. If the engine computes the 401(k) election as a percentage of gross wages instead of gross wages after the Section 125 reduction, the resulting dollar deferral no longer matches the plan’s compensation definition, which distorts ADP/ACP nondiscrimination testing even when no statutory dollar limit is exceeded. Section 125 must therefore be applied first, both to correctly seed the FICA bases and to correctly seed the base the 401(k) percentage is computed against.
Prerequisites & Data Requirements
The resolver that orders these two deduction types needs the following fields, already routed and classified by the Deduction Mapping Rules resolver. Every monetary field is Decimal; the engine must reject a float at the boundary rather than coerce it.
| Field | Type | Precondition |
|---|---|---|
gross_wages |
Decimal |
Total cash wages for the pay period, already net of any non-taxable reimbursements. |
section125_reduction |
Decimal |
Flat per-period cafeteria-plan election; 0 if the employee has no active election. |
k401_election_rate |
Decimal |
Percentage of post-125 compensation, expressed as a Decimal fraction (e.g. Decimal("0.06")). |
employee_age |
int |
As of December 31 of the plan year, used to resolve the applicable catch-up tier. |
ytd_k401_contributions |
Decimal |
Elective deferrals already contributed this plan year, across all periods with this employer. |
plan_year_limits |
lookup | The § 402(g) base limit and catch-up amounts for the plan year, loaded from a versioned table — never hardcoded, since the IRS republishes them annually. |
The plan-year limits table used in the examples below (illustrative figures for the 2026 plan year, structured exactly as the engine should load them):
| Limit | Amount |
|---|---|
| § 402(g) elective-deferral base limit | Decimal("24500.00") |
| Catch-up, age 50–59 or 64+ | Decimal("8000.00") |
| Enhanced catch-up, age 60–63 (SECURE 2.0) | Decimal("11250.00") |
Two preconditions sit outside the function signature:
- Section 125 must already be resolved and active for the period. This routine does not decide whether a cafeteria-plan election applies — that classification comes from the upstream Deduction Mapping Rules resolver. This routine only orders the application of an already-classified
pre_taxelection against the correct bases. ytd_k401_contributionsis scoped to the current employer. If the employee changed jobs mid-year, the prior employer’s contributions do not appear here; cross-employer aggregation for the § 402(g) limit is the employee’s responsibility at filing, not this engine’s.
Step-by-Step Implementation
Step 1 — Pin the plan-year limits and model the inputs. Load limits from a versioned table rather than hardcoding them, and model records as frozen dataclasses so an activated period’s inputs cannot mutate mid-calculation.
from dataclasses import dataclass
from decimal import Decimal
import logging
logger = logging.getLogger("payroll.deductions.pretax_order")
ELECTIVE_DEFERRAL_LIMIT_2026 = Decimal("24500.00")
CATCHUP_STANDARD_2026 = Decimal("8000.00") # ages 50-59 and 64+
CATCHUP_ENHANCED_2026 = Decimal("11250.00") # ages 60-63, SECURE 2.0
@dataclass(frozen=True)
class PayPeriodInputs:
employee_id: str
gross_wages: Decimal
section125_reduction: Decimal
k401_election_rate: Decimal
employee_age: int
ytd_k401_contributions: Decimal
@dataclass(frozen=True)
class TaxBases:
fit_taxable_wages: Decimal
ss_taxable_wages: Decimal
medicare_taxable_wages: Decimal
k401_contribution: Decimal
k401_capped: bool
Step 2 — Apply Section 125 first, against all three bases at once. The cafeteria-plan reduction is the only deduction in this pipeline that touches Social Security and Medicare wages, so it must resolve before anything else runs. Expected output for gross_wages=5000.00, section125_reduction=350.00: wages_after_125 = Decimal("4650.00").
def apply_section125(inputs: PayPeriodInputs) -> Decimal:
"""Reduce gross wages by the Section 125 election once, for all three bases."""
if inputs.section125_reduction < Decimal("0"):
raise ValueError(
f"emp={inputs.employee_id} section125_reduction cannot be negative"
)
wages_after_125 = inputs.gross_wages - inputs.section125_reduction
logger.info(
"section125_applied emp=%s gross=%s reduction=%s wages_after_125=%s",
inputs.employee_id, inputs.gross_wages,
inputs.section125_reduction, wages_after_125,
)
return wages_after_125
Step 3 — Resolve the applicable § 402(g) ceiling for the employee’s age. SECURE 2.0’s enhanced catch-up applies only to ages 60 through 63; age 64 and older revert to the standard catch-up. Expected output: age 55 → Decimal("32500.00"); age 61 → Decimal("35750.00"); age 64 → Decimal("32500.00").
def resolve_402g_limit(employee_age: int) -> Decimal:
"""Return the total elective-deferral ceiling for the employee's age tier."""
if employee_age >= 60 and employee_age <= 63:
return ELECTIVE_DEFERRAL_LIMIT_2026 + CATCHUP_ENHANCED_2026
if employee_age >= 50:
return ELECTIVE_DEFERRAL_LIMIT_2026 + CATCHUP_STANDARD_2026
return ELECTIVE_DEFERRAL_LIMIT_2026
Step 4 — Compute the 401(k) deferral against post-125 compensation and cap it at the remaining § 402(g) room. The election rate applies to wages_after_125, not gross, and the dollar result is capped by whatever YTD room remains — never by the plan compensation alone. Expected output for wages_after_125=4650.00, k401_election_rate=0.08, ytd_k401_contributions=32450.00, employee_age=55 (limit 32500.00): raw election 372.00, remaining room 50.00, so k401_contribution = Decimal("50.00") and k401_capped = True.
def compute_k401_contribution(
wages_after_125: Decimal, election_rate: Decimal,
employee_age: int, ytd_k401_contributions: Decimal, employee_id: str,
) -> tuple[Decimal, bool]:
"""Compute the 401(k) deferral against post-125 wages, capped by § 402(g)."""
limit = resolve_402g_limit(employee_age)
remaining_room = limit - ytd_k401_contributions
if remaining_room < Decimal("0"):
remaining_room = Decimal("0")
raw_election = (wages_after_125 * election_rate).quantize(Decimal("0.01"))
capped = raw_election > remaining_room
contribution = remaining_room if capped else raw_election
logger.info(
"k401_computed emp=%s wages_after_125=%s rate=%s raw=%s "
"limit=%s ytd=%s contribution=%s capped=%s",
employee_id, wages_after_125, election_rate, raw_election,
limit, ytd_k401_contributions, contribution, capped,
)
return contribution, capped
Step 5 — Assemble the three distinct tax bases in one call. Section 125 reduces fit, ss, and medicare together; the 401(k) contribution reduces fit only. Expected output for the running example: fit_taxable_wages = Decimal("4278.00"), ss_taxable_wages = medicare_taxable_wages = Decimal("4650.00").
def compute_pretax_bases(inputs: PayPeriodInputs) -> TaxBases:
"""Order Section 125 before 401(k) and assign each to its correct tax bases."""
wages_after_125 = apply_section125(inputs)
k401_contribution, capped = compute_k401_contribution(
wages_after_125, inputs.k401_election_rate,
inputs.employee_age, inputs.ytd_k401_contributions, inputs.employee_id,
)
fit_taxable_wages = wages_after_125 - k401_contribution
ss_taxable_wages = wages_after_125
medicare_taxable_wages = wages_after_125
logger.info(
"pretax_order_final emp=%s fit=%s ss=%s medicare=%s k401=%s capped=%s",
inputs.employee_id, fit_taxable_wages, ss_taxable_wages,
medicare_taxable_wages, k401_contribution, capped,
)
return TaxBases(
fit_taxable_wages, ss_taxable_wages, medicare_taxable_wages,
k401_contribution, capped,
)
inputs = PayPeriodInputs(
employee_id="E-70213",
gross_wages=Decimal("5000.00"),
section125_reduction=Decimal("350.00"),
k401_election_rate=Decimal("0.08"),
employee_age=55,
ytd_k401_contributions=Decimal("32450.00"),
)
result = compute_pretax_bases(inputs)
# result.fit_taxable_wages == Decimal("4600.00") (4650.00 - 50.00 capped contribution)
# result.ss_taxable_wages == result.medicare_taxable_wages == Decimal("4650.00")
# result.k401_contribution == Decimal("50.00"); result.k401_capped is True
Verification
Confirm correctness against the specific divergence this page isolates, not just aggregate withholding totals:
- Section 125 reduces all three bases equally. With
section125_reduction=350.00andk401_election_rate=0, assertss_taxable_wages == medicare_taxable_wages == gross_wages - 350.00and thatfit_taxable_wagesequals the same value, since no 401(k) reduction applies yet. - 401(k) reduces FIT only. Holding
section125_reductionfixed, varyk401_election_rateand assertss_taxable_wagesandmedicare_taxable_wagesnever change whilefit_taxable_wagesdecreases by exactly the computedk401_contribution. - § 402(g) cap at the exact boundary. Set
ytd_k401_contributionsequal to the resolved limit and assertk401_contribution == Decimal("0.00")andk401_capped is True; set it one cent below the limit and assert the contribution equals exactly that one cent of remaining room. - Catch-up tier boundaries. Assert age
59resolves to the standard8000.00catch-up, age60resolves to the enhanced11250.00catch-up, age63still resolves to11250.00, and age64reverts to8000.00— the enhanced tier is a closed four-year window, not “50 and up.” - Percentage computed against post-125 wages, not gross. With
gross_wages=5000.00,section125_reduction=350.00, andk401_election_rate=0.08, assert the raw election isDecimal("372.00")(4650.00 * 0.08), neverDecimal("400.00")(5000.00 * 0.08). - Decimal determinism. Run the same
PayPeriodInputsthree times and assert byte-identicalTaxBasesoutput; any drift indicates afloatleaked intok401_election_rateor a limits lookup.
Failure Modes
- 401(k) percentage computed against gross wages instead of post-125 compensation. Root cause: the engine reads
gross_wagesdirectly for the percentage election instead of the Section 125 output, so the deferral no longer matches the plan document’s compensation definition under IRC § 414(s). Remediation: always computek401_election_rate * wages_after_125, neverk401_election_rate * gross_wages; add a boundary test asserting the two differ wheneversection125_reduction > 0. - 401(k) contribution subtracted from the Social Security or Medicare base. Root cause: a single combined “taxable wages” variable absorbs both deductions instead of three separately tracked bases, silently understating FICA wages. Remediation: track
fit_taxable_wages,ss_taxable_wages, andmedicare_taxable_wagesas three distinct fields from the start; a discrepancy here requires a Form 941-X and a W-2c to correct understated Social Security and Medicare wages in Boxes 3 and 5, cross-checked against Enforcing the FICA Social Security Wage-Base Cap. - Missing cross-employer § 402(g) aggregation after a mid-year job change. Root cause:
ytd_k401_contributionsresets to zero for the new employer and the engine has no visibility into deferrals already made at the prior employer, so an excess deferral goes undetected until the employee’s personal tax filing. Remediation: flag employees who report prior-employer 401(k) contributions during onboarding for manual excess-deferral tracking, and route the case for a corrective distribution before the April 15 deadline that follows the deferral year under IRC § 402(g)(2), since an uncorrected excess deferral is taxed twice — once when deferred and again when distributed.
Frequently Asked Questions
Why does a Section 125 election reduce FICA wages but a 401(k) deferral does not?
The Internal Revenue Code treats the two differently by design. IRC § 3121(a)(5) excludes qualifying cafeteria-plan amounts from the statutory definition of FICA wages entirely, so a Section 125 election never enters the Social Security or Medicare base. IRC § 3121(v)(1) does the opposite for elective deferrals: it explicitly treats amounts deferred under a 401(k) arrangement as FICA wages at the moment of deferral, even though those same amounts are excluded from income for FIT purposes. One statute excludes a base; the other explicitly includes it.
How does the SECURE 2.0 enhanced catch-up for ages 60–63 change the § 402(g) ceiling?
For employees who are age 60, 61, 62, or 63 as of December 31 of the plan year, the enhanced catch-up amount replaces the standard catch-up rather than stacking on top of it. In the illustrative 2026 figures used above, that means a ceiling of 24500.00 + 11250.00 for that four-year window, while ages 50 through 59 and ages 64 and older use the standard 24500.00 + 8000.00 ceiling. An engine that applies the enhanced amount to every employee 50 and older will overstate the § 402(g) room for anyone 64 or above.
Does the order of applying Section 125 before 401(k) change net pay, or only the intermediate tax bases?
It changes both, but for different reasons. Net pay changes because a percentage-based 401(k) election computed against post-125 compensation produces a smaller dollar deferral than the same rate applied to gross wages, which is the deferral amount most plan documents actually intend. The tax bases change independently of that: regardless of the dollar amount deferred, Social Security and Medicare wages are unaffected by the 401(k) step in either ordering, while FIT wages are only correct when Section 125 has already been subtracted before the 401(k) reduction is applied.
What happens if 401(k) is calculated as a percentage of gross instead of post-125 compensation?
The resulting deferral no longer matches the compensation definition most plan documents use, which is typically wages net of cafeteria-plan reductions under the flexibility IRC § 414(s) gives plan sponsors. This does not necessarily breach the statutory § 402(g) dollar ceiling, but it does distort the deferral relative to the plan’s own formula, which can throw off ADP/ACP nondiscrimination testing and produce a contribution amount that reconciles incorrectly against the benefits carrier’s own election records.
Related
- Deduction Mapping Rules — the parent topic that classifies each deduction as pre-tax, post-tax, or statutory before this ordering stage runs.
- Mapping Garnishments to General-Ledger Accounts — the statutory-priority sibling pattern for deductions that must never be reordered by a resolver.
- Federal Withholding Percentage-Method Lookups — the withholding stage that consumes the
fit_taxable_wagesoutput this page produces. - Enforcing the FICA Social Security Wage-Base Cap — the annual cap applied against the
ss_taxable_wagesbase this page produces.