Implementing GDPR Right-to-Erasure in Circulation Data
This page answers one narrow operational question: a patron files a GDPR Article 17 “right to erasure” request, you delete their row from the patron table, the ticket is closed — and weeks later their personal data is still sitting in circulation history, active holds, a derived analytics warehouse, and last night’s backup. A single-table delete does not satisfy erasure when personal data has been denormalized across a sync pipeline. This page 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 any ILS whose circulation events are replicated downstream for reporting, the leak below is one you will eventually trigger the first time a patron exercises their rights.
Problem Framing
The symptom is a closed ticket that did not actually close anything. The service desk receives an Article 17 request, an admin runs DELETE FROM patron WHERE patron_id = 44182, the row disappears, and the case is marked resolved. No error fires. The patron is gone from every screen a librarian looks at.
The data is not gone from the pipeline. A single query across the downstream stores shows the “erased” patron is still fully present:
$ psql -c "SELECT 'circulation' AS store, count(*) FROM circulation_log WHERE patron_id = 44182
UNION ALL
SELECT 'holds', count(*) FROM hold_queue WHERE patron_id = 44182
UNION ALL
SELECT 'analytics', count(*) FROM warehouse.fact_loans WHERE patron_key = 44182;"
store | count
-------------+-------
circulation | 612 <-- name, email, checkout timestamps still present
holds | 3 <-- pickup branch + hold history still present
analytics | 612 <-- replicated rows in the reporting warehouse
The patron table was only the entry point. Every checkout wrote a row into circulation_log that denormalized the patron’s name and email for reporting convenience; the nightly ETL copied those rows into warehouse.fact_loans; and the hold queue kept its own copy of contact details for pickup notices. Deleting the one authoritative row left every derived copy untouched — and under GDPR each of those copies is personal data the controller is still processing.
Root Cause
The failure is that circulation data is denormalized and replicated downstream, and a delete does not propagate. In a normalized schema, a foreign-key ON DELETE CASCADE would clean up child rows, but circulation pipelines deliberately break normalization: they copy the patron’s name, email and pickup branch into each event so that reports and notices do not need a live join back to the patron table. Those copies live in separate systems — an analytics warehouse, a search index, a notifications queue — that have no foreign key to the patron table at all, and often no synchronous link to it. A DELETE on the source is simply never observed by them.
There is a second, sharper tension. One of those downstream stores is an append-only audit log whose whole legal purpose is to be immutable — you cannot delete from it without destroying the integrity guarantee that makes it admissible. GDPR itself recognizes this: Article 17(3) exempts data the controller must retain for a legal obligation. So the naive answer of “delete everywhere” is both incomplete (it misses the replicas) and illegal in one place (it would gut the audit log). Erasure has to be modelled as a coordinated operation with a per-store strategy, not a single statement.
The fix is to invert the assumption that the patron table is the only place personal data lives. Every store that ever received a copy must be enrolled as an erasure target with its own disposition, exactly the disposition-per-target discipline the parent Patron Consent and Right-to-Erasure Management cluster defines.
Solution
Model erasure as a request that fans out to every enrolled target, applies the correct strategy per store, and verifies each one before the request is considered satisfied. Hard-deletable stores are deleted; the circulation log is anonymized in place by nulling personal columns while keeping the row for referential and statistical integrity; and the append-only audit log — which cannot be deleted — is given a hashed tombstone that proves the erasure occurred without retaining any personal data. The whole operation is idempotent, so a retry after a partial failure converges rather than double-processing.
from __future__ import annotations
import hashlib
import hmac
import logging
from dataclasses import dataclass, field
from typing import Protocol
logger = logging.getLogger("gdpr.erasure")
class ErasureError(RuntimeError):
"""A target could not complete erasure; the request stays open for retry."""
@dataclass(frozen=True)
class ErasureRequest:
patron_id: int
request_ref: str # the Article 17 case reference
tombstone_secret: bytes # keyed hash so the id cannot be brute-forced
class ErasureTarget(Protocol):
name: str
def erase(self, req: ErasureRequest) -> None:
"""Apply this store's erasure strategy. Must be idempotent."""
def still_present(self, req: ErasureRequest) -> bool:
"""True if any personal data for the patron remains in this store."""
@dataclass
class ErasureCoordinator:
targets: list[ErasureTarget]
_completed: set[str] = field(default_factory=set)
def run(self, req: ErasureRequest) -> None:
pending = [t for t in self.targets if t.name not in self._completed]
for target in pending:
try:
target.erase(req)
except Exception as exc: # noqa: BLE001 - re-raised as ErasureError below
logger.error(
"erasure_target_failed",
extra={"target": target.name, "request_ref": req.request_ref},
)
raise ErasureError(f"{target.name} failed erasure") from exc
self._completed.add(target.name)
logger.info(
"erasure_target_done",
extra={"target": target.name, "request_ref": req.request_ref},
)
self._verify(req)
def _verify(self, req: ErasureRequest) -> None:
leaked = [t.name for t in self.targets if t.still_present(req)]
if leaked:
logger.error(
"erasure_verification_failed",
extra={"request_ref": req.request_ref, "leaked_targets": leaked},
)
raise ErasureError(f"data still present in: {', '.join(leaked)}")
logger.info(
"erasure_complete",
extra={"request_ref": req.request_ref, "targets": len(self.targets)},
)
Each store implements the ErasureTarget contract with the strategy its schema demands. The hard-delete stores drop rows; the circulation log nulls personal columns; the audit log writes a tombstone instead of deleting.
PII_COLUMNS = ("patron_name", "patron_email", "patron_phone")
@dataclass
class CirculationLogTarget:
"""Anonymize in place: keep the loan rows, remove the personal data."""
name: str = "circulation_log"
conn: "psycopg.Connection" = None # injected
def erase(self, req: ErasureRequest) -> None:
set_clause = ", ".join(f"{col} = NULL" for col in PII_COLUMNS)
with self.conn.cursor() as cur:
cur.execute(
f"UPDATE circulation_log SET {set_clause} WHERE patron_id = %s",
(req.patron_id,),
)
self.conn.commit()
def still_present(self, req: ErasureRequest) -> bool:
cond = " OR ".join(f"{col} IS NOT NULL" for col in PII_COLUMNS)
with self.conn.cursor() as cur:
cur.execute(
f"SELECT 1 FROM circulation_log "
f"WHERE patron_id = %s AND ({cond}) LIMIT 1",
(req.patron_id,),
)
return cur.fetchone() is not None
@dataclass
class AuditLogTarget:
"""Append-only: cannot delete. Write a hashed tombstone instead."""
name: str = "audit_log"
conn: "psycopg.Connection" = None # injected
def _tombstone(self, req: ErasureRequest) -> str:
# Keyed hash: proves "this patron was erased" without storing the id.
return hmac.new(
req.tombstone_secret,
f"patron:{req.patron_id}".encode(),
hashlib.sha256,
).hexdigest()
def erase(self, req: ErasureRequest) -> None:
digest = self._tombstone(req)
with self.conn.cursor() as cur:
# Redact PII on historical entries; append one tombstone marker.
cur.execute(
"UPDATE audit_log SET patron_name = NULL, patron_email = NULL "
"WHERE patron_id = %s",
(req.patron_id,),
)
cur.execute(
"INSERT INTO audit_log (event, patron_hash, request_ref) "
"VALUES ('erasure_tombstone', %s, %s) "
"ON CONFLICT (patron_hash) DO NOTHING", # idempotent
(digest, req.request_ref),
)
self.conn.commit()
def still_present(self, req: ErasureRequest) -> bool:
with self.conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM audit_log "
"WHERE patron_id = %s "
"AND (patron_name IS NOT NULL OR patron_email IS NOT NULL) LIMIT 1",
(req.patron_id,),
)
return cur.fetchone() is not None
The behavioural change is that erasure is now a whole-pipeline operation. The coordinator only reports erasure_complete when every enrolled target has both applied its strategy and passed the still_present check — so a forgotten warehouse copy surfaces as a verification failure rather than a silent leak. The audit log keeps its immutability guarantee: instead of deleting rows, it redacts the personal columns and appends a keyed-hash tombstone that lets a future auditor confirm the erasure happened without the row ever revealing which patron it was. Because erase uses NULL-idempotent updates and an ON CONFLICT DO NOTHING insert, re-running the request after a crash converges to the same state.
Compliance or Privacy Impact
Erasure is a different obligation from retention expiry, and conflating them is the most common way libraries get this wrong.
- Erasure is event-driven; retention expiry is time-driven. An Article 17 request erases one identified patron now, on demand, regardless of how old their data is. Retention expiry deletes all data older than a policy window on a schedule, with no reference to any individual. They use overlapping mechanics but answer different legal questions, and a retention job running on a Celery Beat schedule will never satisfy an erasure request that arrives mid-window. Keep the two paths distinct and align the retention side with Data Retention Policies for Public Libraries.
- Nulling is anonymization, not deletion — and that is the point in the circulation log. Keeping the loan row with its personal columns nulled preserves aggregate statistics (loans per branch, per day) while removing the ability to attribute any loan to a person. This is the same anonymize-in-place technique the export path uses in PII Masking in Patron Data Exports; the erasure path simply applies it destructively rather than at read time.
- Backups have a grace window. You cannot rewrite an immutable backup on demand, and GDPR does not require you to. Record the erasure request, and ensure your restore procedure re-applies all pending tombstones so a restored patron is re-erased before the data is served again. Document the backup rotation window as the maximum time to full erasure.
The operation narrows the personal-data surface rather than widening it, but it shifts risk onto the completeness of the target list. A store that receives circulation copies but is not enrolled as an ErasureTarget is a silent leak with a closed ticket in front of it, so enrolling a new downstream consumer must include registering it here.
Verification
Confirm the erasure is complete with a test that seeds a patron across every store, runs the coordinator, and asserts that no store still reports the identifier while the audit tombstone exists.
def test_erasure_fans_out_and_verifies(coordinator, req, stores) -> None:
# Seed personal data in every enrolled target.
seed_patron(stores, patron_id=req.patron_id)
coordinator.run(req)
# No target retains any personal data for the patron.
for target in coordinator.targets:
assert not target.still_present(req), f"{target.name} still has PII"
# The append-only log kept a tombstone, not the identity.
tombstone = fetch_audit_tombstone(stores.audit, req.request_ref)
assert tombstone.event == "erasure_tombstone"
assert tombstone.patron_hash != str(req.patron_id) # opaque, not the id
assert len(tombstone.patron_hash) == 64 # sha256 hexdigest
def test_erasure_is_idempotent(coordinator, req, stores) -> None:
seed_patron(stores, patron_id=req.patron_id)
coordinator.run(req)
coordinator.run(req) # a retry must not raise or duplicate the tombstone
assert count_audit_tombstones(stores.audit, req.request_ref) == 1
For a running system, add a standing reconciliation query — the same shape as the diagnostic that exposed the leak — that periodically checks recently-erased request_refs against every store and alerts if any identifier reappears. A nonzero count after an ETL run is your early signal that a downstream job re-imported personal data from a stale source and the patron must be re-erased. Track the age of the oldest open erasure request against your backup rotation window; if it exceeds that window, a restore has silently resurrected erased data.
Related
- Patron Consent and Right-to-Erasure Management — the parent guide to the consent-and-erasure lifecycle and per-target disposition model this fan-out reuses.
- Enforcing FERPA Retention Schedules in Patron Records — the time-driven retention counterpart to this event-driven erasure path.
- Data Retention Policies for Public Libraries — how scheduled retention expiry differs from on-demand erasure.
- Patron Validation & Privacy Data Routing — the parent guide covering the full patron-data privacy and routing architecture.