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