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
attendanceRate(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 absentattendanceRate(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 absentattendanceRate(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.
export function attendanceRate(possibleSessions: number, missedSessions: number, onDate: string): AttendanceRate
| possibleSessions | int | sessions the pupil could have attended: two a day, morning and afternoon |
| missedSessions | int | sessions missed for any reason, authorised or not |
| onDate | 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
/** Attendance, absence and the DfE absence flags for one pupil. */
export interface AttendanceRate {
/** 10000 minus absenceBasisPoints */
readonly attendanceBasisPoints: number;
/** missed / possible, rounded half-up to a basis point */
readonly absenceBasisPoints: number;
/** missed 10% or more of possible sessions */
readonly persistentlyAbsent: boolean;
/** missed 50% or more; null on a date before that measure was published */
readonly severelyAbsent: boolean | null;
}
Your code names it in one line, in the file that uses it
import { attendanceRate } from "#fune/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.
import { parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { ABSENCE_THRESHOLDS, ABSENCE_THRESHOLDS_HISTORY, ABSENCE_THRESHOLDS_HORIZON } from "./education_attendance_rate_data.ts"; ← this capability’s own data, compiled from data/absence-thresholds.json into the same file by fune build
import { type AttendanceRate } from "./education_attendance_rate_types.ts";
function thresholdOn(measure: string, onDate: string): number | null {
for (const r of ABSENCE_THRESHOLDS) {
if (r.measure === measure && onDate >= r.validFrom && (r.validTo === null || onDate <= r.validTo)) return r.basisPoints;
}
return null;
}
/**
* 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.
*/
export function attendanceRate(possibleSessions: number, missedSessions: number, onDate: string): AttendanceRate {
if (!Number.isInteger(possibleSessions) || possibleSessions < 1) {
throw new RangeError(`possibleSessions must be a whole number of 1 or more, received ${possibleSessions}`);
}
if (!Number.isInteger(missedSessions) || missedSessions < 0 || missedSessions > possibleSessions) {
throw new RangeError(`missedSessions must be a whole number from 0 to possibleSessions (${possibleSessions}), received ${missedSessions}`);
}
parseIsoDate(onDate);
const persistent = thresholdOn("persistent", onDate);
if (persistent === null) {
if (ABSENCE_THRESHOLDS_HISTORY !== "full" && ABSENCE_THRESHOLDS_HORIZON !== null && onDate < ABSENCE_THRESHOLDS_HORIZON) {
throw new RangeError(
`no persistent absence definition on ${onDate}: this build was installed with history=${ABSENCE_THRESHOLDS_HISTORY}, so it only carries rules from ${ABSENCE_THRESHOLDS_HORIZON}. Reinstall with history=full for earlier years.`,
);
}
throw new RangeError(`no persistent absence definition on ${onDate}`);
}
const severe = thresholdOn("severe", onDate);
const absence = roundDiv(missedSessions * 10000, possibleSessions, "half-up");
return {
attendanceBasisPoints: 10000 - absence,
absenceBasisPoints: absence,
persistentlyAbsent: missedSessions * 10000 >= possibleSessions * persistent,
severelyAbsent: severe === null ? null : missedSessions * 10000 >= possibleSessions * severe,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, 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.attendance-rate
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./education.attendance-rate-1.0.1-typescript.fune, or fetch it from a terminal with fune pull education.attendance-rate@1.0.1:typescript.
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 |