Filter concepts by levelShowing all levels.

Django · Auditing

Auditing

Three kinds of record, four kinds of event, and one table that must be append-only

  • Three records, three audiencesapp logs (days) · audit logs (years, append-only) · domain events (the product)
  • update() emits no signalsso signal-based auditing misses exactly the bulk changes that matter most
  • Store the diff, not a snapshotthe audit table has your longest retention — redact sensitive fields
  • Same transaction as the changea change with no audit row is the gap the trail exists to prevent
  • Audit the failed logins tooa successful login is only evidence next to the forty that failed

1.Timestamps and actors — the cheapest auditing there is

Before any audit system, add four fields: `created_at`, `updated_at`, `created_by`, `updated_by`. They cost almost nothing and answer a surprising share of real questions — when did this appear, when did it last change, and who touched it. What they cannot answer is *history*: `updated_by` holds only the most recent actor, so the previous five edits are gone. That is the gap a state-change history table fills, and knowing exactly where these four fields stop is what tells you when you need one.

Key features

  • `auto_now_add=True` sets the field once on creation; `auto_now=True` updates it on every `save()`
  • Both are applied in `save()`, so `QuerySet.update()` silently leaves them stale
  • When you use `update()` on purpose, set `updated_at=timezone.now()` in the same call
  • A model cannot see the request — pass the actor in, or read a middleware-set `ContextVar`
  • `created_by`/`updated_by` must be nullable: commands, tasks and migrations have no user
  • `updated_by` holds one actor; any question spanning two changes needs a history table

What each tier can answer — and where it stops

What each tier can answer — and where it stops
QuestionTimestamps+ actors+ history
when was this created?yesyesyes
when did it last change?yesyesyes
who created it?noyesyes
who made the *last* change?noyesyes
who changed the price to £49?noonly if nobody touched it sinceyes
how long was it in `processing`?nonoyes
what did it look like in March?nonoyes

Which write paths keep the fields honest

Which write paths keep the fields honest
Write path`auto_now` fires?Signals fire?
`obj.save()`yesyes
`obj.save(update_fields=[…])`yes — include the fieldyes
`Model.objects.create(...)`yesyes
`QuerySet.update(...)`**no****no**
`bulk_create()` / `bulk_update()`no — `save()` is skippedno
a raw SQL `UPDATE`nono
Three tiers — climb only as far as your questions require

Nothing

you cannot even say when the row appeared

created_at / updated_at

two fields, near-zero cost — but stale after any update()

+ created_by / updated_by

the last actor only; nullable, because commands and tasks have no user

+ a state-change history row

from, to, actor, when — the first tier that survives a second edit

+ an append-only audit log

a different audience and a different retention — see the next concept

  1. Nothing — you cannot even say when the row appeared
  2. created_at / updated_at — two fields, near-zero cost — but stale after any update()
  3. + created_by / updated_by — the last actor only; nullable, because commands and tasks have no user
  4. + a state-change history row — from, to, actor, when — the first tier that survives a second edit
  5. + an append-only audit log — a different audience and a different retention — see the next concept

Command

python
created_at = models.DateTimeField(auto_now_add=True)   # set on save(), not on update()

Four fields answer a lot for almost nothing — but know exactly where they stop. `auto_now`/`auto_now_add` fire in `save()`, so `QuerySet.update()`, `bulk_create` and raw SQL all leave them stale; set them by hand on those paths, or rows will claim they were last changed before the change that just happened. Actor fields must be nullable, because commands, tasks and migrations have no user, and should be passed explicitly rather than read from a global — a module-level current user misattributes rows under concurrency. And `updated_by` holds one actor: any question spanning two edits needs a history row per change.

2.Three kinds of record, and why they are not interchangeable

The roadmap names three things that people call "logging" and treats them as distinct, because they are. **Application logs** are for engineers debugging behaviour — high volume, short retention, disposable. **Audit logs** answer "who did what, to what, when" for someone who is not an engineer — compliance, a customer dispute, a security review — so they are append-only and kept for years. **Business event history** is the record of what happened to a domain object: the states an order moved through. Mixing them gives you one store that serves none of the three well.

Key features

  • Application log: engineers, this week, high volume, disposable — retention in days
  • Audit log: non-engineers, years later, append-only — "who did what to what, when"
  • Business event history: part of the domain, queried by the product itself
  • `auto_now`/`auto_now_add` are applied on `save()`, so `QuerySet.update()` leaves them stale
  • `update()` "doesn't run any `save()` methods, or emit the `pre_save` or `post_save` signals"
  • Store the diff, not a full copy — a snapshot duplicates data and extends its retention

The three, side by side

The three, side by side
PropertyApplication logAudit logBusiness event history
read byengineers, debuggingcompliance, support, securitythe product, and users
answers"why did this break?""who did this, and when?""what happened to this order?"
lives ina log pipelinea database tablea database table
retentiondays to weeksyears, by policythe object's lifetime
mutable?irrelevant — it is discarded**append-only**append-only in practice
may lose entries?yes, acceptableno — a gap looks like a cover-upno
containsanything usefulactor, action, target, time, sourcedomain states and transitions

What to record, from cheapest to most complete

What to record, from cheapest to most complete
LevelGives youCost
`created_at` / `updated_at`when, roughlytwo fields — and stale after `update()`
`created_by` / `updated_by`the last actor onlytwo more fields; history still lost
an append-only audit tableevery action, forevera write per action, and a retention policy
a domain event tablethe object's own timelinemodelling work; but the product wants it anyway
full before/after snapshotseverythingduplicated data, and personal data kept for years
One refund, recorded three ways — and what each one can answer

An application log line

  • Written for whoever is debugging this week
  • Retention measured in days
  • May be dropped under load, and that is acceptable
  • Answers: "why did the refund call fail?"
  • Cannot answer: "who authorised this in March?"

An audit row and a domain event

  • Written for compliance, support and security
  • Retention measured in years, by policy
  • Append-only, in the database, inside the transaction
  • Answers: "who authorised this, from where, when?"
  • And the event answers: "what happened to this order?"
  • An application log line
    • Written for whoever is debugging this week
    • Retention measured in days
    • May be dropped under load, and that is acceptable
    • Answers: "why did the refund call fail?"
    • Cannot answer: "who authorised this in March?"
  • An audit row and a domain event
    • Written for compliance, support and security
    • Retention measured in years, by policy
    • Append-only, in the database, inside the transaction
    • Answers: "who authorised this, from where, when?"
    • And the event answers: "what happened to this order?"

Command

python
updated_at = models.DateTimeField(auto_now=True)   # set on save(), NOT on update()

Three records, three audiences. Application logs are for engineers this week and may be dropped; audit logs are for non-engineers years later and must be append-only and complete; business event history is part of the domain and the product queries it directly. Start with `created_at`/`updated_at` and `created_by`/`updated_by` — but know `auto_now` only fires on `save()`, and `QuerySet.update()` emits no signals, so signal-based auditing has a hole exactly where bulk changes happen. Write the audit row in the same transaction as the change, store the diff rather than a snapshot, and redact sensitive fields — the audit store has your longest retention.

3.What actually needs auditing — and what data access logging costs

Four categories earn an audit row rather than a log line. **Security events**: sign-in, sign-out, failed attempts, password and email changes, permission grants, token issue and revocation. **Administrative actions**: anything staff do to somebody else's data. **Payment events**: every state change to money. **Data access logging**: recording *reads*, not just writes — which is the expensive one, and the only one where "audit everything" is usually the wrong answer.

Key features

  • Django emits `user_logged_in`, `user_logged_out` and `user_login_failed` signals — audit all three
  • Failed attempts are the ones that make a successful login meaningful
  • `django.contrib.admin.models.LogEntry` covers admin additions/changes/deletions — and nothing outside the admin
  • Staff acting on customer data is its own category: legitimate, and still attributable
  • Every payment state change gets a row, with the provider reference and the amount
  • Read logging is the expensive one: reads outnumber writes, so scope it rather than blanket it

The four categories, and the question each one answers

The four categories, and the question each one answers
CategoryRecordAnswers
**Security**login, logout, failed login, password/email change, MFA, token issue+revoke"was this account compromised?"
**Administrative**staff action, target user, impersonation start/end, permission grants"did staff do something they should not have?"
**Payment**every state change, amount, currency, provider reference"where did this money go?"
**Data access**reads of sensitive records; bulk reads especially"who looked at this?"
(not audited)page views, ordinary reads, search queriesnothing worth years of storage

Deciding how much read logging to do

Deciding how much read logging to do
ApproachCostRight when
nonezerothe data is not sensitive — most systems
sensitive records onlya write per sensitive viewpayment details, personal data, customer records
bulk access onlya write per export or wide querythe signal you actually want: one actor, many records
every readmore rows than the data itselfa regulator asks for it — and then it is async
An account compromise, reconstructed from audit rows alone
  1. 02:14

    41 × auth.login_failed

    same username, one IP in a country the account has never used

  2. 02:15

    auth.login_succeeded

    same IP — unremarkable on its own; damning after the line above

  3. 02:16

    auth.email_changed

    the recovery address is moved first, so the real owner cannot reset

  4. 02:16

    auth.mfa_disabled

    the second factor removed while the session is still valid

  5. 02:19

    data.bulk_access

    8,400 customer records read in one query — the read that mattered

  6. 02:23

    payment.payout_created

    new bank details, entered four minutes earlier

  7. 09:40

    admin.impersonation_started

    support investigating — recorded as support, not as the customer

  1. 02:14: 41 × auth.login_failed — same username, one IP in a country the account has never used
  2. 02:15: auth.login_succeeded — same IP — unremarkable on its own; damning after the line above
  3. 02:16: auth.email_changed — the recovery address is moved first, so the real owner cannot reset
  4. 02:16: auth.mfa_disabled — the second factor removed while the session is still valid
  5. 02:19: data.bulk_access — 8,400 customer records read in one query — the read that mattered
  6. 02:23: payment.payout_created — new bank details, entered four minutes earlier
  7. 09:40: admin.impersonation_started — support investigating — recorded as support, not as the customer

Command

python
@receiver(user_login_failed)   # the signal most systems forget to audit

Audit four categories: security events (including the *failed* logins — a successful one is only meaningful beside them), administrative actions by staff on other people's data, every payment state change, and — deliberately, not by default — access to sensitive data. Django gives you `user_logged_in`, `user_logged_out` and `user_login_failed` for free, and `admin.LogEntry` covers the admin and nothing outside it. Read logging is the expensive category because reads vastly outnumber writes, so scope it to sensitive records and to *bulk* access, which is the signal you actually want.

Remember

  • `auto_now` is applied during `save()`. A `QuerySet.update()` leaves `updated_at` stale and nothing warns you.
  • An audit trail that application code can edit proves nothing — grant INSERT and SELECT, not UPDATE and DELETE.
  • `admin.LogEntry` covers the Django admin and nothing outside it; your own staff tooling is not audited by it.
  • Read logging is the expensive category. Scope it to sensitive records and to bulk access.

Pro tip

  • Put the `request_id` on every audit row. It joins the entry to the application logs and traces for the same request, which is what makes an investigation possible.
  • Record impersonation start and end explicitly, or every support action is attributed to the customer.

Next up

  • Soft Delete
  • State Machines and Workflows
  • Payment and Webhook Workflows

Sources

  • QuerySet API (update, signals) — docs.djangoproject.com/en/5.2/ref/models/querysets/
  • Authentication signals — docs.djangoproject.com/en/5.2/ref/contrib/auth/
  • Model field reference (auto_now, auto_now_add) — docs.djangoproject.com/en/5.2/ref/models/fields/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.