payroll.income-tax
PAYE income tax for one pay period from a tax code, cumulative or week 1/month 1, by HMRC's tax table routines.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 28 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates payroll figures from published rules. It is a software component for developers, not tax or legal advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a payroll professional (CIPP) review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
Status: needs review by a qualified payroll professional before it is published.
PAYE income tax for one payment. Give it the employee's tax code, the pay frequency and tax period (from `payroll.tax-period`), this period's taxable pay, the taxable pay and tax so far this tax year, and the pay date; it returns the tax to deduct (or refund) and the running totals.
For example
income_tax(1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28)→ tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 1257L month 1 on 3,000 pounds: free pay 1,048.26, tax 20% of 1,951income_tax(S1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28)→ tax £392.27, tax to date £392.27, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 the same pay for a Scottish taxpayer runs through starter, basic and intermediate bandsincome_tax(C1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28)→ tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 a Welsh taxpayer pays the rest-of-UK rates
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def income_tax(tax_code: str, frequency: PayFrequency, period: int, pay: Money, previous_pay_to_date: Money, previous_tax_to_date: Money, pay_date: str) -> IncomeTax
| tax_code | string | as issued by HMRC, e.g. 1257L, S1257L, K475 M1, BR |
| frequency | PayFrequency | |
| period | int | tax week (1-56) or tax month (1-12) of the payment, from payroll.tax-period |
| pay | Money | taxable pay for this period, after any net pay pension deduction |
| previous_pay_to_date | Money | taxable pay already paid this tax year (including a P45's figure); ignored on a week 1 / month 1 basis |
| previous_tax_to_date | Money | tax already deducted this tax year (including a P45's figure); ignored on a week 1 / month 1 basis |
| pay_date | date | decides the tax year's bands |
| returns | IncomeTax |
The type it declares, generated into your project
@dataclass(frozen=True)
class IncomeTax:
"""The tax for this period and the running totals a payslip and FPS need."""
#: to deduct this period; negative is a refund
tax: Money
#: previousTaxToDate plus tax
tax_to_date: Money
#: previousPayToDate plus pay
pay_to_date: Money
#: free pay used in the calculation; negative is the additional pay of a K code
allowance_to_date: Money
#: taxable pay the tax was worked out on, rounded down to whole pounds: to date when cumulative, this period otherwise
taxable_pay: Money
#: false for a W1/M1/X code and for weeks 53, 54 and 56
cumulative: bool
#: the 50% overriding limit held the deduction down
limit_applied: bool
#: tax held back by the limit this period
tax_not_deducted: Money
Your code names it in one line, in the file that uses it
from fune.payroll.income_tax import income_tax # payroll.income-tax@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import re
from typing import List, NoReturn, Sequence
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
from .payroll_income_tax_data import INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY, OVERRIDING_LIMIT, OVERRIDING_LIMIT_HISTORY, IncomeTaxBand ← this capability’s own data, compiled from data/income-tax-bands.json into the same file by fune build
from .payroll_income_tax_types import IncomeTax
from .payroll_tax_code_parse import parse_tax_code ← from payroll.tax-code-parse ^1.0.0 · built alongside by fune
from .payroll_tax_period_types import PayFrequency
ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# HMRC's routines work "to 4 decimal places of a pound without correcting the
# final place". Holding amounts as integer ten-thousandths of a pound (1 = 0.0001
# pounds, 100 = 1p) makes every one of those truncations an exact integer division.
UNITS_PER_PENNY = 100
UNITS_PER_POUND = 10000
def _in_force(row, on_date: str) -> bool:
return row.valid_from <= on_date and (row.valid_to is None or on_date <= row.valid_to)
def _no_rule(what: str, on_date: str, history: str, froms: Sequence[str]) -> NoReturn:
# A build installed with history=current only carries the rules still in
# force; answering an older date with this year's bands would be a quiet,
# plausible wrong answer, so say why there is nothing instead.
if history != "full" and len(froms) > 0:
earliest = min(froms)
if on_date < earliest:
raise ValueError(
"no %s on %s: this build was installed with history=%s, so it only carries rules from %s. "
"Reinstall with history=full for earlier tax years." % (what, on_date, history, earliest)
)
raise ValueError("no %s on %s" % (what, on_date))
def _bands_for(region: str, on_date: str) -> List[IncomeTaxBand]:
rows = [b for b in INCOME_TAX_BANDS if b.region == region and _in_force(b, on_date)]
if len(rows) == 0:
_no_rule(
"income tax bands for %s" % (region,),
on_date,
INCOME_TAX_BANDS_HISTORY,
[b.valid_from for b in INCOME_TAX_BANDS if b.region == region],
)
return sorted(rows, key=lambda b: b.band)
def _overriding_limit(on_date: str) -> int:
for row in OVERRIDING_LIMIT:
if _in_force(row, on_date):
return row.basis_points
_no_rule("PAYE overriding limit", on_date, OVERRIDING_LIMIT_HISTORY, [r.valid_from for r in OVERRIDING_LIMIT])
def _period_allowance(code_number: int, periods_per_year: int) -> int:
"""Free pay (or K-code additional pay) for one week or month, in pence: HMRC paragraph 4.3.1."""
if code_number == 0:
return 0
# Codes above 500 are split into blocks of 500 and a remainder of 1-500, each
# rounded up to the penny separately; that is how the printed tables were
# built, and computing the whole code in one division is a penny out.
blocks = (code_number - 1) // 500
remainder = (code_number - 1) % 500 + 1
block_value = 9616 if periods_per_year == 52 else 41667
return round_div((remainder * 10 + 9) * 100, periods_per_year, "up") + blocks * block_value
def _banded_tax(taxable_pence: int, bands: Sequence[IncomeTaxBand], n: int, periods_per_year: int) -> int:
"""Tax due to date on positive taxable pay by the banded Tax Formulae of paragraph 4.4, in pence."""
taxable_pounds = round_div(taxable_pence, 100, "down")
previous_threshold = 0
previous_threshold_tax = 0
lower_pounds = 0
cumulative_annual_tax = 0
for band in bands:
if band.up_to is not None:
threshold = round_div(band.up_to * UNITS_PER_POUND * n, periods_per_year, "down")
# The income test compares the unrounded pay with the threshold
# rounded UP to a whole pound (the round-pound limits of Tables C),
# while the formula itself uses the exact threshold.
cvalue = round_div(threshold, UNITS_PER_POUND, "up")
if taxable_pence > cvalue * 100:
cumulative_annual_tax += (band.up_to - lower_pounds) * band.basis_points
lower_pounds = band.up_to
previous_threshold = threshold
previous_threshold_tax = round_div(cumulative_annual_tax * n, periods_per_year, "down")
continue
at_this_rate = round_div((taxable_pounds * UNITS_PER_POUND - previous_threshold) * band.basis_points, 10000, "down")
return round_div(previous_threshold_tax + at_this_rate, UNITS_PER_PENNY, "down")
raise ValueError("income tax bands have no top band")
def _check_gbp(name: str, amount: Money) -> None:
if amount.currency != "GBP":
raise ValueError("payroll amounts must be in GBP, received %s for %s" % (amount.currency, name))
def income_tax(
tax_code: str,
frequency: PayFrequency,
period: int,
pay: Money,
previous_pay_to_date: Money,
previous_tax_to_date: Money,
pay_date: str,
) -> IncomeTax:
"""PAYE income tax for one payment, following HMRC's "Specification for PAYE
tax table routines" (the computerised form of the tax tables, used by
payroll software): cumulative or week 1 / month 1, suffix, K, BR, D and NT
codes, Scottish and Welsh bands, and the 50% overriding limit.
"""
_check_gbp("pay", pay)
_check_gbp("previousPayToDate", previous_pay_to_date)
_check_gbp("previousTaxToDate", previous_tax_to_date)
if not isinstance(pay_date, str) or not ISO_DATE.match(pay_date):
raise ValueError('payDate must be an ISO date (YYYY-MM-DD), received "%s"' % (pay_date,))
if frequency == "monthly":
weeks_in_period, valid_period = 1, 1 <= period <= 12
elif frequency == "weekly":
weeks_in_period, valid_period = 1, 1 <= period <= 53
elif frequency == "fortnightly":
weeks_in_period, valid_period = 2, 1 <= period <= 52 or period == 54
elif frequency == "four-weekly":
weeks_in_period, valid_period = 4, 1 <= period <= 52 or period == 56
else:
raise ValueError('unknown pay frequency "%s"' % (frequency,))
if isinstance(period, bool) or not isinstance(period, int) or not valid_period:
raise ValueError("period %s is not a tax period for %s pay" % (period, frequency))
code = parse_tax_code(tax_code)
# Looked up even when no band is needed (NT, pay under the allowance), so a
# date outside the tax years on file is always refused.
bands = _bands_for(code.region, pay_date)
periods_per_year = 12 if frequency == "monthly" else 52
# Weeks 53, 54 and 56 are always taxed on a week 1 basis (paragraph 14),
# using the week 1, 2 or 4 figures.
cumulative = code.cumulative and period <= 52
n = period if cumulative else weeks_in_period
pay_to_date_for_tax = previous_pay_to_date.minor + pay.minor if cumulative else pay.minor
liability = 0
allowance_to_date = 0
taxable_pay = 0
if code.kind in ("allowance", "negative-allowance"):
per_period = _period_allowance(code.number, periods_per_year) * n
allowance_to_date = per_period if code.kind == "allowance" else -per_period
taxable_pence = pay_to_date_for_tax - allowance_to_date
if taxable_pence > 0:
taxable_pay = round_div(taxable_pence, 100, "down") * 100
liability = _banded_tax(taxable_pence, bands, n, periods_per_year)
elif code.kind in ("basic-rate", "d-rate"):
basic = next((i for i, b in enumerate(bands) if b.basic_rate), -1)
index = basic if code.kind == "basic-rate" else basic + 1 + code.number
if basic < 0 or index >= len(bands):
raise ValueError("no %s rate for %s on %s" % (code.code, code.region, pay_date))
pounds = round_div(pay_to_date_for_tax, 100, "down") if pay_to_date_for_tax > 0 else 0
taxable_pay = pounds * 100
liability = round_div(pounds * bands[index].basis_points, 100, "down")
# The overriding limit: no more than 50% of this payment may go in tax. It
# never restricts a refund, and on negative pay it is zero (paragraph 4.5.4).
limit = round_div(pay.minor * _overriding_limit(pay_date), 10000, "down") if pay.minor > 0 else 0
due = liability - previous_tax_to_date.minor if cumulative else liability
tax = limit if due > limit else due
return IncomeTax(
tax=money(tax, "GBP"),
tax_to_date=money(previous_tax_to_date.minor + tax, "GBP"),
pay_to_date=money(previous_pay_to_date.minor + pay.minor, "GBP"),
allowance_to_date=money(allowance_to_date, "GBP"),
taxable_pay=money(taxable_pay, "GBP"),
cumulative=cumulative,
limit_applied=due > limit,
tax_not_deducted=money(due - limit if due > limit else 0, "GBP"),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add payroll.income-tax
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./payroll.income-tax-1.0.0-python.fune, or fetch it from a terminal with fune pull payroll.income-tax@1.0.0:python.
The whole function, every language, is one file too: payroll.income-tax-1.0.0.fune, 73,674 bytes, sha256 32cd4cbaf68c99ed34b25bc60d367c45a37187f2d962f96ebd5d38ff8a5f9319. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before payroll.income-tax
after — your function gets the result and the arguments, and returns the final result.
# fune: after payroll.income-tax
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace math.round-div in payroll.income-tax
# fune: replace money.amount in payroll.income-tax
# fune: replace payroll.tax-code-parse in payroll.income-tax
# fune: replace payroll.tax-period in payroll.income-tax
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show payroll.income-tax --steps.
# fune: step payroll.income-tax after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 1257L month 1 on 3,000 pounds: free pay 1,048.26, tax 20% of 1,951 | 1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 | → | tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 |
| the same pay for a Scottish taxpayer runs through starter, basic and intermediate bands | S1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 | → | tax £392.27, tax to date £392.27, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 |
| a Welsh taxpayer pays the rest-of-UK rates | C1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 | → | tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 |
| higher rate in month 1 uses the exact threshold of 3,141.6666 | 1257L, monthly, 1, £6,000.00, £0.00, £0.00, 2025-04-28 | → | tax £1,352.06, tax to date £1,352.06, pay to date £6,000.00, allowance to date £1,048.26, taxable pay £4,951.00, cumulative true, limit applied false, tax not deducted £0.00 |
| additional rate on a weekly payroll | 1257L, weekly, 1, £3,000.00, £0.00, £0.00, 2024-04-12 | → | tax £975.77, tax to date £975.77, pay to date £3,000.00, allowance to date £241.92, taxable pay £2,758.00, cumulative true, limit applied false, tax not deducted £0.00 |
| month 12 of a 60,000 pound year settles to the annual tax | 1257L, monthly, 12, £5,000.00, £55,000.00, £10,470.00, 2027-03-31 | → | tax £958.00, tax to date £11,428.00, pay to date £60,000.00, allowance to date £12,579.12, taxable pay £47,420.00, cumulative true, limit applied false, tax not deducted £0.00 |
| a refund when pay stops: month 3 with no pay | 1257L, monthly, 3, £0.00, £6,000.00, £780.40, 2025-06-30 | → | tax -£209.40, tax to date £571.00, pay to date £6,000.00, allowance to date £3,144.78, taxable pay £2,855.00, cumulative true, limit applied false, tax not deducted £0.00 |
| K1000 month 1: the 50% overriding limit holds the deduction to 250 pounds | K1000, monthly, 1, £500.00, £0.00, £0.00, 2026-04-30 | → | tax £250.00, tax to date £250.00, pay to date £500.00, allowance to date -£834.09, taxable pay £1,334.00, cumulative true, limit applied true, tax not deducted £16.80 |
| K1000 month 2 recovers what the limit held back | K1000, monthly, 2, £3,000.00, £500.00, £250.00, 2026-05-29 | → | tax £783.60, tax to date £1,033.60, pay to date £3,500.00, allowance to date -£1,668.18, taxable pay £5,168.00, cumulative true, limit applied false, tax not deducted £0.00 |
| a K code with negative pay deducts nothing: the limit is zero | K100, monthly, 2, -£500.00, £1,000.00, £50.00, 2026-05-29 | → | tax £0.00, tax to date £50.00, pay to date £500.00, allowance to date -£168.18, taxable pay £668.00, cumulative true, limit applied true, tax not deducted £83.60 |
Show the other 18 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| BR taxes all pay, rounded down to the pound | BR, monthly, 1, £1,234.56, £0.00, £0.00, 2026-04-30 | → | tax £246.80, tax to date £246.80, pay to date £1,234.56, allowance to date £0.00, taxable pay £1,234.00, cumulative true, limit applied false, tax not deducted £0.00 |
| BR on a week 1 basis ignores the year to date | BR W1, weekly, 10, £300.99, £3,000.00, £500.00, 2026-06-12 | → | tax £60.00, tax to date £560.00, pay to date £3,300.99, allowance to date £0.00, taxable pay £300.00, cumulative false, limit applied false, tax not deducted £0.00 |
| D0 is 40% on everything in England | D0, monthly, 1, £2,000.00, £0.00, £0.00, 2026-04-30 | → | tax £800.00, tax to date £800.00, pay to date £2,000.00, allowance to date £0.00, taxable pay £2,000.00, cumulative true, limit applied false, tax not deducted £0.00 |
| SD0 is the 21% intermediate rate in Scotland | SD0, monthly, 1, £1,000.50, £0.00, £0.00, 2026-04-30 | → | tax £210.00, tax to date £210.00, pay to date £1,000.50, allowance to date £0.00, taxable pay £1,000.00, cumulative true, limit applied false, tax not deducted £0.00 |
| SD3 is the 48% top rate from 2024-25 | SD3, monthly, 1, £1,000.00, £0.00, £0.00, 2025-04-30 | → | tax £480.00, tax to date £480.00, pay to date £1,000.00, allowance to date £0.00, taxable pay £1,000.00, cumulative true, limit applied false, tax not deducted £0.00 |
| SD2 was the 47% top rate in 2023-24 | SD2, monthly, 1, £1,000.00, £0.00, £0.00, 2023-04-28 | → | tax £470.00, tax to date £470.00, pay to date £1,000.00, allowance to date £0.00, taxable pay £1,000.00, cumulative true, limit applied false, tax not deducted £0.00 |
| NT on a cumulative basis refunds everything deducted so far | NT, monthly, 4, £2,000.00, £6,000.00, £600.00, 2026-07-31 | → | tax -£600.00, tax to date £0.00, pay to date £8,000.00, allowance to date £0.00, taxable pay £0.00, cumulative true, limit applied false, tax not deducted £0.00 |
| 0T on a week 1 basis: no allowance at all | 0T W1, weekly, 5, £500.00, £2,000.00, £200.00, 2026-05-08 | → | tax £100.00, tax to date £300.00, pay to date £2,500.00, allowance to date £0.00, taxable pay £500.00, cumulative false, limit applied false, tax not deducted £0.00 |
| week 53 is taxed non-cumulatively on the week 1 figures | 1257L, weekly, 53, £500.00, £26,000.00, £3,000.00, 2027-04-05 | → | tax £51.60, tax to date £3,051.60, pay to date £26,500.00, allowance to date £241.92, taxable pay £258.00, cumulative false, limit applied false, tax not deducted £0.00 |
| fortnightly on a week 1 basis uses the week 2 figures | 1257L W1, fortnightly, 10, £1,000.00, £0.00, £0.00, 2026-08-07 | → | tax £103.20, tax to date £103.20, pay to date £1,000.00, allowance to date £483.84, taxable pay £516.00, cumulative false, limit applied false, tax not deducted £0.00 |
| four-weekly, second payment in week 8 | 1257L, four-weekly, 8, £2,000.00, £2,000.00, £206.40, 2026-05-29 | → | tax £206.40, tax to date £412.80, pay to date £4,000.00, allowance to date £1,935.36, taxable pay £2,064.00, cumulative true, limit applied false, tax not deducted £0.00 |
| SD3 did not exist in 2023-24 | SD3, monthly, 1, £1,000.00, £0.00, £0.00, 2023-04-28 | → | error: no SD3 rate for scotland |
| a date before the bands on file | 1257L, monthly, 1, £1,000.00, £0.00, £0.00, 2022-04-28 | → | error: no income tax bands for rest-of-uk |
| pay in euros is refused | 1257L, monthly, 1, €1,000.00, £0.00, £0.00, 2026-04-28 | → | error: payroll amounts must be in GBP |
| there is no month 13 | 1257L, monthly, 13, £1,000.00, £0.00, £0.00, 2026-04-28 | → | error: is not a tax period for monthly pay |
| fortnightly pay has week 54, not week 53 | 1257L, fortnightly, 53, £1,000.00, £0.00, £0.00, 2027-04-05 | → | error: is not a tax period for fortnightly pay |
| an unrecognised code is refused | 1257X9, monthly, 1, £1,000.00, £0.00, £0.00, 2026-04-28 | → | error: unrecognised tax code |
| a malformed date is refused | 1257L, monthly, 1, £1,000.00, £0.00, £0.00, 28/04/2026 | → | error: ISO date |
More from the author
## Which method
This follows HMRC's **"Specification for PAYE tax table routines"** (version 24.0, February 2026), the computerised form of the printed tax tables that payroll software is expected to implement. It is not a "divide the annual bands by 12" approximation, and the difference shows up in pence:
- **Free pay** for a code is worked out per week or month from `number x £10 + £9`, rounded *up* to the penny, with codes over 500 split into blocks of 500 (£96.16 a week, £416.67 a month each) plus a remainder, exactly as paragraph 4.3.1 says. 1257L is £241.92 a week and £1,048.26 a month. - **Taxable pay** is rounded *down* to the whole pound. - The **band thresholds to date** are `annual band x n / 52` (or `/ 12`) held to four decimal places without rounding; the test of which band applies uses that figure rounded *up* to the pound, the tax formula uses it exactly. - Every tax formula is worked to four decimal places and the result is rounded *down* to the penny.
All of that is done in integer ten-thousandths of a pound, so the three languages agree to the penny. HMRC notes (paragraph 16) that the printed tables can differ from this specification by 1p, exceptionally 2p; this capability matches the specification, not the printed tables.
## What it handles
- Cumulative codes: tax due to date on pay to date, less tax already deducted. A negative result is a refund. - Week 1 / month 1 codes (`W1`, `M1`, `X`): each payment on its own, with the week 1 figures (week 2 for fortnightly, week 4 for four-weekly pay). The year-to-date arguments are only added to for the running totals. - Weeks 53, 54 and 56 are always non-cumulative (paragraph 14). - Suffix codes, K codes, 0T, BR, D codes (`D0` 40%, `D1` 45%; Scotland `SD0` 21% to `SD3` 48%, `SD2` 47% in 2023-24) and NT. NT on a cumulative basis refunds all tax deducted so far this year. - Scottish (S), Welsh (C) and rest-of-UK bands for 2023-24 to 2026-27. Welsh rates are held as their own rows even though they currently equal the rest-of-UK rates, because the Senedd can set them separately. - **The overriding limit** (the "K-code 50% rule"): tax deducted from a payment may not exceed 50% of that payment. Under a cumulative code the tax held back is recovered in later periods automatically, because the next calculation compares tax due to date with tax actually deducted (`taxNotDeducted` reports what was held back). The limit never restricts a refund, and on negative pay it is zero (paragraph 4.5.4).
## What it does not do
- Payrolled benefits in kind: the limit is applied to the whole of `pay`; if you payroll benefits, the limit should be applied to cash pay only. - Mid-year changes of region: supply the code in force for this payment; the pay and tax to date carry over, as the specification says. - It trusts `previousTaxToDate` to be tax actually deducted, as HMRC's routine requires. - The newest band rows have no end date. They stay in force until a new version adds the next tax year, so a 2027-28 pay date is answered with 2026-27 bands until then.
## Sources
- HMRC, "Specification for PAYE tax table routines", version 24.0 (February 2026), paragraphs 3-16 and Appendices A-C (band widths, rates, Maxrate 50%): https://www.gov.uk/government/publications/payroll-technical-specifications-income-tax - HMRC, "Rates and thresholds for employers" for each year (rest-of-UK, Scottish and Welsh bands and the 1257L emergency code): https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2023-to-2024, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2024-to-2025, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2025-to-2026, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027 - The Income Tax (Pay As You Earn) Regulations 2003 (SI 2003/2682), regulation 23 (cumulative basis, overriding limit): https://www.legislation.gov.uk/uksi/2003/2682/regulation/23
Files
| Path | Bytes |
|---|---|
| README.md | 4,386 |
| data/income-tax-bands.json | 12,157 |
| data/overriding-limit.json | 208 |
| impl/python.py | 8,636 |
| impl/rust.rs | 10,693 |
| impl/typescript.ts | 8,644 |
| vectors.json | 18,091 |