education.attendance-rate
Pupil attendance and absence rates in basis points, with the DfE persistent and severe absence flags.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
A pupil's attendance and absence rates in basis points (9000 is 90.00%), and whether they are persistently or severely absent under the Department for Education's definitions for schools in England.
## The definitions, and why they are data
For example
attendance_rate(380, 38, 2025-10-01)→ attendance basis points 90%, absence basis points 10%, persistently absent true, severely absent false 38 of 380 sessions missed is exactly 10%: persistently absentattendance_rate(380, 37, 2025-10-01)→ attendance basis points 90.26%, absence basis points 9.74%, persistently absent false, severely absent false 37 of 380 is 9.74%: not persistently absentattendance_rate(2,501, 250, 2025-10-01)→ attendance basis points 90%, absence basis points 10%, persistently absent false, severely absent false 250 of 2,501 rounds to 10.00% but is under 10%: not persistently absent
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 attendance_rate(possible_sessions: int, missed_sessions: int, on_date: str) -> AttendanceRate
| possible_sessions | int | sessions the pupil could have attended: two a day, morning and afternoon |
| missed_sessions | int | sessions missed for any reason, authorised or not |
| on_date | date | a date in the period measured, which picks the definitions in force |
| returns | AttendanceRate | rates and flags; the flags use the exact fraction, not the rounded rate |
The type it declares, generated into your project
@dataclass(frozen=True)
class AttendanceRate:
"""Attendance, absence and the DfE absence flags for one pupil."""
#: 10000 minus absenceBasisPoints
attendance_basis_points: int
#: missed / possible, rounded half-up to a basis point
absence_basis_points: int
#: missed 10% or more of possible sessions
persistently_absent: bool
#: missed 50% or more; null on a date before that measure was published
severely_absent: Optional[bool]
Your code names it in one line, in the file that uses it
from fune.education.attendance_rate import attendance_rate # education.attendance-rate@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Optional
from .dates_add_days import parse_iso_date ← from dates.add-days ^1.0.0 · built alongside by fune
from .education_attendance_rate_data import ABSENCE_THRESHOLDS, ABSENCE_THRESHOLDS_HISTORY, ABSENCE_THRESHOLDS_HORIZON ← this capability’s own data, compiled from data/absence-thresholds.json into the same file by fune build
from .education_attendance_rate_types import AttendanceRate
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _threshold_on(measure: str, on_date: str) -> Optional[int]:
for r in ABSENCE_THRESHOLDS:
if r.measure == measure and on_date >= r.valid_from and (r.valid_to is None or on_date <= r.valid_to):
return r.basis_points
return None
def attendance_rate(possible_sessions: int, missed_sessions: int, on_date: str) -> AttendanceRate:
"""Attendance and absence in basis points, and the DfE absence flags.
The flags compare missed x 10000 with possible x threshold, exactly: a
rate of 9.996% rounds to 10.00% but is not persistent absence.
"""
if not _is_int(possible_sessions) or possible_sessions < 1:
raise ValueError("possibleSessions must be a whole number of 1 or more, received %s" % (possible_sessions,))
if not _is_int(missed_sessions) or missed_sessions < 0 or missed_sessions > possible_sessions:
raise ValueError(
"missedSessions must be a whole number from 0 to possibleSessions (%s), received %s"
% (possible_sessions, missed_sessions)
)
parse_iso_date(on_date)
persistent = _threshold_on("persistent", on_date)
if persistent is None:
if ABSENCE_THRESHOLDS_HISTORY != "full" and ABSENCE_THRESHOLDS_HORIZON is not None and on_date < ABSENCE_THRESHOLDS_HORIZON:
raise ValueError(
"no persistent absence definition on %s: this build was installed with history=%s, so it only carries rules from %s. "
"Reinstall with history=full for earlier years." % (on_date, ABSENCE_THRESHOLDS_HISTORY, ABSENCE_THRESHOLDS_HORIZON)
)
raise ValueError("no persistent absence definition on %s" % (on_date,))
severe = _threshold_on("severe", on_date)
absence = round_div(missed_sessions * 10000, possible_sessions, "half-up")
return AttendanceRate(
attendance_basis_points=10000 - absence,
absence_basis_points=absence,
persistently_absent=missed_sessions * 10000 >= possible_sessions * persistent,
severely_absent=None if severe is None else missed_sessions * 10000 >= possible_sessions * severe,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, 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.attendance-rate
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.attendance-rate-1.0.1-python.fune, or fetch it from a terminal with fune pull education.attendance-rate@1.0.1:python.
The whole function, every language, is one file too: education.attendance-rate-1.0.1.fune, 20,274 bytes, sha256 628cb864d801b56920bd73fdf860b8bf0b0145e5019b166523a736ed57a86b22. 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.attendance-rate
after — your function gets the result and the arguments, and returns the final result.
# fune: after education.attendance-rate
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.attendance-rate
# fune: replace math.round-div in education.attendance-rate
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.attendance-rate --steps.
# fune: step education.attendance-rate 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 | |
|---|---|---|---|
| 38 of 380 sessions missed is exactly 10%: persistently absent | 380, 38, 2025-10-01 | → | attendance basis points 90%, absence basis points 10%, persistently absent true, severely absent false |
| 37 of 380 is 9.74%: not persistently absent | 380, 37, 2025-10-01 | → | attendance basis points 90.26%, absence basis points 9.74%, persistently absent false, severely absent false |
| 250 of 2,501 rounds to 10.00% but is under 10%: not persistently absent | 2,501, 250, 2025-10-01 | → | attendance basis points 90%, absence basis points 10%, persistently absent false, severely absent false |
| 190 of 380 is exactly half: severely absent | 380, 190, 2025-10-01 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent true |
| 189 of 380 is just under half | 380, 189, 2025-10-01 | → | attendance basis points 50.26%, absence basis points 49.74%, persistently absent true, severely absent false |
| no sessions missed | 380, 0, 2025-10-01 | → | attendance basis points 100%, absence basis points 0%, persistently absent false, severely absent false |
| every session missed | 380, 380, 2025-10-01 | → | attendance basis points 0%, absence basis points 100%, persistently absent true, severely absent true |
| a half basis point rounds absence up and attendance is what is left, so they sum to 100% | 20,000, 1, 2025-10-01 | → | attendance basis points 99.99%, absence basis points 0.01%, persistently absent false, severely absent false |
| a single possible session, attended | 1, 0, 2025-10-01 | → | attendance basis points 100%, absence basis points 0%, persistently absent false, severely absent false |
| 2016/17, before severe absence was published: severe is null | 380, 190, 2016-10-01 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent — |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the last day before severe absence data | 380, 190, 2018-08-31 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent — |
| the first day of 2018/19 | 380, 190, 2018-09-01 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent true |
| the first day of the 10% definition | 380, 38, 2015-09-01 | → | attendance basis points 90%, absence basis points 10%, persistently absent true, severely absent — |
| before 2015/16 there is no per-pupil 10% definition | 380, 38, 2015-08-31 | → | error: no persistent absence definition on 2015-08-31 |
| more sessions missed than possible is an error | 380, 381, 2025-10-01 | → | error: missedSessions must be a whole number from 0 to possibleSessions (380), received 381 |
| negative missed sessions is an error | 380, -1, 2025-10-01 | → | error: missedSessions must be a whole number from 0 to possibleSessions (380), received -1 |
| half a session is an error | 380, 1.5, 2025-10-01 | → | error: missedSessions must be a whole number from 0 to possibleSessions (380), received 1.5 |
| no possible sessions is an error | 0, 0, 2025-10-01 | → | error: possibleSessions must be a whole number of 1 or more, received 0 |
| an impossible date is an error | 380, 38, 2025-02-30 | → | error: "2025-02-30" is not a real calendar date |
More from the author
- **Persistent absence**: missing 10% or more of the pupil's own possible sessions. This is the definition from the 2015/16 academic year. It was introduced in 2005/06 at 20%, and from 2010/11 was about 15% measured against a standard number of sessions rather than the pupil's own, which cannot be expressed as a percentage of one pupil's sessions; a date before 1 September 2015 is therefore refused rather than answered with today's rule. - **Severe absence**: missing 50% or more. The data carries it from the 2018/19 academic year, the first year for which the DfE statistics release reports it; before 1 September 2018 `severelyAbsent` is null.
Both are dated rows in `data/absence-thresholds.json`, and `onDate` picks the row in force.
## Decisions
- **The flags use the exact fraction.** A pupil who missed 250 of 2,501 sessions has an absence rate of 9.996%, which rounds to 1000 basis points, but is not persistently absent: 250 is less than 10% of 2,501. Comparing the rounded rate is the naive mistake; a vector pins it. - **Absence is rounded, attendance is derived.** Absence is rounded half-up to a basis point and attendance is 10000 minus that, so the two always add up to 100%. (Rounding both half-up gives 10001 when each sits on a half.) - Sessions are whatever the school counts: two per day (morning and afternoon) in England. Missed sessions include authorised and unauthorised absence, as the DfE measures do; pass only the sessions you want counted. - No possible sessions is an error: there is no rate to report.
## Sources
- Department for Education, "Pupil absence statistics: methodology" (Explore education statistics): definitions of persistent absence (10% or more of a pupil's own possible sessions, from 2015/16) and severe absence (50% or more), and the history of the persistent absence threshold: https://explore-education-statistics.service.gov.uk/methodology/pupil-absence-statistics-methodology - Department for Education, "Pupil absence in schools in England: 2018/19", the first release reporting severe absence: https://explore-education-statistics.service.gov.uk/find-statistics/pupil-absence-in-schools-in-england/2018-19
## 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 | 188 |
| README.md | 2,744 |
| data/absence-thresholds.json | 477 |
| impl/python.py | 2,516 |
| impl/rust.rs | 3,537 |
| impl/typescript.ts | 2,332 |
| vectors.json | 4,449 |