Functional Weave
Code in Python

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/26
  • school_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 class
  • school_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_datedatethe child's date of birth
on_datedatethe date to place the child on; the school year containing it is used
nationUkNationwhose cut-off and year names apply
returnsSchoolYearGroup

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
impl/python.py · 30 lines · open · raw

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
Download for Python education.school-year-group-1.0.1-python.fune · 21,263 bytes sha256 c20b538b84078a36172ec7ec4e0bed59734794dd780b49b9a969c5a2679f8d21

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
NOTICE190
README.md3,315
data/cohort-rules.json725
data/year-groups.json4,302
impl/python.py1,551
impl/rust.rs2,212
impl/typescript.ts1,633
vectors.json5,751