06 · Compliance & Retention in Data Lakes¶
Regulations like GDPR and CCPA give individuals concrete rights over data about them — to see it, to have it deleted, to know how long it's kept — and a data lake's append-friendly, copy-heavy nature makes honoring those rights harder than it sounds. This module builds the mechanics: retention policies, right-to-erasure across a lakehouse's versioned history, and audit-ready deletion records.
What actually ran
This module was reasoned through step by step against real pandas,
pyarrow, and sqlite3 APIs but not executed in a live interpreter for
this lesson — the outputs shown match documented behavior precisely.
Retention policy as data, not tribal knowledge¶
import sqlite3
import pandas as pd
import datetime as dt
policy_db = sqlite3.connect(":memory:")
policy_db.execute("""
CREATE TABLE retention_policies (
dataset TEXT PRIMARY KEY,
retention_days INTEGER,
legal_basis TEXT
)
""")
policy_db.executemany(
"INSERT INTO retention_policies VALUES (?, ?, ?)",
[
("raw.clickstream", 90, "operational necessity"),
("silver.orders", 2555, "financial record-keeping (7 years)"),
("bronze.support_chats", 365, "customer support / GDPR minimization"),
],
)
policy_db.commit()
print(pd.read_sql("SELECT * FROM retention_policies", policy_db))
dataset retention_days legal_basis
0 raw.clickstream 90 operational necessity
1 silver.orders 2555 financial record-keeping (7 years)
2 bronze.support_chats 365 customer support / GDPR minimization
Different datasets have different legal bases for how long they can be kept — financial records typically must be retained for years (a regulatory floor), while raw clickstream data should often be deleted promptly once it's no longer operationally needed (a minimization ceiling). Both directions are compliance requirements.
Applying retention: what should be purged today?¶
def rows_past_retention(df: pd.DataFrame, date_col: str, retention_days: int, as_of: dt.date) -> pd.DataFrame:
cutoff = as_of - dt.timedelta(days=retention_days)
df = df.copy()
df[date_col] = pd.to_datetime(df[date_col]).dt.date
return df[df[date_col] < cutoff]
clickstream = pd.DataFrame({
"event_id": [1, 2, 3],
"event_date": ["2026-01-01", "2026-07-01", "2026-08-25"],
})
expired = rows_past_retention(clickstream, "event_date", retention_days=90, as_of=dt.date(2026, 8, 31))
print(expired)
Only event 1 (January) is past the 90-day window as of August 31st — this is the query a scheduled retention-enforcement job runs nightly against every dataset with a policy.
Right to erasure across a versioned lakehouse table¶
Deleting a row from the current view isn't enough — Module 07 (Level 3) showed that time travel keeps old versions queryable. A real erasure request must also account for history.
def erase_subject(current_table: pd.DataFrame, versions_history: list[pd.DataFrame], subject_id, id_col="customer_id"):
"""Physically remove a subject's rows from the current table and rewrite
(not just soft-delete) every historical version that still contains them."""
cleaned_current = current_table[current_table[id_col] != subject_id]
cleaned_history = []
for version_df in versions_history:
if id_col in version_df.columns:
cleaned_history.append(version_df[version_df[id_col] != subject_id])
else:
cleaned_history.append(version_df)
return cleaned_current, cleaned_history
current = pd.DataFrame({"customer_id": [1, 2, 3], "amount": [10, 20, 30]})
v0 = pd.DataFrame({"customer_id": [1, 2], "amount": [10, 20]})
v1 = pd.DataFrame({"customer_id": [1, 2, 3], "amount": [10, 20, 30]})
new_current, new_history = erase_subject(current, [v0, v1], subject_id=2)
print(new_current)
for i, v in enumerate(new_history):
print(f"version {i}:\n{v}")
customer_id amount
0 1 10
2 3 30
version 0:
customer_id amount
0 1 10
version 1:
customer_id amount
0 1 10
1 3 30
This is why erasure on a time-traveling lakehouse table must rewrite
historical files, not just add a new commit that filters the subject out —
otherwise read_table_at(version=0) would still surface the erased
subject's data forever, defeating the purpose. This is exactly what a real
VACUUM/expire-history operation combined with a deletion rewrite
accomplishes on Delta/Iceberg tables.
Recording that erasure happened, without keeping what was erased¶
erasure_log = sqlite3.connect(":memory:")
erasure_log.execute("""
CREATE TABLE erasure_requests (
subject_id TEXT,
requested_at TEXT,
completed_at TEXT,
datasets_affected TEXT,
rows_removed INTEGER
)
""")
def log_erasure(erasure_log, subject_id, datasets_affected: list[str], rows_removed: int):
now = dt.datetime.now().isoformat(timespec="seconds")
erasure_log.execute(
"INSERT INTO erasure_requests VALUES (?, ?, ?, ?, ?)",
(str(subject_id), now, now, ",".join(datasets_affected), rows_removed),
)
erasure_log.commit()
log_erasure(erasure_log, subject_id=2, datasets_affected=["silver.orders"], rows_removed=2)
print(pd.read_sql("SELECT * FROM erasure_requests", erasure_log))
subject_id requested_at completed_at datasets_affected rows_removed
0 2 2026-08-31T00:00:00 2026-08-31T00:00:00 silver.orders 2
The audit trail records that subject 2 was erased, when, and how many rows were affected — without storing the erased data itself, which would defeat the erasure. This log is what you show a regulator or auditor as proof of compliance.
Traps¶
- Treating a soft-delete flag as erasure. Setting
is_deleted = Trueon a row satisfies neither GDPR's erasure right nor most storage-cost goals — the underlying bytes are still there. Erasure means the data is actually gone, including from backups within a reasonable time and from lakehouse history. - No retention policy for derived/aggregated tables. A Gold table computed from Silver can still contain individually-identifiable aggregates (e.g., a per-customer lifetime total) — retention and erasure need to be traced through lineage (Module 02), not just applied to raw tables.
- Confusing "must delete" with "must retain." Applying a blanket deletion policy to financial records that a regulator requires you to keep for 7 years is itself a compliance violation — retention floors and minimization ceilings both need explicit policy, as modeled above.
Cheat sheet¶
| Requirement | Mechanism |
|---|---|
| "How long can/must we keep this?" | retention_policies table per dataset |
| Nightly purge of expired data | rows_past_retention() on a schedule |
| Right to erasure (including history) | Rewrite current + all historical versions, not soft-delete |
| Proof of compliance | erasure_requests audit log, without the erased data itself |
How It Actually Works¶
Retention and deletion compliance in a lake/lakehouse runs directly into the tension between immutable, append-oriented storage and legally required deletion of specific records (GDPR/CCPA "right to erasure") — and the mechanism for resolving that tension differs sharply between raw lakes and lakehouse table formats.
In a raw lake, a specific customer's records are typically scattered across many Parquet files (partitioned by date, not by customer), and Parquet provides no mechanism to delete a subset of rows from an existing file in place — deleting one customer's rows means locating every file containing any of their records, reading each one back into memory, filtering out the target rows, and rewriting the remainder as new files, then deleting the originals. At scale, "locate every file containing this customer" is itself a real engineering problem without an index — which is why lakes built for compliance from the start often maintain a secondary index (a customer-ID-to-file-list mapping) purely to make deletion requests tractable.
Lakehouse formats make this mechanically cleaner via DELETE FROM table WHERE customer_id =
:id, executed as a real transaction-log operation: the engine identifies which existing
files contain matching rows (using the same file-level statistics used for query pruning —
if a file's customer_id min/max range excludes the target ID, it's skipped entirely),
rewrites only the affected files with those rows removed, and commits a new log entry marking
old files removed and new files added — the same copy-on-write mechanism compaction uses,
just triggered by a delete predicate instead of a size-based bin-packing policy. The
subtlety compliance teams must account for is that, exactly as in time-travel, the original
file (containing the "deleted" data) is not immediately purged from storage — it persists
until vacuum's retention window expires, meaning a table isn't actually GDPR-compliant purely
by virtue of running a DELETE; retention/vacuum policy has to be tightened and executed too,
and any time-travel or snapshot export taken before vacuum runs can still expose the
"deleted" data.
Exercise¶
Extend erase_subject to also walk lineage (reusing downstream_of from
Module 02) and return the list of downstream tables that need their own
erasure pass because they were derived from the erased subject's rows —
e.g., if silver.orders feeds gold.customer_lifetime_value, erasing a
customer from Silver means the Gold aggregate is now stale and must be
recomputed or have that customer's contribution removed too.