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:

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.

Reservation fields split by lawful basis into erase and pseudonymize paths A reservation record fans into two groups. Personal-data fields — guest name, email, phone — are held on consent or contract necessity with a short TTL and flow to an erasure path that deletes them after the retention window. Financial fields — invoice total, tax, published rate — are held on legal obligation with a multi-year TTL and flow to a pseudonymization path that replaces the guest key with a salted hash and keeps the row for revenue reporting. Both paths emit a structured erasure-audit event, and the immutable audit sink is drawn receiving only pseudonymized records. One reservation, two retention clocks Personal fields expire on a short lawful-basis TTL; financial fields survive pseudonymized for the statutory period. Reservation field-level tagged Personal data — consent / contract name · email · phone → erase after short TTL Financial — legal obligation total · tax · rate → pseudonymize, keep years Immutable audit pseudonymized only each action emits a structured erasure-audit event — proving erasure is itself a compliance record
Figure 1: a reservation splits into a personal-data group erased on a short lawful-basis TTL and a financial group pseudonymized and retained for the statutory period, with only pseudonymized records ever reaching the immutable audit sink.

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.

python
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.

python
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.

python
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.

python
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

Verification snippet

Confirm the two guarantees regulators care about: expired personal fields are actually erased, and the financial record survives in pseudonymized form.

python
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.

← Back to Compliance & Audit Logging for Sync Pipelines