policyengine-uk-rust

High-performance UK tax-benefit microsimulation engine. ~0.1 ms per household, with a Python wrapper for policy analysis.

Rust · Python v0.11.0 Income tax · NI · UC · Child Benefit · 10+ programmes

What this is

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.

Quick links

Quick start

Install the Python package and run your first simulation in five minutes.

🐍

Python API

Household constructors, reform patterns, marginal tax rates, and dataset downloads.

CLI reference

All flags for the compiled binary — data extraction, simulation, and output formats.

📄

Datasets

FRS, SPI, LCFS, WAS, and EFRS — sources, download instructions, and when to use each.

Legislative reference

Statutory citations and year-by-year parameter values for every modelled provision.

Limitations

UC caseload undercount and other model caveats you need to know before citing results.

Programmes modelled

ProgrammeNotes
Income tax (UK & Scottish rates)Personal allowance, basic/higher/additional bands, dividend allowance
National Insurance (Class 1, 2, 4)Employee, employer, self-employed
Universal CreditStandard allowance, work allowance, taper, housing element, childcare
Child BenefitHigh-income charge included
Working Tax Credit / Child Tax CreditLegacy system for non-migrated claimants
Housing BenefitLegacy claimants only
Pension CreditGuarantee and savings credit
Benefit capHousehold-level application
Council Tax Benefit / Reduction
Stamp Duty Land TaxEFRS dataset required
Capital Gains TaxRequires manually populated capital_gains field
Wealth tax (modelled provision)EFRS dataset required; not a current UK tax

Quick start

Install the Python package (Python 3.10+, pip or uv):

pip install policyengine-uk-compiled
# or
uv add policyengine-uk-compiled

Single-household simulation

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

Population simulation with FRS

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")

Running a reform

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")
UC caseload caveat. FRS under-reports UC receipt at ~60% of actual. Any UC reform costing from this model will be proportionally lower than OBR/DWP estimates. See the Limitations page for detail.

Python API

Household constructors

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
)

Custom households

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)

Marginal tax rates

# 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%

Reform parameters

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},
        ]
    }
}

Dataset management

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"

Key classes and functions

NameDescription
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.

CLI reference

After cargo build --release, the binary is at ./target/release/policyengine-uk-rust.

Core flags

FlagDescription
--data DIRPath to clean CSV base directory (YYYY/persons.csv etc.)
--year YYYYFiscal year — determines which parameter file is loaded
--policy-json '{...}'Inline JSON reform overlay. Merged on top of baseline parameters.
--output jsonMachine-readable aggregate output to stdout
--output-microdata-stdoutPer-entity CSVs separated by ===PERSONS=== / ===BENUNITS=== / ===HOUSEHOLDS===
--export-params-jsonDump the baseline parameter tree and exit
--stdin-dataAccept a single constructed household payload from stdin
--persons-onlySuppress benefit/decile outputs — use with SPI which has no household structure

Data extraction flags

FlagUse
--frs TAB_DIR --extract OUT_DIRExtract FRS raw tabs → clean CSVs
--lcfs TAB_DIR --extract OUT_DIRExtract LCFS raw tabs → clean CSVs
--spi TAB_DIR --extract OUT_DIRExtract SPI raw tabs → clean CSVs
--was TAB_DIR --extract OUT_DIRExtract WAS raw tabs → clean CSVs
--extract-efrs OUT_DIRBuild Enhanced FRS (requires --data, --was-dir, --lcfs-dir)
--uprate-to YYYYUprate dataset to target year when used with --extract

Examples

# 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

Datasets

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.

DatasetSourceUKDS SNBest 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.

Getting data

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.

SPI file naming. Newer files use 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 — Enhanced FRS

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/
CGT note. 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 year coverage

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.

Parameters

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.

Inspecting parameters

# 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

Structure

Top-level keys in the parameter tree:

KeyContents
income_taxPersonal allowance, UK and Scottish rate bands, dividend allowance, savings rate
national_insurancePrimary/secondary thresholds and rates, Class 2 and 4 self-employed rates
universal_creditStandard allowances by household type, taper rate, work allowances, childcare element, migration rates
child_benefitRates and high-income charge threshold/taper
working_tax_creditLegacy WTC parameters (kept for non-migrated claimants)
child_tax_creditLegacy CTC parameters
benefit_capCap amounts by household type and London/outside London
stamp_dutySDLT thresholds and rates (EFRS only)
capital_gains_taxAnnual exempt amount, rates
wealth_taxModelled provisions — not a current UK tax

Legislative reference

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.

Adding a new year

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.

Limitations

These are structural model constraints, not implementation bugs. They affect how results should be interpreted and compared with official estimates.

UC caseload undercount (~60%)

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 realised capital gains data

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 sample size

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.

SPI — no household structure

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.

Benefit cap and migration timing

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.

Contributing

Build and test

cargo build --release
cargo test

Repository layout

PathContents
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.mdStatutory citations for all parameters

Releasing

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

SuffixSemver bump
.addedminor
.fixedpatch
.changedpatch
.removedminor
.breakingmajor

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

Updating parameters for a new fiscal year

  1. Copy the most recent parameters/YYYY_YY.yaml to the new year.
  2. Update all values, citing the relevant Act or statutory instrument.
  3. Cross-reference every change in LEGISLATIVE_REFERENCE.md.
  4. Run cargo test — existing snapshot tests will catch any regressions in prior years.

Changelog

Recent releases. Full history is in CHANGELOG.md.

v0.11.0 2026 added
  • OBR labour supply response elasticities
v0.10.0 2025 added
  • Stamp duty, CGT, and wealth tax parameters exposed in Python wrapper
v0.9.0 2025 added
  • Multi-year simulation support in Python wrapper

See CHANGELOG.md for the complete release history.