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.
pub fn school_year_group(birth_date: &str, on_date: &str, nation: &str) -> 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 is a string in Rust, one of: "england", "wales", "scotland", "northern-ireland".
// Parameters take it as &str and results hold it as String.
/// Where a child of that age sits in the school system on a date.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchoolYearGroup {
/// first day of the school year containing onDate, for this calculation
pub school_year_start: String,
/// calendar year the child's cohort starts Reception or P1
pub entry_year: i64,
/// years since that start: 0 is Reception or P1, negative before it
pub index: i64,
/// Reception, Year 1 ... Year 13; P1 ... S6; P1 ... Year 14; null before or after school
pub year_group: Option<String>,
/// primary or secondary; null when yearGroup is
pub stage: Option<String>,
}
Your code names it in one line, in the file that uses it
fune!(education.school-year-group@^1); // then call school_year_group(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::parse_iso_date; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::education_school_year_group_data::{COHORT_RULES, YEAR_GROUPS}; ← this capability’s own data, compiled from data/cohort-rules.json into the same file by fune build
/// 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.
///
/// # Panics
/// Panics on a malformed date, onDate before birthDate, or an unknown nation.
pub fn school_year_group(birth_date: &str, on_date: &str, nation: &str) -> SchoolYearGroup {
let birth = parse_iso_date(birth_date);
let on = parse_iso_date(on_date);
if on_date < birth_date {
panic!("onDate {} is before birthDate {}", on_date, birth_date);
}
let rule = match COHORT_RULES.iter().find(|r| r.nation == nation) {
Some(r) => r,
None => panic!("unknown nation \"{}\": use england, wales, scotland or northern-ireland", nation),
};
let entry_year = birth.year + rule.entry_age + if &birth_date[5..] >= rule.cohort_start { 1 } else { 0 };
let start_year = if &on_date[5..] >= rule.school_year_start { on.year } else { on.year - 1 };
let index = start_year - entry_year;
let name = YEAR_GROUPS.iter().find(|g| g.nation == nation && g.index == index);
SchoolYearGroup {
school_year_start: format!("{:04}-{}", start_year, rule.school_year_start),
entry_year,
index,
year_group: name.map(|g| g.label.to_string()),
stage: name.map(|g| g.stage.to_string()),
}
}
pub fn school_year_group_to_value(g: &SchoolYearGroup) -> Value {
let opt = |v: &Option<String>| match v {
Some(s) => Value::str(s),
None => Value::Null,
};
Value::obj(vec![
("schoolYearStart", Value::str(&g.school_year_start)),
("entryYear", Value::Int(g.entry_year)),
("index", Value::Int(g.index)),
("yearGroup", opt(&g.year_group)),
("stage", opt(&g.stage)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
school_year_group_to_value(&school_year_group(args[0].as_str(), args[1].as_str(), args[2].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./education.school-year-group-1.0.1-rust.fune, or fetch it from a terminal with fune pull education.school-year-group@1.0.1:rust.
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 |