Open-source Canadian payroll deductions calculator. Implements the CRA's T4127 formulas for all provinces and territories. Validated against 92,000+ test cases and penny-for-penny against the CRA's own PDOC calculator.
$1,000/week in Ontario, claim code 1:
Federal tax: $81.61
Provincial tax: $45.80
CPP: $55.50
EI: $16.30
Total deductions: $199.21
from decimal import Decimal
from payroll_calc.calculator import calculate
from payroll_calc.data.loader import load_cra_data
from payroll_calc.config import Province, PayPeriod
from payroll_calc.models import DeductionRequest
cra = load_cra_data()
result = calculate(DeductionRequest(
province=Province.ON,
pay_period=PayPeriod.WEEKLY,
gross_pay=Decimal("1000.00"),
), cra)
print(result.federal_tax) # 81.61
print(result.provincial_tax) # 45.80
print(result.cpp_total) # 55.50
print(result.ei_premium) # 16.30
print(result.total_deductions) # 199.21Every Canadian employer is legally required to calculate payroll deductions using formulas published by the Canada Revenue Agency in a document called the T4127.
The formulas are public. The problem is how they're published.
The CRA distributes its payroll data as 21 CSV files buried across multiple web pages, with inconsistent encoding (UTF-8 BOM, Windows-1252), comma-formatted numbers inside quoted fields, special tokens that require domain knowledge to interpret, and multi-row records that span 3 lines per province. The tax tables are locked inside PDF files that require scraping to validate against. The only official "calculator" is PDOC — a closed-source web app with no API, no batch mode, and no way to integrate it into anything.
There is no reference implementation. No open data API. No sample code. No test vectors.
A country with 2 million employers and a $1.2 trillion annual payroll base, and the government's answer is: here are some PDFs and a web form, good luck.
You have to wonder who benefits from this. It's certainly not small businesses and entrepreneurs, who are left choosing between paying a CPA hundreds of dollars a month, subscribing to payroll SaaS that charges per employee per pay run, or spending weeks deciphering a 70-page formula guide just to issue a paycheque. The complexity isn't accidental — it's a moat. Every layer of obscurity, every undocumented edge case, every PDF that should have been a CSV is another reason a small business owner gives up and hands their payroll to a vendor. The big accounting firms and payroll platforms aren't hurt by this — they thrive on it. They have teams of tax specialists and proprietary implementations they've built over decades. The opacity of the system is their competitive advantage, and the CRA's refusal to publish usable, machine-readable tax logic with reference implementations keeps that advantage locked in.
T4127 Engine exists to change this. If the CRA won't publish a reference implementation, the community can.
Part of the Canada Pay Freedom project. Licensed under AGPL-3.0.
- Payroll software developers who need a correct, auditable calculation engine they can integrate into their product
- HRIS and HR tech builders who need a Canadian tax module without licensing a proprietary one
- Accountants and bookkeepers who want to verify their software's output against a transparent, testable implementation
- Small business owners who are tired of paying per-employee-per-month for what is fundamentally public arithmetic
- Open-source projects that need Canadian payroll support without reinventing the T4127 from scratch
- Anyone who believes tax calculation logic should be public infrastructure, not a proprietary black box
pip install t4127-engineThen download the CRA's published rate data (21 CSV files fetched directly from canada.ca — not bundled in the package due to Crown copyright):
python -m payroll_calc.download_cragit clone https://github.com/asterling/T4127-Engine.git
cd T4127-Engine
pip install -e ".[dev]"
python -m payroll_calc.download_crapython -m pytest payroll_calc/tests/test_regression.py -vThat's it. 43 tests, all passing in under a second.
# Basic calculation
t4127 calculate --gross 1000 --province ON --period 52
# Output:
# Gross pay: $ 1,000.00
# Province: ON (52pp, CC1/1)
#
# Federal tax: $ 81.61
# Provincial tax: $ 45.80
# CPP: $ 55.50
# EI: $ 16.30
# ─────────────
# Total deductions: $ 199.21
# Net pay: $ 800.79
# With deductions
t4127 calculate --gross 3200 --province MB --period 26 --rpp 80 --union-dues 25
# JSON output (for piping to other tools)
t4127 calculate --gross 1000 --province ON --period 52 --json
# Download CRA data
t4127 download
# Compare against CRA's PDOC (requires Selenium + Chrome)
t4127 compare 1000 ON 52Load the CRA data once, then calculate as many pay periods as you need:
from decimal import Decimal
from payroll_calc.calculator import calculate
from payroll_calc.data.loader import load_cra_data
from payroll_calc.config import Province, PayPeriod
from payroll_calc.models import DeductionRequest
cra = load_cra_data()
# Basic calculation — just province, pay period, and gross pay
result = calculate(DeductionRequest(
province=Province.AB,
pay_period=PayPeriod.BIWEEKLY,
gross_pay=Decimal("3200.00"),
), cra)
# With RPP contributions and union dues
result = calculate(DeductionRequest(
province=Province.MB,
pay_period=PayPeriod.BIWEEKLY,
gross_pay=Decimal("3200.00"),
rpp_contributions=Decimal("80.00"),
union_dues=Decimal("25.00"),
), cra)
# With a bonus
result = calculate(DeductionRequest(
province=Province.ON,
pay_period=PayPeriod.WEEKLY,
gross_pay=Decimal("1000.00"),
bonus=Decimal("5000.00"),
), cra)
print(result.bonus_tax) # tax on the $5,000 bonus
# Mid-year with YTD tracking (for accurate CPP/EI cap calculations)
result = calculate(DeductionRequest(
province=Province.ON,
pay_period=PayPeriod.WEEKLY,
gross_pay=Decimal("1000.00"),
ytd_cpp=Decimal("3800.00"),
ytd_ei=Decimal("1050.00"),
ytd_pensionable=Decimal("70000.00"),
), cra)
# CPP/EI will be reduced or zero if annual maximums are reacheduvicorn payroll_calc.main:app --port 8000Interactive docs at http://localhost:8000/docs.
curl -X POST http://localhost:8000/calculate \
-H "Content-Type: application/json" \
-d '{
"province": "ON",
"pay_period": 52,
"gross_pay": "1000.00",
"federal_claim_code": 1,
"provincial_claim_code": 1
}'{
"federal_tax": "81.61",
"provincial_tax": "45.80",
"total_tax": "127.41",
"cpp_total": "55.50",
"cpp_base_portion": "46.17",
"cpp2": "0.00",
"ei_premium": "16.30",
"total_deductions": "199.21",
"bonus_tax": null,
"annual_taxable_income": "51514.96",
"basic_federal_tax": "4243.88",
"annual_federal_tax": "4243.88",
"basic_provincial_tax": "1781.51",
"annual_provincial_tax": "2381.51"
}Compare our output penny-for-penny against the CRA's official Payroll Deductions Online Calculator (requires Selenium + Chrome):
pip install selenium
python -m payroll_calc.pdoc_query --compare 1000 ON 52Field PDOC Ours Diff
--------------------------------------------------------------
Federal tax $ 81.61 $ 81.61 +0.00
Provincial tax $ 45.80 $ 45.80 +0.00
CPP $ 55.50 $ 55.50 +0.00
EI $ 16.30 $ 16.30 +0.00
Total deductions $ 199.21 $ 199.21 +0.00
PERFECT MATCH
Download a T4032-style CSV lookup table (like the ones the CRA publishes as PDFs):
curl -o federal_weekly.csv http://localhost:8000/t4032/ON/52/federal.csv
curl -o provincial_biweekly.csv http://localhost:8000/t4032/ON/26/provincial.csvT4127 Engine can be used as a tool by AI agents — give any LLM the ability to calculate Canadian payroll deductions.
Install with MCP support:
pip install t4127-engine[mcp]
t4127 download # fetch CRA rate dataAdd to your Claude Desktop config (claude_desktop_config.json):
{
"mcpServers": {
"payroll-calculator": {
"command": "t4127-mcp"
}
}
}Or run directly:
python -m payroll_calc.mcp_serverThe server exposes two tools:
calculate_payroll_deductions— full T4127 calculation with all optional parameterslist_provinces— list supported provinces and territories
A ready-to-use tool schema is included at payroll_calc/schemas/openai_tool.json:
import json
from pathlib import Path
# Load the schema
schema_path = Path("payroll_calc/schemas/openai_tool.json")
tools = json.loads(schema_path.read_text())
# Use with OpenAI
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What are the deductions on $5,000 biweekly in Alberta?"}],
tools=tools,
)
# Use with Anthropic
response = client.messages.create(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "What are the deductions on $5,000 biweekly in Alberta?"}],
tools=[{"name": t["function"]["name"],
"description": t["function"]["description"],
"input_schema": t["function"]["parameters"]} for t in tools],
)The schema defines the same parameters as the MCP server and CLI — you handle the actual function execution and return the result to the LLM.
Provinces/territories: AB, BC, MB, NB, NL, NS, NT, NU, ON, PE, SK, YT
Pay periods: Weekly (52), Biweekly (26), Semi-monthly (24), Monthly (12)
Claim codes: 0-10 (federal and provincial)
Not yet supported: Quebec provincial tax. Quebec administers its own provincial income tax through Revenu Québec using separate formulas (TP-1015.3), a separate pension plan (QPP instead of CPP), and a separate parental insurance plan (QPIP). Federal tax for Quebec employees is calculated (with the 16.5% abatement), but provincial deductions require a dedicated Revenu Québec implementation — this is on the roadmap.
- Quebec provincial tax — Revenu Québec TP-1015.3 formulas, QPP, QPIP (the data is already downloaded, the formulas need implementing)
- PyPI package —
pip install t4127-enginefor easy integration - Annual rate updates — automated workflow to pull new T4127 data when the CRA publishes each November
- CPP/QPP edge cases — employees turning 18 or 70 mid-year, multi-jurisdiction workers
- GitHub Actions CI — automated test runs on every push
See CONTRIBUTING.md if you want to help with any of these.
Everything below is for people who want to understand how the engine works, what data it uses, and how it's tested.
The T4127 Engine is built entirely from official CRA data. Here is exactly what the government provides and where — so you can verify we haven't made anything up.
The CRA publishes 21 CSV files as part of the T4127 package. They are not linked from a single page or documented in a consistent format. The download_cra.py script fetches all of them from canada.ca:
One CSV per jurisdiction — maps claim codes 0-10 to total claim amounts (TC) and pre-computed tax credits (K1/K1P):
| File | Jurisdiction |
|---|---|
cc-fd-01-26e.csv |
Federal |
cc-ab-01-26e.csv |
Alberta |
cc-bc-01-26e.csv |
British Columbia |
cc-mb-01-26e.csv |
Manitoba |
cc-nb-01-26e.csv |
New Brunswick |
cc-nl-01-26e.csv |
Newfoundland & Labrador |
cc-ns-01-26e.csv |
Nova Scotia |
cc-nt-01-26e.csv |
Northwest Territories |
cc-nv-01-26e.csv |
Nunavut (CRA uses nv, not nu) |
cc-on-01-26e.csv |
Ontario |
cc-pei-01-26e.csv |
Prince Edward Island (CRA uses pei, not pe) |
cc-sk-01-26e.csv |
Saskatchewan |
cc-yt-01-26e.csv |
Yukon |
| File | T4127 Table | Contents |
|---|---|---|
rtsncmtrshldcnstnt-01-26e.csv |
Table 8.1 | Tax brackets for all jurisdictions — 3 rows per province (thresholds, rates, constants). This is the core of the income tax calculation. |
thrrtsmnts-01-26e.csv |
Table 8.2 | Basic personal amounts, index rates, LCP credits, CEA, Ontario surtax (V1) tiers, and other jurisdiction-specific amounts. |
cpp-qpp-br-01-26e.csv |
Table 8.4 | CPP/QPP base contribution rates and annual maximums |
cpp-qpp-ttl-01-26e.csv |
Table 8.3 | CPP/QPP total rates, YMPE ($74,600), basic exemption ($3,500), max contributions |
cpp-qpp-addntl-01-26e.csv |
Table 8.5 | CPP/QPP first additional contribution (1% rate, $711 max) |
cpp-qpp-scnd-addntl-01-26e.csv |
Table 8.6 | CPP2 second additional contribution — YAMPE ($85,000), 4% rate, $416 max |
ei-01-26e.csv |
Table 8.7 | Employment Insurance rates and maximums (separate rows for QC and non-QC) |
qpip-01-26e.csv |
Table 8.8 | Quebec Parental Insurance Plan rates (for future Quebec support) |
- No reference implementation in any programming language
- No machine-readable API for PDOC (the online calculator)
- No test vectors or expected output for given inputs
- No versioned data releases or changelogs for rate updates
- No standardized format — the 21 CSVs use inconsistent encodings, non-standard province codes (
nvfor Nunavut,peifor PEI), comma-formatted numbers inside quoted fields, special tokens (BPAF,BPAMB,No claim amount), and multi-row records with no delimiter
All of these are parsed by data/loader.py, which handles every encoding quirk and format inconsistency we've encountered.
The engine implements all 6 steps of the T4127 Option 1 formulas:
| Step | Module | What It Computes |
|---|---|---|
| Prereq | formulas/cpp.py |
CPP base + first additional + CPP2 contributions |
| Prereq | formulas/ei.py |
EI premiums |
| 1 | formulas/annual_income.py |
Annual taxable income: A = P x (I - F - F2 - F5A - U1) - HD - F1 |
| 2-3 | formulas/federal_tax.py |
Basic federal tax T3 and annual federal tax T1 |
| 4-5 | formulas/provincial_tax.py |
Basic provincial tax T4 and annual provincial tax T2 |
| 6 | formulas/per_period_tax.py |
Per-period deduction: T = (T1 + T2) / P + L |
Supporting modules:
| Module | Purpose |
|---|---|
formulas/credits.py |
Tax credits K1-K4 (federal) and K1P-K5P (provincial) |
formulas/bpaf.py |
Dynamic basic personal amounts — BPAF clawback ($181,440-$258,482), BPAMB ($200,000-$400,000), BPAYT |
formulas/province_specific.py |
Ontario surtax (V1), Ontario Health Premium (V2), Ontario/BC tax reductions (S), Alberta K5P |
formulas/bonus.py |
Bonus/retroactive pay: TB = (T1+T2 with bonus) - (T1+T2 without bonus) |
The main entry point is calculator.py, which orchestrates all steps in order and returns a DeductionResponse with every intermediate value.
- Full T4127 Option 1 — all 6 steps for salary, wages, and non-periodic payments
- CPP/CPP2/EI — base CPP, first additional CPP, second additional CPP (CPP2), and EI
- Dynamic BPAF/BPAMB/BPAYT — income-dependent basic personal amounts for federal, Manitoba, and Yukon
- Province-specific rules — Ontario surtax (V1), Ontario Health Premium (V2), Ontario/BC tax reductions (S), Alberta supplemental credit (K5P), labour-sponsored funds credits (LCP) for MB, NB, NS, SK
- Bonus/retroactive pay — differential tax calculation on non-periodic payments
- T4032 table generation — produces CSV files matching the CRA's published lookup tables
- Decimal precision — all arithmetic uses
decimal.Decimalwith CRA-specific rounding rules
| Test Suite | Count | What It Validates |
|---|---|---|
test_regression.py |
43 | CRA data parsing, CPP/EI calculations, all provinces, all pay periods, claim code ordering, BPAF formulas, bonus tax |
test_t4032_exhaustive.py |
91,992 | Every row in all 8 T4032ON PDF tables (4 pay periods x federal + provincial), tested at midpoint + 2 random points per range, across all claim codes |
| Gross Pay | Province | Period | Federal Tax | Provincial Tax | Total Deductions | PDOC Match |
|---|---|---|---|---|---|---|
| $1,000/wk | ON | 52pp | $81.61 | $45.80 | $199.21 | Exact |
| $3,000/wk | ON | 52pp | $514.60 | $301.03 | $1,039.03 | Exact |
| $5,000/wk | ON | 52pp | $1,073.23 | $690.74 | $2,138.97 | Exact |
| $20,000/mo | ON | 12pp | $4,172.15 | $2,654.48 | $8,325.28 | Exact |
Below YMPE (~$74,600/year annualized), our calculator and the T4032 PDF tables agree within $1.50/period. Above YMPE, differences grow to $5-$70 because the T4032 tables don't properly handle the CPP annual cap. The CRA acknowledges this:
"If at any point during the year, the employee reaches the YMPE of $74,600 [...] we recommend using the PDOC for more accurate calculations."
Our calculator matches PDOC exactly — the T4032 discrepancies are a limitation of the lookup tables, not our formulas.
All fields except province, pay_period, and gross_pay are optional and default to sensible values (claim code 1, zero deductions, zero YTD).
Request body:
| Field | Type | Default | Description |
|---|---|---|---|
province |
string | required | AB, BC, MB, NB, NL, NS, NT, NU, ON, PE, SK, YT |
pay_period |
int | required | 52, 26, 24, or 12 |
gross_pay |
decimal | required | Gross remuneration for the pay period |
federal_claim_code |
int | 1 |
Federal claim code (0-10) |
provincial_claim_code |
int | 1 |
Provincial claim code (0-10) |
pensionable_earnings |
decimal | gross_pay | If different from gross pay |
insurable_earnings |
decimal | gross_pay | If different from gross pay |
rpp_contributions |
decimal | 0 |
RPP/RRSP contributions per period |
union_dues |
decimal | 0 |
Union dues per period |
prescribed_zone |
decimal | 0 |
Annual prescribed zone deduction |
annual_deductions |
decimal | 0 |
Annual deductions (child care, support payments) |
additional_tax |
decimal | 0 |
Additional tax per period (TD1 request) |
bonus |
decimal | 0 |
Bonus or retroactive pay this period |
ytd_cpp |
decimal | 0 |
Year-to-date CPP contributions |
ytd_cpp2 |
decimal | 0 |
Year-to-date CPP2 contributions |
ytd_ei |
decimal | 0 |
Year-to-date EI premiums |
ytd_pensionable |
decimal | 0 |
Year-to-date pensionable earnings |
dependants_for_reduction |
decimal | 0 |
Ontario tax reduction dependant amount |
cpp_months |
int | 12 |
Months CPP contributions required |
Response body:
| Field | Description |
|---|---|
federal_tax |
Federal income tax for the period |
provincial_tax |
Provincial income tax for the period |
total_tax |
Combined federal + provincial |
cpp_total |
CPP contribution (base + first additional) |
cpp_base_portion |
Base CPP portion (used in K2 credit) |
cpp2 |
CPP2 second additional contribution |
ei_premium |
Employment insurance premium |
total_deductions |
Sum of all deductions |
bonus_tax |
Tax on the bonus (null if no bonus) |
annual_taxable_income |
Annualized taxable income (A) |
basic_federal_tax |
Basic federal tax (T3) |
annual_federal_tax |
Annual federal tax (T1) |
basic_provincial_tax |
Basic provincial tax (T4) |
annual_provincial_tax |
Annual provincial tax (T2) |
Generate a T4032-style CSV. table_type is federal or provincial. Returns CSV with columns: From, Less than, CC 0 through CC 10.
List all supported provinces.
Claim code table for federal or a province code (e.g., ON).
T4127-Engine/
├── README.md
├── LICENSE AGPL-3.0
├── CONTRIBUTING.md
├── pyproject.toml
│
└── payroll_calc/
├── __init__.py Package init, __version__
├── calculator.py Main engine — orchestrates all T4127 steps
├── config.py Province/PayPeriod enums, CRA code mappings
├── models.py Pydantic request/response models
├── rounding.py CRA-specific rounding (ROUND_HALF_UP, truncation)
├── main.py FastAPI app entry point
├── download_cra.py Downloads all CRA CSVs and PDFs from canada.ca
├── pdoc_query.py Selenium PDOC scraper for validation
│
├── data/
│ ├── schema.py Dataclasses: TaxBracket, ClaimCode, CppParams, etc.
│ └── loader.py CSV parser — handles all CRA encoding quirks
│
├── formulas/
│ ├── annual_income.py Step 1: annual taxable income (A)
│ ├── federal_tax.py Steps 2-3: T3, T1
│ ├── provincial_tax.py Steps 4-5: T4, T2
│ ├── per_period_tax.py Step 6: per-period tax (T)
│ ├── credits.py Tax credits K1-K4, K1P-K5P
│ ├── cpp.py CPP/CPP2 contributions
│ ├── ei.py EI premiums
│ ├── bpaf.py Dynamic BPAF/BPAMB/BPAYT
│ ├── province_specific.py ON surtax/OHP, BC reduction, AB K5P
│ └── bonus.py Bonus/retroactive pay tax
│
├── tables/
│ └── t4032_generator.py Generates T4032-style CSV lookup tables
│
├── api/
│ └── routes.py FastAPI route definitions
│
├── tests/
│ ├── test_regression.py 43 regression tests
│ ├── test_t4032_exhaustive.py 91,992 exhaustive PDF validation tests
│ ├── extract_pdf_reference.py PDF table extractor
│ └── reference_data/ Extracted T4032ON tables (8 JSON files)
│
└── cra_data/ Downloaded CRA data (gitignored)
└── 2026/
├── csvs/ 21 CSV files
└── pdfs/ 12 PDF files
| Source | Edition | URL |
|---|---|---|
| T4127 Payroll Deductions Formulas | 122nd, Jan 2026 | canada.ca |
| T4032ON Payroll Deductions Tables | Jan 2026 | canada.ca |
| PDOC Online Calculator | 2026 | canada.ca |
See CONTRIBUTING.md for development setup and PR guidelines.
AGPL-3.0 — free to use, modify, and deploy. If you modify and deploy it as a service, you must share your changes.