education.school-year-group
School year group from date of birth on a date, by UK nation: Reception to Year 13, P1 to S6, P1 to Year 14.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 22 tests, run in TypeScript, Python and Rust.
What it does
The school year group a child is in on a date, from their date of birth, in each UK nation: the normal age-for-year placement, before any deferral or decision to place a child out of their year group.
| nation | the cohort is born | starts | year groups | |---|---|---|---| | England, Wales | 1 September to 31 August | Reception, in the September after the 4th birthday | Reception, Year 1 to Year 13 | | Scotland | 1 March to the end of February | P1, in August, aged about 4½ to 5½ | P1 to P7, S1 to S6 | | Northern Ireland | 2 July to 1 July (4 on or before 1 July) | P1, in September | P1 to P7, Year 8 to Year 14 |
For example
school_year_group(2020-09-01, 2025-09-15, england)→ school year start 2025-09-01, entry year 2,025, index 0, year group Reception, stage primary England: born 1 September 2020, the oldest in Reception in 2025/26school_year_group(2021-08-31, 2025-09-15, england)→ school year start 2025-09-01, entry year 2,025, index 0, year group Reception, stage primary England: born 31 August 2021, the youngest in the same Reception classschool_year_group(2020-08-31, 2025-09-15, england)→ school year start 2025-09-01, entry year 2,024, index 1, year group Year 1, stage primary England: born a day earlier, 31 August 2020, is a year ahead in Year 1
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 school_year_group(birth_date: str, on_date: str, nation: UkNation) -> SchoolYearGroup
| birth_date | date | the child's date of birth |
| on_date | date | the date to place the child on; the school year containing it is used |
| nation | UkNation | whose cut-off and year names apply |
| returns | SchoolYearGroup |
The types it declares, generated into your project
UkNation = Literal["england", "wales", "scotland", "northern-ireland"]
@dataclass(frozen=True)
class SchoolYearGroup:
"""Where a child of that age sits in the school system on a date."""
#: first day of the school year containing onDate, for this calculation
school_year_start: str
#: calendar year the child's cohort starts Reception or P1
entry_year: int
#: years since that start: 0 is Reception or P1, negative before it
index: int
#: Reception, Year 1 ... Year 13; P1 ... S6; P1 ... Year 14; null before or after school
year_group: Optional[str]
#: primary or secondary; null when yearGroup is
stage: Optional[str]
Your code names it in one line, in the file that uses it
from fune.education.school_year_group import school_year_group # education.school-year-group@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .dates_add_days import parse_iso_date ← from dates.add-days ^1.0.0 · built alongside by fune
from .education_school_year_group_data import COHORT_RULES, YEAR_GROUPS ← this capability’s own data, compiled from data/cohort-rules.json into the same file by fune build
from .education_school_year_group_types import SchoolYearGroup, UkNation
def school_year_group(birth_date: str, on_date: str, nation: UkNation) -> SchoolYearGroup:
"""The year group a child is in on a date, placed by their birth date.
The cohort comes from the birth date itself (born on or after the cohort
start joins a year later), never from an age on a cut-off date, which
misplaces 29 February births where the cohort starts on 1 March.
"""
birth = parse_iso_date(birth_date)
on = parse_iso_date(on_date)
if on_date < birth_date:
raise ValueError("onDate %s is before birthDate %s" % (on_date, birth_date))
rule = next((r for r in COHORT_RULES if r.nation == nation), None)
if rule is None:
raise ValueError('unknown nation "%s": use england, wales, scotland or northern-ireland' % (nation,))
entry_year = birth.year + rule.entry_age + (1 if birth_date[5:] >= rule.cohort_start else 0)
start_year = on.year if on_date[5:] >= rule.school_year_start else on.year - 1
index = start_year - entry_year
name = next((g for g in YEAR_GROUPS if g.nation == nation and g.index == index), None)
return SchoolYearGroup(
school_year_start="%04d-%s" % (start_year, rule.school_year_start),
entry_year=entry_year,
index=index,
year_group=None if name is None else name.label,
stage=None if name is None else name.stage,
)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 education.school-year-group
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.school-year-group-1.0.1-python.fune, or fetch it from a terminal with fune pull education.school-year-group@1.0.1:python.
The whole function, every language, is one file too: education.school-year-group-1.0.1.fune, 25,255 bytes, sha256 f3210b5447a1609ada7aa234b73029f9222291daf2c7c1cdc506f4556ffe4ef4. 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 education.school-year-group
after — your function gets the result and the arguments, and returns the final result.
# fune: after education.school-year-group
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 dates.add-days in education.school-year-group
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 education.school-year-group --steps.
# fune: step education.school-year-group 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 | |
|---|---|---|---|
| England: born 1 September 2020, the oldest in Reception in 2025/26 | 2020-09-01, 2025-09-15, england | → | school year start 2025-09-01, entry year 2,025, index 0, year group Reception, stage primary |
| England: born 31 August 2021, the youngest in the same Reception class | 2021-08-31, 2025-09-15, england | → | school year start 2025-09-01, entry year 2,025, index 0, year group Reception, stage primary |
| England: born a day earlier, 31 August 2020, is a year ahead in Year 1 | 2020-08-31, 2025-09-15, england | → | school year start 2025-09-01, entry year 2,024, index 1, year group Year 1, stage primary |
| England: 31 August is still the previous school year, before Reception | 2020-09-01, 2025-08-31, england | → | school year start 2024-09-01, entry year 2,025, index -1, year group —, stage — |
| England: Year 7 is secondary | 2014-01-15, 2025-11-01, england | → | school year start 2025-09-01, entry year 2,018, index 7, year group Year 7, stage secondary |
| England: Year 13, the last year | 2008-01-15, 2026-03-01, england | → | school year start 2025-09-01, entry year 2,012, index 13, year group Year 13, stage secondary |
| England: after Year 13 there is no year group | 2007-01-15, 2026-03-01, england | → | school year start 2025-09-01, entry year 2,011, index 14, year group —, stage — |
| Wales uses the same cut-off and names | 2019-02-10, 2026-01-10, wales | → | school year start 2025-09-01, entry year 2,023, index 2, year group Year 2, stage primary |
| Scotland: born 1 March 2020 starts P1 in August 2025 | 2020-03-01, 2025-08-20, scotland | → | school year start 2025-08-01, entry year 2,025, index 0, year group P1, stage primary |
| Scotland: born 28 February 2021 is in the same P1 | 2021-02-28, 2025-08-20, scotland | → | school year start 2025-08-01, entry year 2,025, index 0, year group P1, stage primary |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| Scotland: born 29 February 2020 belongs to the year before, P2, though aged like the 1 March children | 2020-02-29, 2025-08-20, scotland | → | school year start 2025-08-01, entry year 2,024, index 1, year group P2, stage primary |
| Scotland: July is still the previous session | 2020-03-01, 2025-07-31, scotland | → | school year start 2024-08-01, entry year 2,025, index -1, year group —, stage — |
| Scotland: S1 is secondary | 2013-06-01, 2025-10-01, scotland | → | school year start 2025-08-01, entry year 2,018, index 7, year group S1, stage secondary |
| Scotland: S6 is the last year | 2008-05-01, 2026-05-01, scotland | → | school year start 2025-08-01, entry year 2,013, index 12, year group S6, stage secondary |
| Northern Ireland: 4 on 1 July 2025 starts P1 in September 2025 | 2021-07-01, 2025-09-10, northern-ireland | → | school year start 2025-09-01, entry year 2,025, index 0, year group P1, stage primary |
| Northern Ireland: 4 on 2 July 2025 waits a year | 2021-07-02, 2025-09-10, northern-ireland | → | school year start 2025-09-01, entry year 2,026, index -1, year group —, stage — |
| Northern Ireland: secondary years are numbered on from primary | 2011-12-01, 2025-10-01, northern-ireland | → | school year start 2025-09-01, entry year 2,016, index 9, year group Year 10, stage secondary |
| Northern Ireland: Year 14, the last year | 2008-03-01, 2026-03-01, northern-ireland | → | school year start 2025-09-01, entry year 2,012, index 13, year group Year 14, stage secondary |
| born today is years before school | 2025-09-15, 2025-09-15, england | → | school year start 2025-09-01, entry year 2,030, index -5, year group —, stage — |
| onDate before birthDate is an error | 2020-09-01, 2019-09-01, england | → | error: onDate 2019-09-01 is before birthDate 2020-09-01 |
| an unknown nation is an error | 2020-09-01, 2025-09-15, ireland | → | error: unknown nation "ireland" |
| an impossible birth date is an error | 2021-02-29, 2025-09-15, scotland | → | error: "2021-02-29" is not a real calendar date |
More from the author
## How it works, and the leap-day trap
The cohort is decided from the birth date itself: a child born on or after the nation's cohort start date (1 September, 1 March, 2 July) joins the class that starts the year after four years on; one born before it, the year before. The school year containing `onDate` then gives how many years along they are.
Working from the child's *age* on a cut-off date instead goes wrong on 29 February in Scotland: a child born 29 February 2020 is in the cohort born 1 March 2019 to 29 February 2020, but has the same age as the 1 March 2020 children on every late-February date in later years (a birthday of 29 February counts as 1 March in common years). A vector pins it.
## Decisions
- **School year boundaries** are 1 September (England, Wales, Northern Ireland) and 1 August (Scotland, whose schools go back mid-August; each council sets the exact day). A date between that boundary and the first day of term is placed in the new year group. - `index` is always returned; `yearGroup` is null before Reception or P1 (nursery and pre-school are not named) and after the last year. - Deferred entry (Scotland's January and February births, summer-born children in England, Northern Ireland's 1 April to 1 July births) and children educated out of their year group are the caller's to apply: shift `index` by the years deferred. - The rules are long-standing conventions rather than dated rates, so the tables carry no `effective` columns and always ship whole.
## Sources
- GOV.UK, "School admissions: school starting age" ("Most children start reception full-time in September after their fourth birthday"): https://www.gov.uk/schools-admissions/school-starting-age - mygov.scot, "Choosing a school for your child", and Education Scotland Parentzone, "Starting school" (a year group is born between the start of March and the end of February; P1 starts in August): https://www.mygov.scot/register-your-child-for-a-school , https://education.gov.scot/parentzone/my-child/transitions/starting-school - nidirect, "Deferral of pre-school or primary school places", and Department of Education NI, "Compulsory education" (children aged 4 on or before 1 July start P1 that September): https://www.nidirect.gov.uk/articles/deferral-pre-school-or-primary-school-places , https://www.education-ni.gov.uk/articles/compulsory-education
## Notices
Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 190 |
| README.md | 3,315 |
| data/cohort-rules.json | 725 |
| data/year-groups.json | 4,302 |
| impl/python.py | 1,551 |
| impl/rust.rs | 2,212 |
| impl/typescript.ts | 1,633 |
| vectors.json | 5,751 |