Functional Weave
Code in Python

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,951
  • income_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 bands
  • income_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_codestringas issued by HMRC, e.g. 1257L, S1257L, K475 M1, BR
frequencyPayFrequency
periodinttax week (1-56) or tax month (1-12) of the payment, from payroll.tax-period
payMoneytaxable pay for this period, after any net pay pension deduction
previous_pay_to_dateMoneytaxable pay already paid this tax year (including a P45's figure); ignored on a week 1 / month 1 basis
previous_tax_to_dateMoneytax already deducted this tax year (including a P45's figure); ignored on a week 1 / month 1 basis
pay_datedatedecides the tax year's bands
returnsIncomeTax

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
impl/python.py · 177 lines · open · raw

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
Download for Python payroll.income-tax-1.0.0-python.fune · 53,647 bytes sha256 8dc12c9618edbf93d1bbf1b1272418221df92785d8019f0732e3948b53974778

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md4,386
data/income-tax-bands.json12,157
data/overriding-limit.json208
impl/python.py8,636
impl/rust.rs10,693
impl/typescript.ts8,644
vectors.json18,091