Skip to content

Charge VAT on private school fees at current law - #2319

Draft
MaxGhenis wants to merge 6 commits into
fix/corporate-sdlt-double-countfrom
fix/private-school-vat-current-law
Draft

MaxGhenis wants to merge 6 commits into
fix/corporate-sdlt-double-countfrom
fix/private-school-vat-current-law

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2318

Summary

VAT has applied to private school fees since 1 January 2025, but private_school_vat was £0 at current law: its only rate was the contributed reform parameter gov.contrib.labour.private_school_vat, which is 0 for all time.

This PR adds the statutory charge and keeps the contributed rate for reforms without letting it tax the fees twice.

Household taxes now include private school VAT as a change from baseline, as they do VAT, so baseline household net income is unchanged and reforms move it.

Stacked on #2235, which adds policyengine_uk/data/fiscal_partition.yaml. The base is #2235's branch, so this diff shows only this change. It retargets to main when #2235 merges.

The law

  • Before. VATA 1994 Sch 9 Group 6 item 1 exempts "the provision by an eligible body of education", and Note (1)(a)(i) makes a registered independent school an eligible body (as enacted). The exemption dates from VAT's start: Finance Act 1972 Sch 5 Group 6 item 1, "the provision of education if it is provided by a school" (as enacted).

  • Finance Act 2025 s.47 (enacted) inserts Sch 9 Part 3, items 1 to 3:

    • education by a private school;
    • vocational training by a private school;
    • closely related board and lodging.

    It amends s.31(1), so a supply is exempt only if it is in Part 2 and not in Part 3. No zero or reduced rate applies, so these supplies bear the standard rate.

  • Section 49 (enacted) gives effect "in relation to any provision of education, vocational training or board and lodging on or after 1 January 2025".

Does vat already tax the fees?

Not by formula. But the two bases are not separated household by household, and on calibrated data they overlap in aggregate. What the code does:

  • vat = (full_rate_vat_consumption × standard rate + reduced_rate_vat_consumption × reduced rate) ÷ microdata_vat_coverage (0.38).

  • full_rate_vat_consumption = consumption × full_rate_vat_expenditure_rate, and consumption adds the twelve COICOP categories, including education_consumption (LCFS P610).

  • policyengine-uk-data (tag 1.58.0) imputes full_rate_vat_expenditure_rate:

    • from ETB totvat and expdis for training year 2023, that is FYE 2024 (datasets/imputations/vat.py, datasets/private_releases.py);
    • with a QRF on adults, children, state-pension-age members and net income.

    Private school fees were exempt in FYE 2024, so the share prices them as exempt in aggregate. A fee-paying household's fees are charged at its blended share.

  • Household calculator. With the default share of 0.5, education consumption entered as an input is charged in vat as well as in private_school_vat, where attendance is an input.

  • Calibrated data. policyengine-uk-data calibrates weighted vat to the OBR "Value added tax" receipts line (targets/sources/obr.py, calibration year 2025). That line includes VAT on private school fees from 2025-26, so gov_tax counts it in both heads until the target nets it out: OBR VAT target includes VAT on private school fees, which policyengine-uk now models separately policyengine-uk-data#567.

private_school_vat itself charges the fees once, at max(statutory rate, reform rate). Neither head reads the other's inputs or parameters (tested).

The change

Before After
Statutory charge none gov.hmrc.vat.private_school_fees_exempt: true from 1973-04-01, false from 2025-01-01. When false, the fees bear gov.hmrc.vat.standard_rate.
Rate on fees gov.contrib.labour.private_school_vat max(statutory rate, gov.contrib.labour.private_school_vat)
private_school_vat variables/contrib/labour/ variables/gov/hmrc/; formula pupils × mean fee × share of fees bearing VAT × rate, unchanged otherwise
household_tax sums private_school_vat sums private_school_vat_change = private_school_vat − baseline_private_school_vat
gov_tax sums private_school_vat unchanged

The baseline. baseline_private_school_vat applies baseline policy (the exemption, the standard rate, the reform rate) to this simulation's attendance, mean fee and share. So a reform that only changes those assumptions is not scored as a tax change; one example is disabling economic assumptions, which moves the fee uprating.

Why a boolean. The exemption is a boolean, not a 20% rate parameter, because the law removes an exemption and the standard rate follows: a reform to the standard rate moves the fees with it.

The contributed rate. Taking the maximum means:

  • 20% from 2025 changes nothing, where adding the rates would give 40%;
  • 25% charges 25%;
  • before 2025 it applies alone, as before.

Repealing the charge is gov.hmrc.vat.private_school_fees_exempt: true.

Fiscal partition (policyengine_uk/data/fiscal_partition.yaml, from #2235)

  • private_school_vat drops zero_at_baseline and is in gov_tax only.
  • New head private_school_vat_change in household_tax: sign: any, zero_at_baseline: expected. It declares its reads: the level, and the attendance imputation's routes to income tax, NI and council tax.
  • New exclusion baseline_private_school_vat (baseline_measure).
  • New guard private_school_vat_level_and_change (level_and_change).
  • The vat guard becomes overlapping_base and says what is above: separate inputs, overlapping base, the calculator double charge, and the calibrated-data overlap with a link to policyengine-uk-data#567. The header lists the guard kinds.
  • test_fiscal_partition.py's identity property now:
    • draws households with a child at private school;
    • adds a scenario restoring the exemption;
    • asserts private_school_vat > 0 exactly where a pupil's fees are charged.

Invariants

For every household and parameter set:

  1. Statute differential. private_school_vat = pupils × fee × share × max(statutory, reform), where statutory is 0 while exempt and the standard rate otherwise. The reference is computed in the test from the statute, not from the formula.
  2. Once. A reform rate at or below the statutory rate leaves private_school_vat unchanged.
  3. Monotone. private_school_vat never falls when the reform rate or the standard rate rises.
  4. Separate inputs. vat is unchanged by any private school parameter. private_school_vat is unchanged by consumption, including education consumption.
  5. Level and change.
    • change = level − baseline;
    • baseline = level under baseline policy with this simulation's assumptions;
    • an assumptions-only reform changes nothing;
    • the change is 0 at current law.
  6. Partition identity (from Count corporate SDLT once and let SDLT abolition remove all of it #2235, now exercised). Each total is the sum of its heads, with pupils present, at current law and with the exemption restored.

Tests

File Covers
tests/policy/baseline/gov/hmrc/private_school_vat.yaml 15 cases: exempt 2024; standard rate from 2025; pupils only; reform rate at, below and above the standard rate; reform rate before 2025; restored exemption; changed standard rate; level vs change in gov_tax and household_tax, including a reform that raises the change (+500) and one that removes it (−2,000)
tests/test_private_school_vat_properties.py Invariants 1-5 (Hypothesis), plus a 48-cell grid for 1: year 2024/2025 × exemption at current law, restored or removed × reform rate 0, 0.1, 0.2, 0.3 × standard rate default or 25%
tests/test_fiscal_partition.py 40 pass, including the identity property with pupils
tests/policy/baseline/gov/hmrc/excise_integration.yaml Zeroes the new head

Mutations, run locally:

Mutation Caught by
Adding the rates 3 YAML cases and the differential property
Ignoring the exemption 2 YAML cases and the differential
household_tax summing the level the integration case and the partition differential
Baseline using current parameters the level-and-change property

Local runs: 15 + 2 YAML cases, 53 property and grid tests, and 40 partition tests pass. CI does not run on a PR whose base is another branch, so it runs when this retargets to main.

National impact (real runs)

Running: policyengine-uk-data 1.58.0 certified enhanced_frs_2024_25.h5, base origin/main vs head (main plus this PR's model files), policyengine-core 3.33.0, 2025-26 to 2030-31. Four scenarios: current law, the reform rate at 20%, the exemption restored, and a basic rate of 21% to measure attendance re-imputation. This section will be filled from those runs.

Known limitations (tracked)

  • Nursery classes and sixth-formers.
    • Part 3 item 1 excepts "education in a nursery class", and 18-year-olds in sixth forms are chargeable.
    • The attendance imputation spreads pupils over all under-18s and is calibrated to 557,000 pupils, so some imputed pupils are aged 0-3 and none is 18.
    • This PR charges all imputed pupils; a note in private_school_vat.py records the simplification. Restricting only the charge would cut a calibrated total that sits close to the real school-age count. The fix is to impute over school ages and re-solve the factor: Private school attendance: store the imputation, impute over school ages, follow the pupil trend policyengine-uk-data#568, point 2. The runs report pupils by age.
  • Attendance under reforms. Attendance is re-imputed from net income in every simulation. A reform to income tax, NI or benefits can switch some pupils, which moves gov_tax through private_school_vat but not household_tax. The runs measure this for a basic rate of 21%. The durable fix is to store the imputed attendance in the dataset: policyengine-uk-data#568, point 1.
  • Calibrated VAT. As above: policyengine-uk-data#567.
  • Timing. The fiscal-year conversion reads year Y's parameters at 30 April, so model year 2024 (FY 2024-25) has no charge, and the January to March 2025 receipts (part of the £460m costed for 2024-25) are missed.
  • Behaviour. There is no pupil or fee response. The OBR assumes about 35,000 fewer pupils in the long run, and the pupil target is flat at 557,000 (policyengine-uk-data#568, point 3).
  • Local authority placements. Pupils placed by local authorities with an EHCP are charged VAT, which the authority recovers under VATA s.33. They are not separated.

Downstream

  • Reforms that set gov.contrib.labour.private_school_vat to 0.2 for 2025 onwards now score nothing; the changelog says so.
  • policyengine-scorecard. Its Autumn Budget 2024 event private_school_vat_20pct is built as a forward delta on that parameter. Its identical-worlds guard will flag the event once it pins a release with this change. A follow-up rebuilds it as a reverse delta.

Review

Both rounds ran on subfleet run --task review --tier hard. Opus 5.5 lanes served them, because the Codex lanes were full.

Round 1 (review_r1.md) asked for changes, and every finding is addressed or disclosed:

  • the baseline holds assumptions fixed;
  • the guard text is corrected;
  • the calibration overlap is disclosed;
  • the identity property is exercised;
  • the exemption starts in 1973;
  • the changelog is updated.

Round 2 (Opus 5.5 lane, review_r2.md): APPROVE. The small follow-ups are done:

  • a comment that parameters.baseline reads 1 January values;
  • an independent 20% reference in the grid;
  • the guard and header wording;
  • a forced pupil example, plus exemption-restored assertions in the identity property;
  • a code note on the nursery-class simplification.

axiom: TheAxiomFoundation/rulespec-uk#453 queued

🤖 Generated with Claude Code

MaxGhenis and others added 5 commits October 11, 2026 14:45
The aggregate impact report (#2335) adds gov_spending, household_benefits
and spending_heads to fiscal_partition.yaml. The tax tests now check the
three tax totals only, require that spending heads borrow no tax head id or
variable and that no spending head names a tax total, and apply dropped_when
to the tax totals alone. Checked against #2335's file with these tax
sections merged in: this file's tests and #2335's partition and guard tests
pass.

The vat head records that 76 households have negative VAT on
policyengine-uk-data 1.58.0 (consumption or the full-rate expenditure share
is negative in the data; policyengine-uk-data issue 565).

Refs #2159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finance Act 2025 s.47 takes private school education, vocational training
and closely related board and lodging out of the VATA 1994 Sch 9 Group 6
exemption, with effect for supplies on or after 1 January 2025 (s.49). The
new gov.hmrc.vat.private_school_fees_exempt parameter records the
exemption; when it is off the fees bear the standard rate. The contributed
reform rate now raises the rate instead of adding to it, so setting it
never charges the fees twice.

private_school_vat moves to variables/gov/hmrc. Household taxes include it
as a change from baseline (private_school_vat_change), as they do VAT;
government tax revenue includes the level.

Fixes #2318

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
private_school_vat is now non-zero at current law, so it leaves the
zero-at-baseline heads and stays in gov_tax only; household_tax sums the
new private_school_vat_change head, which is zero at baseline, with
baseline_private_school_vat as its baseline measure. A level-and-change
guard mirrors vat and vat_change. The vat overlap guard becomes a
disjointness property: vat's full-rate share was trained when the fees
were exempt, and neither head reads the other's inputs or parameters.

Adds Hypothesis properties: private_school_vat matches the statute
computed independently, a reform rate at or below the statutory rate never
taxes the fees twice, the head is monotone in both rates, vat and
private_school_vat are disjoint, and the level, baseline and change agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
baseline_private_school_vat now applies baseline policy (the exemption,
the standard rate and the reform rate) to this simulation's attendance,
mean fee and share of fees bearing VAT, so a reform that changes only those
assumptions (such as disabling economic assumptions, which moves the fee
uprating) is no longer scored as a household tax change. YAML cases now
compute the change and include reforms that raise and remove it; a grid
test checks the statutory rate exhaustively across the commencement, the
exemption, reform rates and standard rates. The exemption parameter starts
when VAT did (Finance Act 1972 Sch 5 Group 6), so no earlier lookup reads
as taxable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vat guard becomes overlapping_base: the heads read separate inputs,
but vat applies one household share to all consumption including
education, so a fee-paying household's fees are charged at its blended
share, and in a household calculation education consumption entered as an
input is charged in both heads. On calibrated data the overlap is in
aggregate, because policyengine-uk-data targets vat at total OBR VAT
receipts (policyengine-uk-data#567). The partition header lists the guard
kinds and says data overlaps are recorded under their guards.

The identity property now draws households with a child at private school
and a scenario restoring the exemption, and checks the head is positive
exactly where a pupil's fees are charged. The changelog says contributed
reforms at 20% now score nothing from 2025.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the second independent review:
- The identity property always includes a household with a pupil, and
  under a restored exemption checks the head is zero and the change is
  minus the baseline charge exactly where a pupil's fees bore it.
- The grid's reference uses the statutory 20 per cent, not the model's
  parameter.
- The eligibility-reads guard says its check is vacuous for the private
  school heads on situations; the header says formula overlaps between
  heads with unseparated bases are recorded under their guards.
- private_school_vat notes that every imputed pupil is charged (nursery
  classes excepted by law, 18-year-olds chargeable), and the baseline notes
  it reads 1 January values where the level reads 30 April.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant