Enforcing FERPA Retention Schedules in Patron Records
This page answers one narrow operational question: why do FERPA-governed student patron records — and the circulation trails attached to them — sit fully populated years after the student has left, and how do you enforce automated purging so that no record outlives the retention window your policy already defines? It sits under Patron Consent and Right-to-Erasure Management, which covers the consent and erasure lifecycle end to end, and within the broader Patron Validation & Privacy Data Routing architecture. If you run an academic or K-12 library whose borrower accounts are provisioned from a campus Student Information System (SIS), the graveyard of never-purged records below is one you will eventually discover during an audit.
Problem Framing
The symptom is not a crash. Circulation runs fine, patron lookups return correct results, and no exception fires. The problem is what is still there: student accounts and their full checkout history for people who graduated or withdrew years ago, long past the window your retention policy says they should have been destroyed. Nobody notices until an auditor, a records-retention review, or a subpoena asks the library to prove it purges on schedule — and a single query shows it does not.
The evidence is a count of records whose separation date is older than the retention window, still fully populated:
$ psql -c "SELECT patron_class, count(*) AS overdue \
FROM patron_record r \
JOIN patron_separation s USING (patron_id) \
WHERE r.purged_at IS NULL \
AND s.separation_date < now() - interval '5 years' \
GROUP BY patron_class"
patron_class | overdue
--------------+---------
student | 4127 <-- past a 5-year window, never purged
alumni | 88
(2 rows)
Four thousand student records, each with an intact circulation trail, sitting years past the point where FERPA-aligned policy required their destruction. The purged_at column is null on every one of them because nothing ever set it. The separation dates arrived faithfully from the SIS; no process ever acted on them.
Root Cause
The failure is that retention was written as policy but never encoded as a job. The rule — “destroy student patron records and their circulation history N years after separation” — lives in a records-retention schedule document, a PDF in a shared drive, or a line in a data-governance policy. It does not live anywhere in code. No scheduled task reads a separation date, adds the window, and compares the result to today.
Two contributing conditions make the gap invisible. First, the SIS does deliver separation events — a separation_date when a student graduates or withdraws — but the patron pipeline stores that date and moves on; the date is data nobody consumes. Second, retention is a deletion obligation, and deletion is the one operation nobody’s dashboard rewards. A missing record raises an alarm; a record that should be missing but is present raises none. So the pile grows silently, one term at a time, until an external review forces a reckoning.
The fix is to make the schedule executable: turn the retention window into a typed policy object, compute each record’s purge-due date deterministically from its separation date, and run an enforcer on a schedule that acts on every record whose due date has passed. This is the same disposition-per-record discipline the parent Patron Consent and Right-to-Erasure Management cluster applies to on-demand erasure requests — here it runs on a clock rather than on a request.
Solution
Encode the retention schedule as a typed policy keyed by patron class, compute the purge-due date as separation_date + window, and run an enforcer that purges or anonymizes any record at or past its due date and records the action. The enforcer is idempotent — a record already purged is skipped, so re-running the job is safe — and it fails per record rather than aborting the batch, so one problematic row cannot block the rest.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Protocol
logger = logging.getLogger("ferpa.retention")
class RetentionError(Exception):
"""Raised when a record cannot be evaluated or purged safely."""
@dataclass(frozen=True)
class RetentionPolicy:
"""A governing schedule: how long a patron class is retained past separation."""
patron_class: str
window: timedelta
def purge_due(self, separation: date) -> date:
"""The date on or after which this record must be purged."""
return separation + self.window
# The retention schedule, expressed in code rather than in a policy document.
RETENTION_SCHEDULE: dict[str, RetentionPolicy] = {
"student": RetentionPolicy("student", timedelta(days=5 * 365)),
"alumni": RetentionPolicy("alumni", timedelta(days=7 * 365)),
}
@dataclass(frozen=True)
class PatronRecord:
patron_id: str
patron_class: str
separation_date: date | None
purged_at: date | None
class RetentionStore(Protocol):
def due_records(self, as_of: date) -> list[PatronRecord]: ...
def anonymize(self, patron_id: str) -> None: ...
def record_audit(self, patron_id: str, action: str, due: date) -> None: ...
def enforce_retention(store: RetentionStore, *, as_of: date | None = None) -> int:
"""Purge every record whose FERPA retention window has closed.
Returns the number of records purged. Idempotent: already-purged records
are skipped, so the job is safe to re-run. A failure on one record is
logged and quarantined without aborting the batch.
"""
as_of = as_of or date.today()
purged = 0
for record in store.due_records(as_of):
if record.purged_at is not None:
continue # idempotent: already handled on an earlier run
if record.separation_date is None:
# No separation date means the clock has not started; keep and log.
logger.info(
"retention_no_separation",
extra={"patron_id": record.patron_id, "action": "keep"},
)
continue
policy = RETENTION_SCHEDULE.get(record.patron_class)
if policy is None:
raise RetentionError(f"no policy for class {record.patron_class!r}")
due = policy.purge_due(record.separation_date)
if as_of < due:
continue # not yet due; re-checked on the next run
try:
store.anonymize(record.patron_id)
except Exception as exc: # noqa: BLE001 - isolate per-record failure
logger.error(
"retention_purge_failed",
extra={"patron_id": record.patron_id, "due": due.isoformat()},
)
raise RetentionError(record.patron_id) from exc
store.record_audit(record.patron_id, action="purge", due=due)
logger.info(
"retention_purged",
extra={
"patron_id": record.patron_id,
"patron_class": record.patron_class,
"due": due.isoformat(),
"action": "purge",
},
)
purged += 1
return purged
The behavioural change is that the schedule now executes. Before, a separation_date sat in the row inert; after, purge_due turns it into a concrete deadline and the enforcer acts the moment as_of reaches it. anonymize is deliberately named over delete because the circulation trail usually cannot be dropped outright — aggregate collection statistics depend on it — so the operation strips the identifying columns and severs the link to the student while leaving an anonymized event for reporting. The audit write is not optional: every purge produces a durable record of what was destroyed and which due date justified it, which is exactly what an auditor asks for. Scheduling the enforcer itself — running it nightly under a worker — is covered in Automating Retention Schedule Enforcement with Celery Beat.
Compliance or Privacy Impact
Automating the enforcer changes the retention posture in three concrete ways, and each has a downstream effect worth tracking.
- The schedule becomes provable, not aspirational. Because every purge writes an audit entry carrying the computed due date, the library can answer “show me that student records are destroyed on schedule” with a query rather than an assurance. The governing windows and the classes they apply to should trace back to Data Retention Policies for Public Libraries, which defines what the schedule is; this page is only the mechanism that carries it out.
- Purge and mask are different obligations. Anonymizing a record at its retention deadline destroys it going forward, but any export that already left the building is out of reach. Retention enforcement therefore complements — it does not replace — the redaction applied on the way out in PII Masking in Patron Data Exports; the first bounds how long you hold the data, the second bounds what leaves while you hold it.
- Retention interacts with erasure. A right-to-erasure request destroys one record on demand; the retention enforcer destroys many on schedule. They share the same anonymize path and the same audit sink, and they can race: a record purged by the enforcer must be treated as already-erased if an erasure request arrives afterward, and vice versa. The idempotent
purged_atcheck above is what keeps that race safe. The on-demand side is detailed in Implementing GDPR Right to Erasure in Circulation Data.
The enforcer narrows the retention surface rather than widening it, but it shifts risk onto the correctness of RETENTION_SCHEDULE and the separation dates feeding it. A window mistakenly set too long keeps records that should be gone; a window set too short destroys records prematurely and irreversibly. Changes to the schedule map deserve code review and the verification below.
Verification
Confirm the enforcer purges exactly the records at or past their due date, leaves the rest untouched, and is safe to re-run.
from datetime import date, timedelta
class FakeStore:
def __init__(self, records: list[PatronRecord]) -> None:
self._records = {r.patron_id: r for r in records}
self.audits: list[tuple[str, str, date]] = []
def due_records(self, as_of: date) -> list[PatronRecord]:
return list(self._records.values())
def anonymize(self, patron_id: str) -> None:
r = self._records[patron_id]
self._records[patron_id] = PatronRecord(
r.patron_id, r.patron_class, r.separation_date, purged_at=date.today()
)
def record_audit(self, patron_id: str, action: str, due: date) -> None:
self.audits.append((patron_id, action, due))
def test_purges_only_overdue_records() -> None:
today = date(2026, 7, 16)
overdue = PatronRecord("p1", "student", today - timedelta(days=6 * 365), None)
current = PatronRecord("p2", "student", today - timedelta(days=365), None)
store = FakeStore([overdue, current])
purged = enforce_retention(store, as_of=today)
assert purged == 1 # only the overdue record
assert store.audits == [("p1", "purge", date(2021, 7, 17))]
def test_enforcer_is_idempotent() -> None:
today = date(2026, 7, 16)
overdue = PatronRecord("p1", "student", today - timedelta(days=6 * 365), None)
store = FakeStore([overdue])
first = enforce_retention(store, as_of=today)
second = enforce_retention(store, as_of=today) # re-run same day
assert first == 1
assert second == 0 # already purged, skipped
assert len(store.audits) == 1 # no duplicate audit entry
For a running pipeline, add a continuous invariant on the patron table itself: periodically re-run the overdue query from Problem Framing and assert the count is zero. If it is ever nonzero, the enforcer is not keeping up — a schedule gap, a class with no policy, or a batch of records with a null separation date — and the batch should alert rather than pass silently. Track the purge count per run over time; a sudden spike after a term rollover is expected, while a run that purges nothing when overdue records exist is your signal that the enforcer stalled.
Related
- Patron Consent and Right-to-Erasure Management — the parent guide to the consent and erasure lifecycle this scheduled enforcer runs alongside.
- Patron Validation & Privacy Data Routing — the privacy and routing architecture that governs where patron records may flow and how long they may persist.
- Implementing GDPR Right to Erasure in Circulation Data — the on-demand counterpart that shares the same anonymize path and audit sink.
- Data Retention Policies for Public Libraries — where the governing windows this enforcer executes are defined.