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 claimants with reported receipt
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
child_benefitRates and high-income charge threshold/taper
working_tax_creditLegacy WTC parameters (kept for claimants with reported receipt)
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

The FRS under-reports UC receipt. UC is only awarded to benefit units that reported UC in the FRS (on_uc=True) or under full take-up (--full-take-up); benefit units with reported legacy benefit receipt stay on the legacy system.

Practical effect: UC reform costings from this model will undercount relative to OBR/DWP estimates, in proportion to the FRS under-reporting of the UC caseload.

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 UC migration

The model applies the benefit cap deterministically and routes benefit units to UC or legacy purely from reported receipt. In practice, legacy-to-UC managed 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.