Back to blog
By Rasul

Django Audit Log: Model History vs Security Events

Design a Django audit log for model changes, denied actions, exports, privacy, retention, and evidence tests in production SaaS.

A production Django audit log should be a durable record of who attempted which important action, against what object, when, and with what outcome. Model history can supply field changes. Security records must also capture denied actions, sensitive reads, exports, and authentication events. Operational logs explain failures, but they should not be your only audit evidence.

The package choice comes after the evidence design. Start by deciding which questions an investigator, support engineer, or account owner must be able to answer. Then choose model snapshots, field diffs, database triggers, or an explicit domain event table for each question.

The framework and package behavior in this guide was verified against Django 6.0, django-auditlog, django-simple-history, django-pghistory, OWASP guidance, and Djass's current API audit implementation on August 2, 2026.

On this page: three record types · coverage matrix · Django defaults · package choice · audit model · transactions · privacy · tests · checklist

Separate three record types

An application often calls all three of these "logs," but they answer different questions and need different retention, access, and failure policies.

Record Primary question Typical destination Example
Operational log Why is this request or worker failing? Structured stdout, log backend, error tracker project_download_failed, exception class, request ID
Model history Which persisted fields changed? History or diff table beside the model role: member -> admin
Security or business audit event Who attempted what, against which object, and what happened? Restricted append-oriented database table or audit store member.role_change, actor 42, target 81, denied

The boundary matters most when nothing changes. A rejected permission change, an invalid API key, a failed export request, or a read of sensitive data may leave no model diff. A model-history package cannot infer the business action that was attempted. Request middleware can record a path and status, but it may not know the domain object, authorization rule, or safe reason code.

Use structured Django logging for diagnostics and correlation. Use model history when people need before-and-after state. Use a domain audit event when the attempted action and its outcome are the evidence. One operation can legitimately produce all three records.

Design an audit coverage matrix

Do not begin by registering every model. List important actions and the evidence each one requires. This coverage matrix is the working contract:

Action Success Denial Failure Read/access Required context
Change a member's role Yes Yes Yes No actor, workspace, member, old/new role, policy result
Rotate or revoke an API key Yes Yes Yes No actor, key ID, scope set, reason; never the key value
Download an export Yes Yes Yes Yes actor, export ID, classification, result, byte/hash metadata if safe
Update a billing entitlement Yes Yes Yes No source event ID, customer/account ID, old/new state
Read health or identity data Optional Yes Yes Yes actor, record class, purpose or route, outcome
Edit ordinary profile text Maybe Maybe Maybe No model history may be enough

For every row, decide:

  1. Which outcomes must exist: succeeded, denied, failed, not_found, or another small controlled set.
  2. Which actor forms are possible: user, API key, worker, provider webhook, or unknown principal.
  3. Which target identifier remains meaningful if the target is later deleted.
  4. Which fields are safe to retain and who can query them.
  5. Whether failure to write the audit event should block the business action.

That last decision is easy to skip. A best-effort record protects availability when the audit store fails. A fail-closed record protects evidence completeness but can prevent users from completing work. Choose per action and document the tradeoff. Do not accidentally inherit it from a broad try/except in a helper.

OWASP's Logging Cheat Sheet makes a similar distinction: process, audit, transaction, and security records can have different purposes and may need to stay separate. It recommends defining the events during requirements and design rather than collecting a blind checklist that creates alarm noise.

Know what Django records by default

Django includes useful pieces, but not an application-wide audit system.

The admin's LogEntry model tracks object additions, changes, and deletions performed through the admin interface. It records the acting user, content type, object ID and representation, action flag, timestamp, and change message. It does not claim to cover your API, ordinary views, workers, management commands, provider webhooks, or direct database writes.

Django also exposes authentication and model signals. Signals can be useful for package-level integration, but the Django signal documentation warns that they can make code harder to maintain and suggests a custom manager or helper method when that makes the behavior more explicit.

There is also a concrete coverage gap around bulk work. Django's bulk_create() documentation states that it does not call each model's save() method or send pre_save and post_save. QuerySet.update() performs SQL directly and does not call model save() methods or those signals either. Any audit mechanism built on save signals needs a supported bulk helper, an explicit event, or database-level capture for those paths.

Treat these defaults as components. Your coverage matrix remains the source of truth.

Choose the smallest mechanism that covers the event

No single package is best for every audit job. Select by the evidence you need and the mutation paths you must cover.

Use django-simple-history for model snapshots

django-simple-history creates a historical model and stores model state plus metadata such as the history user, date, reason, and create/update/delete type. It fits questions like "what did this object look like before the change?"

Its official bulk-operation guidance is part of the design, not a footnote. Ordinary bulk_create, bulk_update, and queryset updates do not automatically create history rows because the relevant signals are not sent. The package provides history-aware bulk helpers, but your team must use them consistently.

Choose snapshots when reconstructing object state is more important than a compact diff. Add a separate domain audit event for denied commands, reads, or exports that do not map to model changes.

Use django-auditlog for field diffs and actor context

django-auditlog automatically records registered model changes using Django signals. It can include or exclude fields, mask selected values, opt into many-to-many tracking, attach actors through middleware or a task context, and carry a correlation ID.

That makes it a good fit when investigators need a readable field-level diff without storing a full snapshot for every version. Registration still does not define your application-wide event coverage. A field diff cannot express a permission denial, and many-to-many tracking is opt-in. Review every bulk, script, task, and raw-write path before calling the result complete.

Use django-pghistory for PostgreSQL trigger coverage

django-pghistory uses PostgreSQL triggers to create event models when tracked rows change. Its middleware and context API can attach request user, URL, and additional metadata. Database triggers cover mutation paths that bypass Django model signals, including direct SQL against the tracked table.

The tradeoff is explicit: the design is PostgreSQL-specific and moves part of the behavior into database triggers and generated event models. It still needs application context for business meaning, and a database update cannot describe an action that was denied before any update occurred.

Use an explicit audit model for commands, reads, and denials

An application-owned audit model fits actions whose meaning lives at a service, authorization, API, or worker boundary. Examples include permission changes, data exports, scoped-key failures, provider event handling, and administrative reads.

This approach requires more design work, but it gives you a controlled action taxonomy, explicit outcomes, stable target identifiers, privacy rules, and a testable failure policy. It can coexist with any model-history package.

Build an explicit domain audit model

Keep the schema narrow and append-oriented. Store identifiers and bounded metadata, not serialized requests or whole model objects.

import uuid

from django.conf import settings
from django.db import models


class AuditEvent(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    occurred_at = models.DateTimeField(auto_now_add=True, db_index=True)
    actor = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="audit_events",
    )
    actor_type = models.CharField(max_length=24)
    action = models.CharField(max_length=64)
    outcome = models.CharField(max_length=24)
    target_type = models.CharField(max_length=64)
    target_id = models.CharField(max_length=64, blank=True)
    request_id = models.CharField(max_length=64, blank=True)
    reason_code = models.CharField(max_length=64, blank=True)
    metadata = models.JSONField(default=dict, blank=True)

    class Meta:
        indexes = [
            models.Index(fields=["action", "occurred_at"]),
            models.Index(fields=["target_type", "target_id", "occurred_at"]),
        ]

actor_type distinguishes a user from an API key, worker, webhook, or unknown principal. action, outcome, and reason_code should use controlled values defined near the owning domain service. target_type and target_id avoid a generic foreign key while keeping deleted-target evidence queryable.

Do not put email addresses, names, raw IP headers, request bodies, access tokens, session IDs, or API keys into metadata by default. If an IP address or user agent is justified for security investigation, give it an explicit field, retention period, access policy, and privacy review.

The append-only intent is an application rule, not a database guarantee. Remove ordinary edit/delete actions from admin, restrict database permissions, record access to the audit surface, and decide whether you need tamper detection or a separate write-only destination. A normal mutable table should not be marketed as immutable evidence.

Make success evidence atomic with the change

When a successful audit event describes a database mutation, write both in the same transaction. Django's transaction.atomic() documentation guarantees that the block commits together or rolls back together.

from django.db import transaction


@transaction.atomic
def change_member_role(*, actor, membership, new_role, request_id):
    old_role = membership.role
    membership.role = new_role
    membership.save(update_fields=["role", "updated_at"])

    AuditEvent.objects.create(
        actor=actor,
        actor_type="user",
        action="member.role_change",
        outcome="succeeded",
        target_type="membership",
        target_id=str(membership.pk),
        request_id=request_id,
        metadata={"before": {"role": old_role}, "after": {"role": new_role}},
    )

Perform authorization before entering this mutation service. If access is denied, create a bounded denial event and return a normal 403 response without attempting the mutation:

def record_role_change_denial(*, actor, membership_id, request_id, reason_code):
    AuditEvent.objects.create(
        actor=actor,
        actor_type="user" if actor else "unknown",
        action="member.role_change",
        outcome="denied",
        target_type="membership",
        target_id=str(membership_id),
        request_id=request_id,
        reason_code=reason_code,
    )

If your project wraps every request in ATOMIC_REQUESTS, confirm that the denial record is not rolled back by a later exception. Returning a handled 403, using a deliberate transaction boundary, or writing security events to a separate audit service are different designs. Test the one you choose.

Use transaction.on_commit() for secondary work such as sending an alert or copying a committed event to another system. Django discards the callback when the transaction rolls back, but the callback runs after the commit and is not part of it. Do not place the only required audit record in an on_commit() callback if callback failure would create an evidence gap.

Learn from Djass's API audit boundary

Djass uses an explicit ProjectAPIAuditLog rather than trying to infer API actions from project model history. The record includes the action, HTTP status, method, path, actor or key references when known, project reference when known, client context, key type, and bounded metadata. Related user, key, and project references use SET_NULL, so deleting those objects does not automatically delete the audit row.

The important design detail is coverage. The Projects API records:

  • an invalid-key attempt even though no authenticated actor can be attached;
  • an insufficient-scope denial with the required scope;
  • successful list and project operations;
  • artifact download outcomes including denied, missing, not-ready, internal failure, and success paths;
  • bounded success metadata such as artifact size and SHA-256 rather than the artifact body or API key.

A project-history package could record a new project row. It could not explain an invalid credential, a rejected scope, a missing object requested by another tenant, or a successful read that changed no project fields. The public Projects API reference describes the endpoints and their error taxonomy.

Djass also makes an explicit availability choice: its audit helper catches a database-write failure, sends a warning to operational logging, and lets the API response continue. That can be reasonable for a generator API where audit telemetry is important but not a legal ledger. A system whose core promise is complete regulated evidence may need to reject selected operations when the audit row cannot be stored. The right answer follows from the coverage matrix, not from copying Djass's policy unchanged.

Protect the evidence

Audit records often become a second concentration of sensitive data. Minimize them before adding encryption or retention machinery.

OWASP recommends enough context to answer when, where, who, and what, while usually excluding direct session identifiers, access tokens, passwords, database connection strings, encryption keys, payment data, and sensitive personal data. It also recommends sanitizing untrusted values to prevent log injection and protecting records from unauthorized access, modification, and deletion.

Apply that guidance as concrete controls:

  1. Allow-list fields. Build metadata dictionaries from named safe fields. Never serialize a request, model, exception locals, or provider payload.
  2. Bound values. Restrict action/outcome choices and truncate user-agent, path, reason, and free-text fields.
  3. Separate access. Product support may need selected events; database administrators may need the full record; ordinary users need neither.
  4. Record audit access. Reading or exporting the audit trail can itself be a sensitive action.
  5. Set retention by purpose. Security investigations, user-visible history, support diagnostics, and legal requirements may have different windows.
  6. Detect gaps. Monitor audit-write failures, event-volume drops, unexpected deletions, and disabled capture paths.
  7. Document limitations. Database rows with privileged writers are not automatically tamper-proof or non-repudiable.

Keep infrastructure credentials in environment-backed configuration. Djass's environment variable guide shows the same separation for application secrets; audit metadata should reference a safe key ID or provider event ID, never copy the credential.

Test coverage, not only row creation

One happy-path test proves very little. Build tests from the coverage matrix.

import pytest

from audit.models import AuditEvent


@pytest.mark.django_db
def test_role_change_records_before_and_after(member_admin, membership):
    change_member_role(
        actor=member_admin,
        membership=membership,
        new_role="admin",
        request_id="req-123",
    )

    event = AuditEvent.objects.get(action="member.role_change")
    assert event.outcome == "succeeded"
    assert event.target_id == str(membership.pk)
    assert event.metadata == {
        "before": {"role": "member"},
        "after": {"role": "admin"},
    }

Add the negative and operational paths:

  • Denied: unauthorized actor gets 403, no model mutation occurs, and one denied event persists with a controlled reason code.
  • Rollback: a failure after the mutation but inside atomic() leaves neither the changed object nor a misleading success event.
  • Bulk: every supported bulk path uses a history-aware helper, explicit event, or database trigger; a regression test proves it.
  • Unknown actor: invalid credentials still produce an event without inventing a user association.
  • Idempotency: retried provider events or commands do not create ambiguous duplicate business evidence; use a stable source-event identifier and a database constraint where the source guarantees uniqueness.
  • Privacy: representative access tokens, session IDs, email addresses, and payload values do not appear in fields or serialized metadata.
  • Retention: the cleanup job deletes only records older than the configured boundary and cannot remove protected categories accidentally.
  • Permissions: ordinary staff cannot edit or delete audit records, and sensitive audit views enforce the intended role.
  • Failure policy: simulate an audit database error and assert whether the business action blocks or continues according to the documented decision.

Then run a staging drill. Perform one allowed action and one denied attempt. Find both from the actor, target, action, outcome, and request ID. Confirm the operational log can explain a failure without being required to reconstruct the audit trail.

Common Django audit logging mistakes

Registering every model without an event taxonomy

This creates storage and noise while still missing denials, reads, and business meaning. Track important model state, then add explicit action events for the coverage gaps.

Calling a mutable table immutable

Append-only application code does not prevent a privileged database account from updating or deleting rows. State the actual controls: restricted roles, separate writers, tamper detection, or an external destination. When backups are part of that control set, use a measured Django database restore drill to prove the audit records survive and remain attributable.

Writing success evidence after the transaction

The model can commit and the audit insert can fail, or the audit row can claim success before the model rolls back. Put the successful mutation and its required event in one atomic() block.

Recording only successful actions

Security investigations often start with denials, missing targets, invalid credentials, unusual reads, or failed exports. Those paths may have no model change at all.

Storing before and after values without a privacy review

A field diff can copy passwords, tokens, reset codes, health data, addresses, or payment details into a longer-lived table. Include and mask fields deliberately. "Audit" does not create an exemption from data minimization.

Assuming signals cover bulk and raw writes

Signal-based tools are useful, but Django documents paths that skip model save() and save signals. Inventory bulk helpers, management commands, scripts, workers, and direct SQL before choosing the capture mechanism.

Production Django audit log checklist

Before shipping:

  • [ ] Important actions have a coverage row for success, denial, failure, and reads.
  • [ ] Action, outcome, actor type, target type, and reason use controlled values.
  • [ ] Model history and domain audit events have distinct responsibilities.
  • [ ] Django admin history is not treated as API, worker, or application-wide coverage.
  • [ ] Bulk, task, script, webhook, and raw-SQL paths have an explicit capture strategy.
  • [ ] Successful mutations and required audit rows share one transaction.
  • [ ] Denied attempts persist without creating a model change.
  • [ ] Audit-write failure policy is documented and tested per important action.
  • [ ] Metadata is allow-listed, bounded, and free of credentials and raw payloads.
  • [ ] Audit readers, writers, retention, export, and deletion controls are defined.
  • [ ] Tests prove rollback, denial, privacy, bulk, permissions, and failure behavior.
  • [ ] Monitoring detects write failures, missing event volume, and unauthorized changes.

Djass gives a generated Django SaaS the surrounding project structure and agent-readable development workflow, while product-specific authorization and audit policy remain your responsibility. Review the available generator modules, the Django Q2 background-job boundary, and current Djass pricing when you want that foundation before implementing your domain's evidence model.

Django audit log FAQ

Does Django have a built-in audit log?

Django's admin LogEntry records additions, changes, and deletions made through the admin interface. It is not an application-wide audit log for APIs, normal views, workers, webhooks, management commands, raw SQL, denied actions, or sensitive reads.

What is the difference between django-auditlog and django-simple-history?

django-auditlog focuses on model changes and readable field diffs, with actor, masking, correlation, and optional access or many-to-many tracking. django-simple-history stores historical model states with history metadata. Neither replaces an explicit domain event for denied commands or reads that do not mutate a model.

Do Django signals capture bulk updates?

Not automatically. Django documents that bulk_create() does not call model save() or send pre_save and post_save, and QuerySet.update() performs a direct SQL update without those calls. Use package-supported bulk helpers, explicit audit events, or database triggers for those paths.

Should audit records be in the same database transaction?

If a success event is required evidence for a database mutation, write both in the same transaction.atomic() block. Use on_commit() for secondary alerts or exports, because callbacks run after commit and cannot roll the mutation back if the callback fails.

What should a Django audit log contain?

At minimum, use a timestamp, controlled action and outcome, actor type and safe actor identifier, target type and identifier, request or correlation ID, and a bounded reason code or metadata allow-list. Add IP or user-agent data only when the security value and privacy policy justify it.