GDPR Retention Policies for Reservation Data
The right-to-erasure request that lands after a guest checks out puts two obligations in direct tension: GDPR says delete the personal data, and tax law says keep the invoice for years. Resolve it the wrong way and you either erase a financial record you were legally required to retain, or you keep a guest’s email long past any lawful basis to hold it. This page implements the reconciliation — lawful-basis-driven time-to-live, pseudonymization over deletion where the audit trail needs the row to survive, and a Python sweeper that enforces both — as the data-protection companion to the compliance and audit logging workflow that owns the immutable financial trail. The reader is the engineer who has to make erasure and retention coexist without corrupting either record.
The key realization is that “a reservation” is not one retention object. It is a bundle of fields with different lawful bases and different clocks: the guest’s name and email are held on consent or contract necessity and expire soon after stay; the invoice total, tax, and the rate that was published are held on a legal obligation and must survive for the statutory period. Erasure operates on the first group; the second group is pseudonymized so it stays aggregatable for revenue reporting without identifying anyone.
Prerequisites & environment
The sweeper and erasure handler run against the operational reservation store, coordinated with the immutable audit sink. Pin these:
- Python 3.11+ — for
datetimetimezone handling and modern type hints. pydantic2.6+ — the retention policy and reservation contracts are v2 models (field_validator,model_dump), so a field’s lawful basis and TTL are validated data, not scattered constants.polars1.x — the daily sweep evaluates TTLs across a full reservation roster as a vectorized pass rather than a per-row Python loop.structlog24.x — every erasure and pseudonymization is itself logged as a key=value audit event keyed byreservation_id, because proving you honored an erasure request is part of compliance.- A field-level classification — each reservation field tagged with its lawful basis and retention days, maintained under version control as the source of truth for what expires when.
- Access to the WORM audit sink — read-only, to confirm the financial record is preserved before personal fields are erased from the operational store.
The hard rule: erasure of guest PII must never delete or mutate a record in the immutable audit trail. The audit chain is append-only; you cannot reach back into it. Instead, the audit records are written already-pseudonymized, so erasing the operational PII leaves nothing identifying in the permanent store to begin with.
Step-by-step implementation
Step 1 — Classify every field by lawful basis and TTL
Retention is a property of each field, not the record. Encode the classification as data: the lawful basis, the number of days to retain past the stay, and whether expiry means deletion or pseudonymization.
from enum import Enum
from pydantic import BaseModel, Field
class LawfulBasis(str, Enum):
CONSENT = "consent"
CONTRACT = "contract"
LEGAL_OBLIGATION = "legal_obligation"
class ExpiryAction(str, Enum):
ERASE = "erase"
PSEUDONYMIZE = "pseudonymize"
class FieldPolicy(BaseModel):
field_name: str
basis: LawfulBasis
retain_days: int = Field(ge=0) # days after checkout the field may be held
on_expiry: ExpiryAction
RESERVATION_POLICY = [
FieldPolicy(field_name="guest_name", basis=LawfulBasis.CONTRACT, retain_days=90, on_expiry=ExpiryAction.ERASE),
FieldPolicy(field_name="guest_email", basis=LawfulBasis.CONSENT, retain_days=30, on_expiry=ExpiryAction.ERASE),
FieldPolicy(field_name="guest_phone", basis=LawfulBasis.CONTRACT, retain_days=90, on_expiry=ExpiryAction.ERASE),
FieldPolicy(field_name="invoice_total", basis=LawfulBasis.LEGAL_OBLIGATION, retain_days=2555, on_expiry=ExpiryAction.PSEUDONYMIZE),
FieldPolicy(field_name="published_rate", basis=LawfulBasis.LEGAL_OBLIGATION, retain_days=2555, on_expiry=ExpiryAction.PSEUDONYMIZE),
]
Driving retention from a version-controlled policy list rather than hard-coded if branches means a change to a lawful basis — say, dropping email retention from 30 to 14 days after a DPA ruling — is a reviewed data edit, not a code change buried in the sweeper’s logic.
Step 2 — Pseudonymize by replacing the guest key with a salted hash
Financial rows must stay linkable for aggregate reporting (revenue by segment, ADR by month) without identifying the guest. Replace the guest identifier with a salted SHA-256 so the same guest maps to a stable pseudonym within a reporting scope, but the original identity cannot be recovered.
import hashlib
def pseudonymize_guest(guest_id: str, salt: bytes) -> str:
# Salt is stored separately from the data store; without it the hash is not reversible.
return "gx_" + hashlib.sha256(salt + guest_id.encode()).hexdigest()[:24]
def apply_pseudonymization(reservation: dict, salt: bytes) -> dict:
pseudonym = pseudonymize_guest(reservation["guest_id"], salt)
return {**reservation,
"guest_id": pseudonym,
"guest_name": None, "guest_email": None, "guest_phone": None}
Keeping the salt in a separate secret store rather than beside the data is what makes this pseudonymization and not reversible encoding: an attacker who exfiltrates the reservation table alone cannot re-link the hashes to real guests, which is the distinction GDPR draws between pseudonymized and merely obfuscated data.
Step 3 — Run the retention sweeper as a vectorized daily pass
The sweeper computes each field’s expiry date from checkout plus retain_days and selects the reservations whose fields are due. Doing it as a Polars pass over the whole roster keeps a nightly job over hundreds of thousands of reservations fast and side-effect-free until the action step.
import polars as pl
from datetime import date
def find_expired(reservations: pl.DataFrame, policy: list[FieldPolicy],
today: date) -> dict[str, pl.DataFrame]:
due: dict[str, pl.DataFrame] = {}
for fp in policy:
expiry = pl.col("checkout_date") + pl.duration(days=fp.retain_days)
overdue = reservations.filter(expiry <= pl.lit(today))
if overdue.height:
due[fp.field_name] = overdue.select("reservation_id", "guest_id")
return due
Separating finding expired fields from acting on them lets the sweep run in a dry-run mode that reports what would be erased before anything is destroyed — essential when the operation is irreversible and a wrong retain_days could wipe live guest data prematurely.
Step 4 — Handle a right-to-erasure request without breaking the audit trail
An explicit erasure request erases the personal fields immediately and pseudonymizes the financial ones, then writes a structured record proving the request was honored — because demonstrating compliance is itself a GDPR obligation.
import structlog
audit = structlog.get_logger("audit")
def erase_guest(reservation: dict, salt: bytes) -> dict:
# 1. Pseudonymize financial fields so revenue records survive, identity does not.
result = apply_pseudonymization(reservation, salt)
# 2. Emit an erasure-audit event — the proof, itself free of the erased PII.
audit.info("guest.erased",
reservation_id=reservation["reservation_id"],
pseudonym=result["guest_id"],
basis="right_to_erasure",
fields_erased=["guest_name", "guest_email", "guest_phone"])
return result
The erasure-audit event records the pseudonym and the fields cleared but never the erased values themselves, so the act of documenting compliance does not quietly re-introduce the very personal data the request asked you to remove.
Gotchas & production notes
- Never issue an erasure
DELETEagainst the immutable audit sink. The WORM store is append-only by design and rightly rejects deletes; an erasure workflow that tries to reach into it is both technically impossible and conceptually wrong. Keep audit records pseudonymized at write time so there is no personal data there to erase, as the audit-log schema already assumes. - Legal obligation outranks consent — keep the invoice. When a guest withdraws consent, you still retain the financial record under a separate lawful basis. Erase the contact fields, pseudonymize the transactional ones, and be able to explain in the audit trail exactly which basis kept which field. Blanket-deleting the whole reservation on an erasure request is itself a compliance failure.
- Booking timezone versus retention clock.
retain_dayscounts from checkout, but a reservation store often holds local property time while the sweeper runs in UTC. Normalizecheckout_dateto the property’s timezone before adding the retention window, or a property just west of UTC can have a field erased up to a day early. - Pseudonymization is only as strong as salt hygiene. If the salt leaks or is stored alongside the data, the hashes become reversible and you are back to holding identifiable data past its lawful basis. Rotate salts on a schedule and store them in the same secret manager that holds the pipeline’s OAuth credentials.
Verification snippet
Confirm the two guarantees regulators care about: expired personal fields are actually erased, and the financial record survives in pseudonymized form.
from datetime import date
def test_erasure_clears_pii_but_keeps_finance():
reservation = {
"reservation_id": "RES-88421", "guest_id": "guest_7731",
"guest_name": "J. Brown", "guest_email": "[email protected]",
"guest_phone": "+44...", "invoice_total": "540.00",
"published_rate": "180.00", "checkout_date": date(2026, 4, 1),
}
result = erase_guest(reservation, salt=b"unit-test-salt")
assert result["guest_name"] is None and result["guest_email"] is None
assert result["invoice_total"] == "540.00" # financial record preserved
assert result["guest_id"].startswith("gx_") # linkable but not identifying
def test_pseudonym_is_stable_for_same_guest():
a = pseudonymize_guest("guest_7731", b"same-salt")
b = pseudonymize_guest("guest_7731", b"same-salt")
assert a == b # aggregatable across the report
assert a != pseudonymize_guest("guest_7731", b"other-salt")
test_erasure_clears_pii_but_keeps_finance()
test_pseudonym_is_stable_for_same_guest()
The first test encodes the entire balancing act in three assertions — contact fields gone, invoice intact, guest key pseudonymized — so a regression that either over-erases the financial record or under-erases the PII fails loudly. In production, also assert the sweeper’s dry-run count matches the acted-on count, so no expired field is silently skipped and none is erased that the policy did not select.
Related
- Compliance & Audit Logging for Sync Pipelines — the parent workflow: the immutable financial trail this retention policy preserves.
- Designing immutable audit-log schemas — why audit records are written pseudonymized so erasure never has to touch them.
- Sanitizing PCI cardholder data in sync logs — the sibling data-protection control for card data at the log boundary.
- Security & authentication boundaries — the secret manager that holds the pseudonymization salt and scoped credentials.
- Data schema standardization — the canonical field shapes the retention policy classifies.