invest.isa-allowance
ISA allowance used and remaining for a UK tax year: the overall limit, Lifetime ISA, cash ISA and Junior ISA.
1.0.1 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 30 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
How much of each ISA subscription limit has been used in a UK tax year and how much is left: the overall adult limit, the Lifetime ISA limit inside it, the cash ISA limit inside it (from 2027/28, for savers under 65), and the Junior ISA limit, which is the child's own allowance.
## Why it is shaped this way
For example
isa_allowance(2025-10-01, subscriptions ×2, —)→ tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false 2025/26: £5,000 cash and £10,000 stocks and shares leaves £5,000isa_allowance(2025-10-01, subscriptions ×2, —)→ tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false a full Lifetime ISA counts inside the £20,000isa_allowance(2025-10-01, subscriptions ×1, —)→ tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false £18,000 elsewhere leaves £2,000 of Lifetime ISA room, not £4,000
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def isa_allowance(tax_year_date: str, subscriptions: Sequence[IsaSubscription], date_of_birth: Optional[str]) -> IsaAllowance
| tax_year_date | date | any date in the tax year |
| subscriptions | IsaSubscription[] | every payment into an ISA in that tax year, in GBP; Junior ISA payments are for the child |
| date_of_birth | date? | the saver's; needed only in a year whose cash ISA limit depends on age, otherwise may be null |
| returns | IsaAllowance |
The types it declares, generated into your project
IsaKind = Literal["cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior"]
@dataclass(frozen=True)
class IsaSubscription:
"""One payment into an ISA."""
date: str
kind: IsaKind
#: greater than zero
amount: Money
@dataclass(frozen=True)
class IsaLimitUse:
"""One limit and how much of it is used."""
limit: Money
used: Money
#: what can still go in: never below zero, and never more than the overall allowance has left
remaining: Money
#: true when used is over the limit
exceeded: bool
@dataclass(frozen=True)
class IsaAllowance:
"""Each limit for the tax year, and whether any is broken."""
#: HMRC's label, 2026/27
tax_year: str
#: every adult ISA together
overall: IsaLimitUse
#: Lifetime ISA, inside the overall limit
lifetime: IsaLimitUse
#: cash ISA, inside the overall limit; null in a year or for a saver with no separate cash limit
cash: Optional[IsaLimitUse]
#: Junior ISA, a separate allowance for the child
junior: IsaLimitUse
#: true when any limit is exceeded
exceeded: bool
Your code names it in one line, in the file that uses it
from fune.invest.isa_allowance import isa_allowance # invest.isa-allowance@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import re
from typing import Dict, Optional, Sequence
from .invest_isa_allowance_data import ISA_LIMITS, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON ← this capability’s own data, compiled from data/isa-limits.json into the same file by fune build
from .invest_isa_allowance_types import IsaAllowance, IsaLimitUse, IsaSubscription
from .money_amount import Money ← from money.amount ^1.0.0 · built alongside by fune
_ISO_DATE = re.compile(r"[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])")
_KINDS = ("cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior")
def _gbp(minor: int) -> Money:
return Money(minor=minor, currency="GBP")
def _use(limit: int, used: int, room: int) -> IsaLimitUse:
return IsaLimitUse(
limit=_gbp(limit),
used=_gbp(used),
remaining=_gbp(max(0, min(limit - used, room))),
exceeded=used > limit,
)
def _is_date(value: object) -> bool:
return isinstance(value, str) and _ISO_DATE.fullmatch(value) is not None
def isa_allowance(
tax_year_date: str, subscriptions: Sequence[IsaSubscription], date_of_birth: Optional[str]
) -> IsaAllowance:
"""How much of each ISA subscription limit a saver has used in a UK tax
year and how much is left.
The Lifetime ISA limit and (from 2027/28, for savers under 65) the cash
ISA limit sit inside the overall limit, so what is left of them is also
capped by what is left overall: 18,000 in a stocks and shares ISA leaves
2,000 for a Lifetime ISA, not 4,000. The Junior ISA limit is the child's
own and is counted on its own.
"""
if not _is_date(tax_year_date):
raise ValueError('taxYearDate must be an ISO date (YYYY-MM-DD), received "%s"' % (tax_year_date,))
year = next(
(
row
for row in ISA_LIMITS
if row.valid_from <= tax_year_date and (row.valid_to is None or tax_year_date <= row.valid_to)
),
None,
)
if year is None:
if ISA_LIMITS_HORIZON is not None and tax_year_date < ISA_LIMITS_HORIZON:
raise ValueError(
"no ISA limits for %s: this build was installed with history=%s, so it only carries limits "
"from %s. Reinstall with history=full for earlier years."
% (tax_year_date, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON)
)
raise ValueError("no ISA limits for %s" % (tax_year_date,))
end = year.valid_to
used: Dict[str, int] = {kind: 0 for kind in _KINDS}
for s in subscriptions:
if s.kind not in _KINDS:
raise ValueError(
'kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior, received "%s"' % (s.kind,)
)
if s.amount.currency != "GBP":
raise ValueError("subscriptions must be in GBP, received %s" % (s.amount.currency,))
minor = s.amount.minor
if isinstance(minor, bool) or not isinstance(minor, int) or minor <= 0:
raise ValueError("a subscription must be a whole number of pence greater than zero, received %s" % (minor,))
if not _is_date(s.date) or s.date < year.valid_from or s.date > end:
raise ValueError("the subscription on %s is outside the %s tax year" % (s.date, year.tax_year))
used[s.kind] += minor
# Cash has its own limit only for a saver under 65 for the whole tax year:
# the higher limit applies from the start of the year they turn 65.
cash_limit: Optional[int] = None
if year.cash_under65 is not None:
if date_of_birth is None:
raise ValueError(
"dateOfBirth is needed for %s, when the cash ISA limit depends on age" % (year.tax_year,)
)
if not _is_date(date_of_birth):
raise ValueError('dateOfBirth must be an ISO date (YYYY-MM-DD), received "%s"' % (date_of_birth,))
birthday65 = "%04d%s" % (int(date_of_birth[:4]) + 65, date_of_birth[4:])
if birthday65 > end:
cash_limit = year.cash_under65
adult = used["cash"] + used["stocks-and-shares"] + used["innovative-finance"] + used["lifetime"]
overall = _use(year.overall, adult, year.overall - adult)
room = overall.remaining.minor
lifetime = _use(year.lifetime, used["lifetime"], room)
cash = None if cash_limit is None else _use(cash_limit, used["cash"], room)
junior = _use(year.junior, used["junior"], year.junior - used["junior"])
return IsaAllowance(
tax_year=year.tax_year,
overall=overall,
lifetime=lifetime,
cash=cash,
junior=junior,
exceeded=overall.exceeded or lifetime.exceeded or (cash is not None and cash.exceeded) or junior.exceeded,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add invest.isa-allowance
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./invest.isa-allowance-1.0.1-python.fune, or fetch it from a terminal with fune pull invest.isa-allowance@1.0.1:python.
The whole function, every language, is one file too: invest.isa-allowance-1.0.1.fune, 54,072 bytes, sha256 58fad51ab456b08fb84ac13edb2a52cea5f53ba0c617b68d26867efee117b943. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before invest.isa-allowance
after — your function gets the result and the arguments, and returns the final result.
# fune: after invest.isa-allowance
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace money.amount in invest.isa-allowance
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show invest.isa-allowance --steps.
# fune: step invest.isa-allowance after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 2025/26: £5,000 cash and £10,000 stocks and shares leaves £5,000 | 2025-10-01, subscriptions ×2, — | → | tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false |
| a full Lifetime ISA counts inside the £20,000 | 2025-10-01, subscriptions ×2, — | → | tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false |
| £18,000 elsewhere leaves £2,000 of Lifetime ISA room, not £4,000 | 2025-10-01, subscriptions ×1, — | → | tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false |
| over the overall limit by £1,000 | 2024-10-01, subscriptions ×2, — | → | tax year 2024/25, overall …, lifetime …, cash —, junior …, exceeded true |
| over the Lifetime ISA limit but inside the overall one | 2024-10-01, subscriptions ×1, — | → | tax year 2024/25, overall …, lifetime …, cash —, junior …, exceeded true |
| Junior ISA is a separate allowance: £9,000 on top of a full adult £20,000 | 2023-10-01, subscriptions ×2, — | → | tax year 2023/24, overall …, lifetime …, cash —, junior …, exceeded false |
| a penny over the Junior ISA limit is exceeded | 2023-10-01, subscriptions ×1, — | → | tax year 2023/24, overall …, lifetime …, cash —, junior …, exceeded true |
| 2019/20 Junior ISA limit was £4,368 | 2019-04-06, subscriptions ×1, — | → | tax year 2019/20, overall …, lifetime …, cash —, junior …, exceeded false |
| 2017/18, the first year of £20,000 and the Lifetime ISA | 2018-04-05, subscriptions ×2, — | → | tax year 2017/18, overall …, lifetime …, cash —, junior …, exceeded false |
| no subscriptions: every allowance is free | 2026-06-01, , — | → | tax year 2026/27, overall …, lifetime …, cash —, junior …, exceeded false |
Show the other 20 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 2027/28 under 65: cash is capped at £12,000 inside the £20,000 | 2027-06-01, subscriptions ×2, 1970-01-01 | → | tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded false |
| 2027/28 under 65: £13,000 in cash breaks the cash limit though the overall one is fine | 2027-06-01, subscriptions ×1, 1990-12-31 | → | tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded true |
| 2027/28 under 65 with little in cash: cash room is £12,000 less cash used | 2027-06-01, subscriptions ×2, 1990-12-31 | → | tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded false |
| 2027/28 turning 65 during the year (1 March 2028): the whole £20,000 may be cash | 2027-06-01, subscriptions ×1, 1963-03-01 | → | tax year 2027/28, overall …, lifetime …, cash —, junior …, exceeded false |
| 2027/28 turning 65 on 6 April 2028, the day after the year ends: still under 65 | 2027-06-01, subscriptions ×1, 1963-04-06 | → | tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded true |
| before 2027/28 the date of birth is not needed and cash may take the whole allowance | 2026-06-01, subscriptions ×1, — | → | tax year 2026/27, overall …, lifetime …, cash —, junior …, exceeded false |
| 2027/28 needs the date of birth | 2027-06-01, , — | → | error: dateOfBirth is needed for 2027/28 |
| a subscription outside the tax year is refused | 2025-10-01, subscriptions ×1, — | → | error: the subscription on 2025-04-05 is outside the 2025/26 tax year |
| years before 2017/18 are not covered | 2016-10-01, , — | → | error: no ISA limits for 2016-10-01 |
| years after 2027/28 are not covered | 2028-04-06, , — | → | error: no ISA limits for 2028-04-06 |
| a zero subscription is refused | 2025-10-01, subscriptions ×1, — | → | error: a subscription must be a whole number of pence greater than zero |
| fractional pence are refused | 2025-10-01, subscriptions ×1, — | → | error: a subscription must be a whole number of pence greater than zero |
| amounts must be in sterling | 2025-10-01, subscriptions ×1, — | → | error: subscriptions must be in GBP |
| an unknown kind is refused | 2025-10-01, subscriptions ×1, — | → | error: kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior |
| a malformed date is refused | 2025/10/01, , — | → | error: taxYearDate must be an ISO date |
| a tax year date with a trailing newline is refused | 2025-10-01 , , — | → | error: taxYearDate must be an ISO date |
| a tax year date in Arabic-Indic digits is refused | ٢٠٢٥-10-01, , — | → | error: taxYearDate must be an ISO date |
| a subscription date with a trailing newline is outside the tax year | 2025-10-01, subscriptions ×1, — | → | error: the subscription on 2025-04-06 is outside the 2025/26 tax year |
| a date of birth with a trailing newline is refused | 2027-06-01, , 1990-01-01 | → | error: dateOfBirth must be an ISO date |
| a date of birth in Arabic-Indic digits is refused | 2027-06-01, , ١٩٩٠-01-01 | → | error: dateOfBirth must be an ISO date |
More from the author
- **Limits inside limits.** A Lifetime ISA's £4,000, and from 2027/28 an under-65's £12,000 of cash, count towards the £20,000. So what is left of them is also capped by what is left overall: £18,000 in a stocks and shares ISA leaves £2,000 of Lifetime ISA room, not £4,000. - **Junior ISA is separate.** It is the child's allowance, counted on its own and never against the adult's. Pass one child's subscriptions (or leave them out). - **`exceeded`, not an error.** An over-subscription is a fact to report (HMRC repairs or voids the excess), so the function answers with the flags set rather than throwing. - **Each tax year is its own row** in `data/isa-limits.json`, so the label and the 6 April to 5 April bounds come from the data and a year the data does not cover (before 2017/18, after 2027/28) is refused rather than guessed.
## The 2027/28 cash limit
From 6 April 2027 the government has announced a cash ISA limit of £12,000 for savers under 65, inside an unchanged £20,000, with savers 65 or over able to put the full £20,000 in cash "from the start of the tax year in which an individual turns 65". Here that means: a saver whose 65th birthday falls on or before 5 April at the end of the tax year has no separate cash limit (`cash` is null); anyone younger has the £12,000 limit, and `dateOfBirth` is then required. A 29 February birthday is compared as the string "YYYY-02-29", which sorts correctly.
At 2026-09-23 this is announced policy (policy paper published 17 September 2026) with the amending ISA Regulations still in draft. The row is marked so in its note; if the final regulations differ, publish a new version with the corrected row.
## Limits
| Tax years | Overall | Lifetime ISA | Junior ISA | Cash, under 65 | |---|---|---|---|---| | 2017/18 | £20,000 | £4,000 | £4,128 | none | | 2018/19 | £20,000 | £4,000 | £4,260 | none | | 2019/20 | £20,000 | £4,000 | £4,368 | none | | 2020/21 to 2026/27 | £20,000 | £4,000 | £9,000 | none | | 2027/28 | £20,000 | £4,000 | £9,000 | £12,000 |
## Out of scope
Lifetime ISA age rules (open under 40, pay in until 50) and the government bonus; whether a cash Lifetime ISA counts towards the cash limit (it is not counted as cash here); transfers between ISAs, which are not subscriptions; flexible ISA withdrawals and replacements; the 2027 anti-circumvention rules.
## Sources
Read on 2026-09-23:
- GOV.UK, "Individual Savings Accounts (ISAs): How ISAs work" (£20,000 a year; Lifetime ISA £4,000 a year, inside it): https://www.gov.uk/individual-savings-accounts/how-isas-work - GOV.UK, "Junior Individual Savings Accounts (ISA)" (£9,000 in 2026/27): https://www.gov.uk/junior-individual-savings-accounts - HMRC, "Annual savings statistics: background and methodology" (Tables of adult and Junior ISA subscription limits by tax year, 2008/09 to 2026/27): https://www.gov.uk/government/statistics/annual-savings-statistics-2026/annual-savings-statistics-background-and-methodology - HMRC policy paper, "Cash Individual Savings Account (ISA) limit reduction", published 17 September 2026: https://www.gov.uk/government/publications/reduction-in-the-cash-individual-savings-account-isa-limit/cash-individual-savings-account-isa-limit-reduction - "ISA reform 2027: anti-circumvention rules factsheet" (the age 65 test): https://www.gov.uk/government/publications/fiscal-events-2026-factsheets/isa-reform-2027-anti-circumvention-rules-factsheet
1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in taxYearDate, dateOfBirth and subscription dates; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 3,949 |
| data/isa-limits.json | 3,028 |
| impl/python.py | 4,562 |
| impl/rust.rs | 6,771 |
| impl/typescript.ts | 4,250 |
| vectors.json | 22,201 |