Keep an Audit Log of Changes in Django

Record who changed what and when in a Django app: compare django-simple-history, django-auditlog, and pghistory triggers, capture the acting user and request ID, protect audit tables from tampering, plan retention, and keep audit writes fast.

Problem statement

Someone changed a customer's credit limit, deleted an invoice, or granted a user admin rights, and nobody can say who or when. The Django admin's LogEntry records only admin actions and only a summary. Compliance requirements (SOC 2, ISO 27001, finance regulations) and plain operational sanity call for a reliable audit trail of changes to important data: who acted, what changed (old and new values), when, and from which request. Several approaches exist, from model-level history to database triggers, with different guarantees.

An audit trailChange happensadmin, API, scriptCapturesignal or DB triggerContextuser, request IDAudit tableappend-only, retainedCapture every write path and the actor, then protect and retain the records.
  1. Decide the scope: which models and fields need history, and for how long.
  2. Model-level libraries: django-simple-history (full historical rows) or django-auditlog (field diffs as JSON) capture ORM saves with little code.
  3. For changes made outside the ORM (update(), bulk_update, raw SQL, other services), use database triggers, for example django-pghistory.
  4. Attach the acting user and request ID through middleware context.
  5. Make audit records append-only for the app role and back them up; set retention per policy.
  6. Expose history in the admin for support, with read-only access.

Step-by-step solution

1. Choose an approach

  • django-simple-history adds a HistoricalModel table per tracked model, storing a full copy of the row on each change, plus the user and change type. Great for "show me this record as of last Tuesday".
  • django-auditlog stores one LogEntry table with JSON diffs of changed fields, which is compact and easy to query across models.
  • django-pghistory uses PostgreSQL triggers (via django-pgtrigger), so changes are captured even by queryset.update(), bulk_update, raw SQL, and migrations.

ORM-signal-based tools miss bulk operations and raw SQL by design. If those write paths change audited data, use triggers or route them through code that records history.

2. Example with django-auditlog

INSTALLED_APPS += ["auditlog"]
MIDDLEWARE += ["auditlog.middleware.AuditlogMiddleware"]   # records the acting user
from auditlog.registry import auditlog

class Customer(models.Model):
    name = models.CharField(max_length=200)
    credit_limit = models.DecimalField(max_digits=10, decimal_places=2)
    ...

auditlog.register(Customer, exclude_fields=["updated_at"])

Every save or delete of a Customer now creates an entry with the changed fields, old and new values, the user, timestamp, and remote address.

how each tool captures changesCapturesMissesdjango-simple-historyfull row per saveupdate(), bulk, raw SQLdjango-auditlogfield diffs as JSONupdate(), bulk, raw SQLdjango-pghistory (triggers)โœ“every write in the DBneeds app context for useradmin LogEntryadmin actions onlyโœ•everything elseTriggers catch every write path; ORM tools are simpler but miss bulk updatesdjango-deployment.com
Pick by which write paths touch audited data in your app.

3. Capture who and why

The acting user comes from middleware (the libraries provide it). Add the request ID and a reason where relevant:

from auditlog.context import set_actor
with set_actor(request.user, remote_addr=request.META.get("REMOTE_ADDR")):
    customer.credit_limit = new_limit
    customer.save()

For management commands and Celery tasks, set an explicit actor such as a system user and the task name, so automated changes are distinguishable from human ones.

4. Protect the records

An audit log that the application can rewrite is weak evidence. Options:

  • Grant the app role INSERT and SELECT only on audit tables (revoke UPDATE/DELETE), with retention jobs running as a separate role.
  • Stream audit records to an external, append-only store (a log platform, S3 with object lock) as well.
  • Include audit tables in backups and restore tests.

5. Retention and size

Audit tables grow forever. Define retention (for example seven years for financial records, one year for operational data), and implement it with scheduled deletion in batches or monthly partitions you can drop. Index by object and timestamp so support lookups stay fast.

audit trail essentialsEvery write path coveredtriggers if bulk or raw SQL usedActor recorded, including automationuser, task, commandRequest ID stored with entrieslinks to logsApp role cannot update or delete audit rowsappend-onlyRetention policy implementedbatch deletes or partitionsAn audit log is only as good as its coverage and its tamper resistancedjango-deployment.com
Test coverage by changing a record through each write path and checking the log.

Verification check: change an audited record through the admin, the API, a queryset.update(), and a management command, and confirm each produces an audit entry with the right actor; attempt an UPDATE on the audit table as the app role and confirm it is denied.

Explanation

Auditing is change data capture with context. ORM-level tools hook into save() and delete() signals, which gives easy access to the request and user but only sees changes made through those methods. Database triggers fire on every row change regardless of source, guaranteeing coverage, while the application supplies context (actor, request ID) through session variables or a context table. Combining good coverage with append-only storage and retention turns the audit trail into evidence you can rely on in an incident or audit.

Edge cases / notes

  • Personal data in history. GDPR erasure requests apply to audit copies too; plan how to redact personal fields while keeping the fact of a change.
  • Performance. History writes double write volume for tracked tables; track only what you need.
  • Migrations. Schema changes to tracked models need matching changes to history tables (simple-history handles this via its own migrations).
  • Admin LogEntry is still useful for admin activity summaries; keep it alongside.

Next steps

FAQ

How do I track changes to models in Django?

Use a library such as django-simple-history or django-auditlog, or database triggers with django-pghistory.

Does Django's admin log every change?

Only changes made through the admin, and only as summaries. It misses API, script, and bulk changes.

Why are bulk updates missing from my audit log?

update() and bulk_update bypass model signals. Use triggers or record history explicitly.

How do I stop the audit log being edited?

Revoke UPDATE and DELETE on audit tables for the app role and copy entries to an append-only store.

Keep going