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
schoolYearGroup(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/26schoolYearGroup(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 classschoolYearGroup(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.
export function schoolYearGroup(birthDate: string, onDate: string, nation: UkNation): SchoolYearGroup
| birthDate | date | the child's date of birth |
| onDate | 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
export type UkNation = "england" | "wales" | "scotland" | "northern-ireland";
/** Where a child of that age sits in the school system on a date. */
export interface SchoolYearGroup {
/** first day of the school year containing onDate, for this calculation */
readonly schoolYearStart: string;
/** calendar year the child's cohort starts Reception or P1 */
readonly entryYear: number;
/** years since that start: 0 is Reception or P1, negative before it */
readonly index: number;
/** Reception, Year 1 ... Year 13; P1 ... S6; P1 ... Year 14; null before or after school */
readonly yearGroup: string | null;
/** primary or secondary; null when yearGroup is */
readonly stage: string | null;
}
Your code names it in one line, in the file that uses it
import { schoolYearGroup } from "#fune/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.
import { parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { COHORT_RULES, YEAR_GROUPS } from "./education_school_year_group_data.ts"; ← this capability’s own data, compiled from data/cohort-rules.json into the same file by fune build
import { type SchoolYearGroup, type UkNation } from "./education_school_year_group_types.ts";
/**
* 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.
*/
export function schoolYearGroup(birthDate: string, onDate: string, nation: UkNation): SchoolYearGroup {
const birth = parseIsoDate(birthDate);
const on = parseIsoDate(onDate);
if (onDate < birthDate) {
throw new RangeError(`onDate ${onDate} is before birthDate ${birthDate}`);
}
const rule = COHORT_RULES.find((r) => r.nation === nation);
if (rule === undefined) {
throw new RangeError(`unknown nation "${nation}": use england, wales, scotland or northern-ireland`);
}
const birthMonthDay = birthDate.slice(5);
const onMonthDay = onDate.slice(5);
const entryYear = birth.year + rule.entryAge + (birthMonthDay >= rule.cohortStart ? 1 : 0);
const startYear = onMonthDay >= rule.schoolYearStart ? on.year : on.year - 1;
const index = startYear - entryYear;
const name = YEAR_GROUPS.find((g) => g.nation === nation && g.index === index);
return {
schoolYearStart: `${String(startYear).padStart(4, "0")}-${rule.schoolYearStart}`,
entryYear,
index,
yearGroup: name === undefined ? null : name.label,
stage: name === undefined ? null : name.stage,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./education.school-year-group-1.0.1-typescript.fune, or fetch it from a terminal with fune pull education.school-year-group@1.0.1:typescript.
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 |