High-performance UK tax-benefit microsimulation engine. ~0.1 ms per household, with a Python wrapper for policy analysis.
A Rust microsimulation engine for the UK tax-benefit system, wrapped in a Python package (policyengine-uk-compiled). It ingests survey microdata (FRS, SPI, LCFS, WAS, EFRS), applies UK and Scottish tax-benefit rules, and returns aggregate or household-level results. Reforms are expressed as a JSON overlay on the baseline parameter set.
The engine is designed for rapid iteration: parameter changes take effect immediately without recompiling, and a full FRS simulation runs in seconds.
Install the Python package and run your first simulation in five minutes.
Household constructors, reform patterns, marginal tax rates, and dataset downloads.
All flags for the compiled binary — data extraction, simulation, and output formats.
FRS, SPI, LCFS, WAS, and EFRS — sources, download instructions, and when to use each.
Statutory citations and year-by-year parameter values for every modelled provision.
UC caseload undercount and other model caveats you need to know before citing results.
| Programme | Notes |
|---|---|
| Income tax (UK & Scottish rates) | Personal allowance, basic/higher/additional bands, dividend allowance |
| National Insurance (Class 1, 2, 4) | Employee, employer, self-employed |
| Universal Credit | Standard allowance, work allowance, taper, housing element, childcare |
| Child Benefit | High-income charge included |
| Working Tax Credit / Child Tax Credit | Legacy system for non-migrated claimants |
| Housing Benefit | Legacy claimants only |
| Pension Credit | Guarantee and savings credit |
| Benefit cap | Household-level application |
| Council Tax Benefit / Reduction | |
| Stamp Duty Land Tax | EFRS dataset required |
| Capital Gains Tax | Requires manually populated capital_gains field |
| Wealth tax (modelled provision) | EFRS dataset required; not a current UK tax |
Install the Python package (Python 3.10+, pip or uv):
pip install policyengine-uk-compiled
# or
uv add policyengine-uk-compiled
The quickest way to explore the model is with a single constructed household:
from policyengine_uk_compiled import PolicyEngineUK
engine = PolicyEngineUK()
# A single person earning £40,000
household = engine.single_person(employment_income=40_000)
result = engine.calculate(household)
print(result["income_tax"]) # → 5486.0
print(result["national_insurance"]) # → 2692.0
print(result["universal_credit"]) # → 0.0
For aggregate costings you need microdata. The package downloads pre-cleaned FRS CSVs automatically on first use:
from policyengine_uk_compiled import PolicyEngineUK
engine = PolicyEngineUK(dataset="frs", year=2023)
baseline = engine.run()
print(f"Income tax revenue: £{baseline['program_breakdown']['income_tax'] / 1e9:.1f}bn")
print(f"UC spending: £{baseline['program_breakdown']['universal_credit'] / 1e9:.1f}bn")
Reforms are a dict that overrides specific parameters. Unchanged parameters keep their baseline values.
reform = {
"universal_credit": {
"taper_rate": 0.50 # baseline is 0.55
}
}
result = engine.run(reform=reform)
baseline = engine.run()
net_cost = (result["budgetary_impact"]["net_cost"]
- baseline["budgetary_impact"]["net_cost"])
print(f"UC taper 55→50%: £{net_cost / 1e9:.2f}bn/yr")
The wrapper provides convenience constructors for common household types:
# Single person
household = engine.single_person(
employment_income=35_000,
age=35,
)
# Couple (joint assessment for UC purposes)
household = engine.couple(
employment_income_1=40_000,
employment_income_2=20_000,
num_children=2,
)
# Single parent
household = engine.single_parent(
employment_income=25_000,
num_children=1,
rent=900, # monthly, affects UC housing element
)
For non-standard cases or batch processing, construct the payload directly:
import json
from policyengine_uk_compiled import PolicyEngineUK
engine = PolicyEngineUK()
payload = {
"persons": [
{"age": 40, "employment_income": 60_000, "is_female": False},
{"age": 38, "employment_income": 0, "is_female": True},
],
"benunits": [{"person_ids": [0, 1], "is_couple": True}],
"households": [{"benunit_ids": [0], "rent": 1200}],
}
result = engine.calculate_custom(payload)
print(result)
# MTR at a given income point
mtr = engine.marginal_tax_rate(
household=engine.single_person(employment_income=50_000),
income_type="employment_income",
delta=100, # £100 increment used for numerical derivative
)
print(f"MTR at £50k: {mtr:.1%}") # → ~42.0%
All reform keys are optional — only the parameters you specify are changed. The full parameter tree is available via engine.baseline_params(year).
# Income tax: raise higher rate threshold
reform = {"income_tax": {"higher_rate_threshold": 55_000}}
# NI: change primary threshold
reform = {"national_insurance": {"primary_threshold": 10_000}}
# UC: multiple changes
reform = {
"universal_credit": {
"taper_rate": 0.50,
"work_allowance_with_housing": 500,
"work_allowance_without_housing": 750,
}
}
# Scottish income tax: modify a rate band
reform = {
"income_tax": {
"scottish_brackets": [
{"rate": 0.19, "threshold": 0},
{"rate": 0.20, "threshold": 14_876},
{"rate": 0.21, "threshold": 26_561},
{"rate": 0.42, "threshold": 43_662},
{"rate": 0.45, "threshold": 75_000},
]
}
}
from policyengine_uk_compiled import ensure_dataset
# Download FRS clean CSVs if not already present
ensure_dataset("frs", year=2023)
# Download EFRS (Enhanced FRS — needed for wealth/stamp duty/CGT)
ensure_dataset("efrs", year=2023)
# Available datasets: "frs", "spi", "lcfs", "was", "efrs"
| Name | Description |
|---|---|
PolicyEngineUK(dataset, year) | Main simulation class. dataset defaults to "frs"; year defaults to 2025. |
.run(reform=None) | Run a population simulation. Returns aggregate result dict. |
.calculate(household) | Run a single constructed household. Returns per-programme dict. |
.baseline_params(year) | Return the full parameter tree for a given fiscal year. |
.marginal_tax_rate(...) | Numerical MTR at a given income point. |
ensure_dataset(name, year) | Download pre-cleaned microdata from GCS if not cached locally. |
After cargo build --release, the binary is at ./target/release/policyengine-uk-rust.
| Flag | Description |
|---|---|
--data DIR | Path to clean CSV base directory (YYYY/persons.csv etc.) |
--year YYYY | Fiscal year — determines which parameter file is loaded |
--policy-json '{...}' | Inline JSON reform overlay. Merged on top of baseline parameters. |
--output json | Machine-readable aggregate output to stdout |
--output-microdata-stdout | Per-entity CSVs separated by ===PERSONS=== / ===BENUNITS=== / ===HOUSEHOLDS=== |
--export-params-json | Dump the baseline parameter tree and exit |
--stdin-data | Accept a single constructed household payload from stdin |
--persons-only | Suppress benefit/decile outputs — use with SPI which has no household structure |
| Flag | Use |
|---|---|
--frs TAB_DIR --extract OUT_DIR | Extract FRS raw tabs → clean CSVs |
--lcfs TAB_DIR --extract OUT_DIR | Extract LCFS raw tabs → clean CSVs |
--spi TAB_DIR --extract OUT_DIR | Extract SPI raw tabs → clean CSVs |
--was TAB_DIR --extract OUT_DIR | Extract WAS raw tabs → clean CSVs |
--extract-efrs OUT_DIR | Build Enhanced FRS (requires --data, --was-dir, --lcfs-dir) |
--uprate-to YYYY | Uprate dataset to target year when used with --extract |
# Baseline aggregate simulation
./policyengine-uk-rust --data ~/.policyengine-uk-data/frs \
--year 2025 --output json
# Reform: UC taper 55% → 50%
./policyengine-uk-rust --data ~/.policyengine-uk-data/frs \
--year 2025 --output json \
--policy-json '{"universal_credit": {"taper_rate": 0.50}}'
# Per-household microdata
./policyengine-uk-rust --data ~/.policyengine-uk-data/frs \
--year 2025 --output-microdata-stdout
# Extract FRS 2023 from raw tabs
./policyengine-uk-rust --frs raw/frs_2023/tab/ \
--year 2023 --extract data/frs/2023/
# Build EFRS
./policyengine-uk-rust --extract-efrs data/efrs/2023 \
--data data/frs --year 2023 \
--was-dir raw/was/round_7/ \
--lcfs-dir raw/lcfs/2021/
# Inspect baseline parameters
./policyengine-uk-rust --year 2025 --export-params-json | python -m json.tool | less
Five microdata sources are supported. The two-step flow for FRS/LCFS/WAS/SPI is: (1) extract raw survey tabs to clean CSVs, (2) simulate with --data. EFRS is a composite built from FRS + WAS + LCFS.
| Dataset | Source | UKDS SN | Best for |
|---|---|---|---|
| FRS | Family Resources Survey | — | Main analysis dataset. Income tax, NI, UC, all benefits. ~20,000 households/yr. |
| SPI | Survey of Personal Incomes | SN 9422 | Top-of-income-distribution analysis. Use --persons-only; no household structure. |
| LCFS | Living Costs & Food Survey | SN 9468 | Consumption analysis. 12 COICOP categories + petrol/diesel. ~4,200 households/yr. |
| WAS | Wealth & Assets Survey | SN 7215 | Wealth distribution. Used as input to EFRS imputation. |
| EFRS | Enhanced FRS (composite) | — | Wealth taxes, stamp duty, CGT. Wealth imputed from WAS; consumption from LCFS via random forest. |
FRS and EFRS pre-cleaned CSVs are downloaded automatically when you call ensure_dataset() in Python or on first use of the engine. They come from gs://policyengine-uk-microdata/.
SPI, LCFS, and WAS must be obtained from the UK Data Service under project ecf0b3c4-29d2-4d8a-931d-0e3773a4ac0b. Download the tab zips and unzip before extracting.
put{yy}{yy+1}uk.tab (e.g. put2223uk.tab for 2022/23); older files use put{YYYY}uk.tab. The loader detects both automatically.
EFRS imputes wealth from WAS and consumption from LCFS onto FRS microdata using random forest models. It is needed to model wealth taxes, stamp duty, and CGT. Pre-built CSVs are on GCS; rebuild with:
python scripts/rebuild_all.py --only efrs
Or build manually:
./policyengine-uk-rust --extract-efrs data/efrs/2023 \
--data data/frs --year 2023 \
--was-dir raw/was/round_7/ \
--lcfs-dir raw/lcfs/2021/
capital_gains defaults to zero on all datasets — no UK survey records realised gains. CGT reform modelling requires manually setting capital_gains per person via --stdin-data or a custom dataset.
FRS 2023 is the primary microdata year. The engine accepts the fiscal year as the --year argument, which also determines which parameters/YYYY_YY.yaml file is loaded. You can uprate an older dataset to a later year's prices using --uprate-to.
All tax and benefit parameters live in parameters/YYYY_YY.yaml — one file per fiscal year. They are loaded at runtime so reforms take effect without recompiling.
# From the CLI
./policyengine-uk-rust --year 2025 --export-params-json | python -m json.tool
# From Python
engine = PolicyEngineUK(year=2025)
params = engine.baseline_params(2025)
print(params["universal_credit"]["taper_rate"]) # → 0.55
Top-level keys in the parameter tree:
| Key | Contents |
|---|---|
income_tax | Personal allowance, UK and Scottish rate bands, dividend allowance, savings rate |
national_insurance | Primary/secondary thresholds and rates, Class 2 and 4 self-employed rates |
universal_credit | Standard allowances by household type, taper rate, work allowances, childcare element, migration rates |
child_benefit | Rates and high-income charge threshold/taper |
working_tax_credit | Legacy WTC parameters (kept for non-migrated claimants) |
child_tax_credit | Legacy CTC parameters |
benefit_cap | Cap amounts by household type and London/outside London |
stamp_duty | SDLT thresholds and rates (EFRS only) |
capital_gains_tax | Annual exempt amount, rates |
wealth_tax | Modelled provisions — not a current UK tax |
Every parameter value is cross-referenced to its enabling legislation in LEGISLATIVE_REFERENCE.md. This includes the specific Act, statutory instrument, and regulation for each figure, making it straightforward to verify correctness and trace any change.
Copy the most recent parameters/YYYY_YY.yaml, update the values to reflect the new fiscal year's enacted rates, and add citations. The engine picks up the new file automatically based on the --year argument.
These are structural model constraints, not implementation bugs. They affect how results should be interpreted and compared with official estimates.
The FRS under-reports UC receipt. The model simulates ~3.85 million UC households; the actual caseload is ~6.4 million. UC is only awarded to on_uc_system benefit units: those who reported UC in the FRS (on_uc=True), or legacy benefit claimants who migrate probabilistically (on_legacy=True and migration_seed < migration_rate).
Practical effect: UC reform costings from this model will be ~60% of OBR/DWP estimates. As an example, a taper 55%→50% reform produces ~£0.87bn/yr on FRS 2023; scaled to the real caseload this implies ~£1.45–1.6bn, against OBR-style estimates of ~£2bn+.
Migration rates (uc_migration.* parameters: HB 70%, TC 95%, IS 65%) add ~685k households but do not close the gap.
No UK household survey records realised capital gains, so capital_gains defaults to zero on all datasets. CGT reform modelling requires manually populating this field via --stdin-data or a bespoke dataset.
LCFS has ~4,200 households, so aggregate totals from LCFS-based simulations are small. Consumption pattern relativities are correct, but absolute spending totals should not be interpreted as national aggregates without appropriate uprating.
The SPI is a person-level dataset with no household or benefit unit structure. Benefit and decile outputs are therefore not produced. Always use --persons-only when simulating on SPI.
The model applies the benefit cap deterministically and treats legacy-to-UC migration as a one-period probabilistic assignment. In practice, migration is phased and capped household incomes interact with transitional protection — neither of which is modelled.
cargo build --release
cargo test
| Path | Contents |
|---|---|
src/engine/ | Core simulation logic — income tax, NI, UC, benefits, benefit cap |
src/data/ | Data loaders for FRS, SPI, LCFS, WAS, EFRS |
src/parameters/ | Parameter loading and reform overlay logic |
src/variables/ | Intermediate variable definitions |
src/reforms/ | Reform application utilities |
parameters/ | YAML parameter files — one per fiscal year |
interfaces/python/ | Python wrapper (policyengine-uk-compiled) |
tests/ | Integration tests |
LEGISLATIVE_REFERENCE.md | Statutory citations for all parameters |
Versions are managed via pyproject.toml and towncrier-style changelog fragments in changelog.d/. Do not edit CHANGELOG.md or Cargo.toml versions directly — CI updates these automatically.
To ship a change, drop a fragment file in changelog.d/ with the naming convention <slug>.<type>:
| Suffix | Semver bump |
|---|---|
.added | minor |
.fixed | patch |
.changed | patch |
.removed | minor |
.breaking | major |
Example: create changelog.d/uc-taper-fix.fixed with a one-sentence description of the change. CI infers the version bump from fragment types, updates pyproject.toml, and publishes to PyPI.
After publishing, trigger a redeploy of the chat app:
gh workflow run redeploy-on-package-update.yml --repo PolicyEngine/policyengine-uk-chat
parameters/YYYY_YY.yaml to the new year.LEGISLATIVE_REFERENCE.md.cargo test — existing snapshot tests will catch any regressions in prior years.Recent releases. Full history is in CHANGELOG.md.
See CHANGELOG.md for the complete release history.