Privacy law becomes a schema problem long before it becomes a legal one. Model retention, anonymise without breaking your reports, answer access and erasure requests in code, and keep an audit trail that stands up.
Most privacy work in a Django project is not legal work. It is schema design, a few management commands, and the discipline to decide — once, in writing — how long each kind of row may live. Do that and the legal questions answer themselves. Skip it and every request from a user turns into an archaeology project.
You cannot write a retention rule for data you cannot name. The first deliverable is a table of every model that holds personal data, why you hold it, and how long. Keep it next to the code so it rots less:
class Order(models.Model):
class Privacy:
personal_fields = ["email", "shipping_name", "shipping_address", "phone"]
basis = "contract" # why you may hold it
retention_days = 2555 # 7 years — tax law wins over erasure
anonymise_after_days = 730 # strip personal fields, keep the row
Three categories cover almost everything you have:
That last point matters. "We removed everything except your invoices, which we must keep for seven years under tax law" is a complete and lawful response. Deleting the invoice would be the mistake.
An inventory that lives in a wiki is out of date by the second sprint. Because the Privacy inner class sits on the model, you can test for it. The test below fails the build whenever someone adds a model with an obviously personal field but no declaration, which turns "remember to update the inventory" into a red CI run instead of a finding in next year's audit.
from django.apps import apps
from django.db import models
PERSONAL_HINTS = ("email", "phone", "name", "address", "ip", "birth")
# Models you do not own declare their privacy data here instead.
THIRD_PARTY_PRIVACY = {
"auth.User": {"personal_fields": ["email", "first_name", "last_name"],
"basis": "contract", "retention_days": None},
}
def looks_personal(field) -> bool:
if isinstance(field, (models.EmailField, models.GenericIPAddressField)):
return True
return any(hint in field.name for hint in PERSONAL_HINTS)
def test_every_personal_model_declares_privacy():
missing = []
for model in apps.get_models():
if model._meta.label in THIRD_PARTY_PRIVACY or hasattr(model, "Privacy"):
continue
personal = [f.name for f in model._meta.concrete_fields if looks_personal(f)]
if personal:
missing.append(f"{model._meta.label}: {personal}")
assert not missing, "Undeclared personal data:\n" + "\n".join(missing)
Naive anonymisation nulls the columns and destroys your analytics: revenue per country disappears with the address, repeat-customer counts collapse when every email becomes empty. Do it deliberately instead.
import hashlib
from django.conf import settings
def pseudonym(value: str) -> str:
"""Stable, non-reversible token so repeat-customer analysis survives."""
salted = f"{settings.ANONYMISATION_SALT}{value.lower().strip()}"
return hashlib.sha256(salted.encode()).hexdigest()[:32]
@transaction.atomic
def anonymise_order(order):
order.customer_token = pseudonym(order.email) # keep the link
order.country = order.country # keep the geography
order.email = ""
order.shipping_name = "[removed]"
order.shipping_address = ""
order.phone = ""
order.anonymised_at = timezone.now()
order.save(update_fields=[...])
Two warnings that people learn the hard way. The salt must be secret and must never be rotated casually — with the salt, an email can be checked against a token, which is exactly the linkability you were removing. And a pseudonym is still personal data under GDPR; it lowers risk, it does not take you out of scope.
Watch out for the copies. Personal data leaks into places your model layer never sees:
django-simple-history or your own audit tablesBackups are the honest exception: you cannot rewrite them. The accepted answer is a documented backup retention window — "personal data disappears from backups within 35 days" — and a rule that a restored backup is re-anonymised before use.
The pseudonym() function above concatenates the salt and the value and hashes the result. That works, but the construction designed for exactly this job is an HMAC: a keyed hash whose security does not depend on how the key and message are glued together. It is a small change and it is what a security reviewer will expect to see:
import hashlib
import hmac
from django.conf import settings
def pseudonym(value: str) -> str:
key = settings.ANONYMISATION_KEY.encode()
msg = value.lower().strip().encode()
return hmac.new(key, msg, hashlib.sha256).hexdigest()[:32]
Load ANONYMISATION_KEY from the environment or a secrets manager, never from the repository, and keep it separate from SECRET_KEY. You will rotate SECRET_KEY after an incident; rotating the pseudonymisation key splits every customer into "before" and "after" tokens, so treat any rotation as a planned data migration rather than routine hygiene.
Of the places listed above, several are within your code's reach and should be handled in the same unit of work as the anonymisation itself:
from django.core.cache import cache
from django.db import transaction
@transaction.atomic
def anonymise_order_everywhere(order):
anonymise_order(order) # live row first
order.history.all().update( # django-simple-history rows
email="", shipping_name="[removed]",
shipping_address="", phone="",
)
transaction.on_commit(lambda: cache.delete(f"order:{order.pk}"))
transaction.on_commit(lambda: reindex_order.delay(order.pk))
For Celery, the rule is simpler: pass primary keys to tasks, never emails or names. Task arguments are serialised into the broker, stored in the result backend if you use one, shown in monitoring dashboards and printed in failure logs.
A retention policy that nobody runs is worse than none, because you wrote it down. Make it a command, put it in Beat, and have it report what it did.
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("--apply", action="store_true")
def handle(self, *args, **opts):
now, report = timezone.now(), []
stale = Order.objects.filter(
created_at__lt=now - timedelta(days=730),
anonymised_at__isnull=True,
)
report.append(("orders.anonymise", stale.count()))
if opts["apply"]:
for order in stale.iterator(chunk_size=500):
anonymise_order(order)
carts = Cart.objects.filter(updated_at__lt=now - timedelta(days=30),
order__isnull=True)
report.append(("carts.delete", carts.count()))
if opts["apply"]:
carts.delete()
for name, count in report:
self.stdout.write(f"{name}: {count}")
RetentionRun.objects.create(applied=opts["apply"], report=dict(report))
Default to a dry run. The first time this executes against production data you want to read the numbers, not discover them. And log every run: "we delete after two years" is a claim; a table of runs is evidence.
A data subject access request has a one-month deadline. Build the export once and it takes a minute instead of a day.
def export_user_data(user) -> dict:
return {
"generated_at": timezone.now().isoformat(),
"account": {
"email": user.email,
"joined": user.date_joined.isoformat(),
"last_login": user.last_login and user.last_login.isoformat(),
},
"orders": list(
Order.objects.filter(customer=user)
.values("number", "created_at", "total_cents", "status")
),
"support_tickets": list(
Ticket.objects.filter(user=user).values("number", "subject", "created_at")
),
"newsletter": list(
Subscription.objects.filter(email=user.email)
.values("confirmed_at", "unsubscribed_at", "source")
),
"logins": list(
LoginEvent.objects.filter(user=user).order_by("-at")[:100]
.values("at", "ip_truncated", "user_agent")
),
}
Three things that separate a real implementation from a demo:
Here is the registry version of the function above. Each app owns a small privacy.py module; the central code only knows how to iterate:
# privacy/registry.py
from collections.abc import Callable
from typing import Any
from django.utils import timezone
EXPORTERS: dict[str, Callable[[Any], Any]] = {}
def exporter(section: str):
def register(fn):
if section in EXPORTERS:
raise ValueError(f"duplicate export section {section!r}")
EXPORTERS[section] = fn
return fn
return register
def export_user_data(user) -> dict:
data = {"generated_at": timezone.now().isoformat()}
for section, fn in sorted(EXPORTERS.items()):
data[section] = fn(user)
return data
# orders/privacy.py
from privacy.registry import exporter
from .models import Order
@exporter("orders")
def export_orders(user):
return list(Order.objects.filter(customer=user)
.values("number", "created_at", "total_cents", "status"))
# orders/apps.py
class OrdersConfig(AppConfig):
name = "orders"
def ready(self):
from . import privacy # noqa: F401 (registers the exporter)
Pair it with the inventory test: for every app that has a model declaring Privacy with non-empty personal_fields, assert that the app registered at least one exporter. Now the developer who adds a Wishlist model with an email column gets a failing test that tells them exactly what is missing.
When serialising, use json.dumps(data, cls=DjangoJSONEncoder, indent=2) from django.core.serializers.json. values() returns datetime, Decimal and UUID objects that the standard encoder rejects, and you want to find that out in a test, not on day 29 of a request.
user.delete() is almost always wrong. It cascades into orders, invoices and audit rows you are required to keep, and it takes your referential integrity with it. Erase the person, keep the facts:
@transaction.atomic
def erase_user(user, *, reason):
for order in Order.objects.filter(customer=user):
anonymise_order(order) # keeps totals and dates
Ticket.objects.filter(user=user).update(
body="[removed at user request]", author_email="")
Subscription.objects.filter(email=user.email).delete()
user.email = f"deleted-{user.pk}@invalid"
user.is_active = False
user.set_unusable_password()
user.first_name = user.last_name = ""
user.save()
ErasureLog.objects.create(user_pk=user.pk, reason=reason,
performed_at=timezone.now())
Note what ErasureLog does and does not contain: the fact that an erasure happened and when, never the data that was erased. That is the whole trick — you can prove you complied without keeping the thing you deleted.
The email placeholder uses @invalid, a reserved domain that can never route anywhere. Reusing the real address as a "tombstone" defeats the purpose.
Your database is rarely the only place a customer lives. The email provider, the CRM, the helpdesk and the analytics tool each hold a copy, and as controller you are responsible for passing the erasure on. There is a sequencing trap: once erase_user has run, you no longer have the identifiers those systems need. Capture them first:
@transaction.atomic
def erase_user_everywhere(user, *, reason):
external = {name: p.external_id(user) for name, p in PROCESSORS.items()}
erase_user(user, reason=reason)
for name, ext_id in external.items():
if ext_id:
transaction.on_commit(
lambda n=name, e=ext_id: erase_in_processor.delay(user.pk, n, e)
)
@shared_task(bind=True, max_retries=12, default_retry_delay=3600)
def erase_in_processor(self, user_pk, processor, external_id):
try:
PROCESSORS[processor].erase(external_id) # must treat "not found" as success
except ProcessorUnavailable as exc:
raise self.retry(exc=exc)
ProcessorErasure.objects.create(user_pk=user_pk, processor=processor,
completed_at=timezone.now())
Pass the processor's own identifier — a CRM contact id, a payment-provider customer id — rather than the email, for the task-argument reasons above. Make each erase() idempotent, because retries will call it twice. And reconcile: a weekly report that lists erasures without a ProcessorErasure row for every configured processor catches the task that exhausted its retries at 3 a.m. and was never looked at again.
A marketing_opt_in = True column cannot answer the only question that matters when challenged: when, and to what exactly, did this person agree? Store events, not state.
class ConsentEvent(models.Model):
class Kind(models.TextChoices):
MARKETING = "marketing"
ANALYTICS = "analytics"
email = models.EmailField(db_index=True)
kind = models.CharField(max_length=20, choices=Kind.choices)
granted = models.BooleanField()
text_hash = models.CharField(max_length=64) # which wording they saw
source = models.CharField(max_length=50) # "checkout", "footer"
ip_prefix = models.CharField(max_length=20) # truncated, not full
created_at = models.DateTimeField(auto_now_add=True)
Current state becomes "the latest event of this kind", which is one indexed query and is defensible. text_hash is the detail people forget: you changed your consent wording last year, and you need to show which version this person actually accepted.
Consent events create a tension with erasure. You need them to prove consent, and they contain an email. The workable answer is to replace the email with its pseudonym() on erasure and keep the event. Better still, keep a suppression list of pseudonymised addresses for people who unsubscribed or asked to be forgotten: when a marketing list is re-imported next year, anything whose token is on the list is dropped.
The cheapest privacy engineering is the field you never added.
192.0.2.0 instead of 192.0.2.47 — enough for rate limiting and geography, no longer an identifier.The retention runs and the erasure log are two specific audit trails. The general version is an append-only record of who did what to whose data — above all, staff actions: opening a customer in the admin, exporting data, impersonating a user, changing an email address. It must itself hold as little personal data as possible, or it becomes the largest copy of personal data you have.
class AuditEvent(models.Model):
at = models.DateTimeField(default=timezone.now, db_index=True)
actor_pk = models.BigIntegerField(null=True) # None for system jobs
actor_kind = models.CharField(max_length=20) # "staff", "user", "system"
action = models.CharField(max_length=50) # "customer.viewed"
subject_ref = models.CharField(max_length=64, db_index=True) # "user:4812"
detail = models.JSONField(default=dict) # ids and counts, not values
prev_hash = models.CharField(max_length=64)
hash = models.CharField(max_length=64, unique=True)
Plain integer references instead of foreign keys are deliberate: erasing or deleting a user must not cascade into, or null out, the evidence of what was done to them, and the table can later move to a separate database without migration pain. detail records that the email changed, never the old and new values.
Overriding save() to refuse updates is not enough — QuerySet.update(), raw SQL and a curious engineer in dbshell all go around it. Enforce it in PostgreSQL with a trigger, created in a migration, that still permits deleting rows past the audit table's own retention window:
migrations.RunSQL(
sql="""
CREATE FUNCTION audit_append_only() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' AND OLD.at < now() - interval '2 years' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'audit events are append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_append_only
BEFORE UPDATE OR DELETE ON privacy_auditevent
FOR EACH ROW EXECUTE FUNCTION audit_append_only();
""",
reverse_sql="""
DROP TRIGGER audit_append_only ON privacy_auditevent;
DROP FUNCTION audit_append_only();
""",
)
Belt and braces: have the application connect as a role that holds only INSERT and SELECT on that table, with migrations run by a separate owner role.
Django's admin writes LogEntry rows for every add, change and delete, and each one stores object_repr — the object's __str__ at the time, which for a user model is usually the email address. After an erasure, the admin history still shows the old address. Scrub it as part of erase_user:
from django.contrib.admin.models import LogEntry
from django.contrib.contenttypes.models import ContentType
LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(user),
object_id=str(user.pk),
).update(object_repr=f"user {user.pk}", change_message="")
Privacy code fails silently, so the antidote is tests that search for data rather than assert on implementation details. The most valuable one plants a distinctive marker, erases, and then scans every declared personal field in the project for it:
import pytest
from django.apps import apps
MARKER = "erase-me-7f3a"
@pytest.mark.django_db
def test_erasure_leaves_no_marker(user_factory, order_factory, ticket_factory):
user = user_factory(email=f"{MARKER}@example.com", first_name=MARKER)
order_factory(customer=user, email=user.email, shipping_name=MARKER)
ticket_factory(user=user, body=f"call me, {MARKER}")
erase_user(user, reason="test")
for model in apps.get_models():
privacy = getattr(model, "Privacy", None)
for name in getattr(privacy, "personal_fields", []):
hits = model._default_manager.filter(**{f"{name}__icontains": MARKER})
assert not hits.exists(), f"{model._meta.label}.{name} still holds data"
Because the loop is driven by the inventory, it grows automatically: a new model that declares its personal fields is covered by the erasure test on the day it is added, and if erase_user forgets it, the build fails. Add the admin LogEntry and history tables explicitly, since they do not carry a Privacy declaration.
| Symptom | Likely cause | Fix |
|---|---|---|
| An erased customer's email still appears in the admin | LogEntry.object_repr or history rows | Scrub both inside erase_user; cover them in the marker test |
| Repeat-customer rate dropped after a release | Pseudonym normalisation changed or key rotated | One shared pseudonym(); treat key rotation as a migration |
| Someone who asked to be forgotten receives a newsletter | Erasure deleted the suppression record, list was re-imported | Keep a pseudonymised suppression list |
| A processor still holds erased users | Erasure task exhausted retries unnoticed | Alert on failed tasks; weekly reconciliation report |
| Emails appear in error reports | Default PII sending, request bodies, SQL breadcrumbs | send_default_pii=False, before_send, and a before_breadcrumb hook if queries carry parameters |
None of this is exotic Django. It is models, a management command and a scheduled task — the same tools you use for everything else. The difference is that you decided the rules before someone asked you to prove them.