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.
- Decide the scope: which models and fields need history, and for how long.
- Model-level libraries:
django-simple-history(full historical rows) ordjango-auditlog(field diffs as JSON) capture ORM saves with little code. - For changes made outside the ORM (
update(),bulk_update, raw SQL, other services), use database triggers, for exampledjango-pghistory. - Attach the acting user and request ID through middleware context.
- Make audit records append-only for the app role and back them up; set retention per policy.
- Expose history in the admin for support, with read-only access.
Step-by-step solution
1. Choose an approach
- django-simple-history adds a
HistoricalModeltable 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
LogEntrytable 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 byqueryset.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.
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
INSERTandSELECTonly on audit tables (revokeUPDATE/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.
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
- Handle personal data correctly with the Django Privacy and GDPR Production Checklist.
- Link audit entries to logs with Add Request IDs to Django Logs.
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.