Django Advanced

GDPR Engineering in Django: Retention, Anonymisation, DSAR Exports, and Audit Trails

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.

DjangoZen Team Sep 05, 2026 19 min read 16 views

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.

Start with an inventory, not a policy

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:

  • Delete outright. Session logs, abandoned carts, unverified signups, raw analytics. Nothing downstream depends on them.
  • Anonymise and keep. Orders, invoices, support tickets. The facts — amount, date, product — must survive for accounting and reporting; the person must not.
  • Keep in full. Records a law explicitly requires you to retain. Invoices under tax law are the classic case: an erasure request does not override them, and saying so plainly is a valid answer.

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.

Anonymisation that does not wreck your reports

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:

  • historical rows from django-simple-history or your own audit tables
  • Celery result backends and task arguments
  • error reports (Sentry), application logs, request logs
  • email queues, PDF invoices in storage, database backups
  • search indexes and caches

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

Retention as a scheduled job, not a promise

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.

Access requests: one command, not a scavenger hunt

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:

  • A registry, not a hand-written function. Have each app contribute its own section, so a new app cannot be forgotten by the developer who adds it.
  • Other people's data stays out. A support thread may contain another customer's message; export the user's own turns, not the whole thread.
  • Deliver it safely. A signed, expiring download behind a fresh login — never an email attachment, which is the one channel you do not control.

Erasure without breaking foreign keys

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.

Collect less and most of this gets easier

The cheapest privacy engineering is the field you never added.

  • Truncate IP addresses at capture. 192.0.2.0 instead of 192.0.2.47 — enough for rate limiting and geography, no longer an identifier.
  • Do not log request bodies on authentication, checkout or profile endpoints. Scrub by field name at the logging layer, not by hoping.
  • Store what you need, not what the form could ask. Date of birth for an age check can be a boolean "over 18" computed once and thrown away.
  • Set a short default retention on new tables and lengthen deliberately. The default is what you will still have in five years.

A practical checklist

  • Every model with personal data has a documented basis and retention period.
  • Retention runs on a schedule, defaults to dry run, and logs every execution.
  • Export is one command and each app registers its own section.
  • Erasure anonymises rather than cascading deletes, and writes an erasure log.
  • Consent is stored as events including the wording that was shown.
  • IPs are truncated at capture; sensitive fields are scrubbed from logs and error reports.
  • Backup retention is documented, and restores are re-anonymised.

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.