Timestamps and actors — the cheapest auditing there is
corebeginnerBefore 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.
Think of it as
Think of it as three tiers you climb only as far as the questions require. The first tier is timestamps. `auto_now_add=True` sets a field once on creation and `auto_now=True` updates it on every save, which covers "when" for free. The catch is in the word *save*: both are applied during `Model.save()`, so any path that bypasses `save()` bypasses them — and `QuerySet.update()` does exactly that, running one SQL statement and, as Django's documentation states, running no `save()` methods and emitting no `pre_save`/`post_save` signals. So a bulk update leaves `updated_at` showing a date before the change it made, which is worse than having no field at all because it looks authoritative. When you use `update()` deliberately, set the timestamp in the same call. The second tier is actors, and the obstacle is structural: a model has no idea who is logged in, because the ORM has no access to the request. The two honest options are to pass the actor explicitly into a service function — verbose, and completely clear about where the value came from — or to put the current user in a `ContextVar` set by middleware and read it in `save()`, which is convenient and has the well-known failure that anything running outside a request (a management command, a Celery task, a migration) has no user, so the field must be nullable and the code must cope. Prefer explicit passing for anything that matters. The third tier is history, and the trigger for climbing to it is precise: you need it when a question involves *more than one* change. "Who set the price to £49?" is answerable from `updated_by` only if nobody has touched the row since. "How long was this order in processing?", "who approved it before it was rejected?", "what did this look like in March?" are all unanswerable from a current-state field by construction, no matter how many actor columns you add. A row per change — with the previous value, the new value, the actor and the time — answers all of them, and once it exists the four fields become a convenient cache of the latest row rather than the record itself.
What we're doing: A base model giving every table timestamps and actors, with the actor supplied explicitly and the bulk path handled honestly.
- 7–10
- `null=True` is not laziness. Anything running outside a request has no user, and a non-nullable actor field forces every command to invent a fake one — which is worse than an honest `NULL`.
- 9
- `editable=False` keeps these out of `ModelForm`s and the admin, so nobody can set "who did this" from a form.
- 20–22
- Passing the actor is more typing than a `ContextVar` and it makes the provenance of the value visible at the call site, which is the point for a field whose whole purpose is attribution.
- 27–28
- A `save(update_fields=[…])` that omits `updated_by` writes the other fields and silently drops the actor — so the set has to be widened here.
- 35–39
- The bulk path, done honestly. Because `update()` never calls `save()`, both the timestamp and the actor are set by hand; omitting them leaves rows whose `updated_at` predates their own last change.
Why this works: Every table gets when-and-who for two fields of cost, values written outside a request are honestly `NULL` rather than fabricated, and the bulk path does not leave timestamps lying.
Reading the current user from a global in `save()`
Wrong
Better
What you see: Under concurrent load, rows are occasionally attributed to the wrong user — and the audit trail is confidently, unreproducibly wrong.
Why: A module-level variable is shared by every thread in the process, so two overlapping requests write to the same slot and the second one wins for both. The result is misattribution: an audit field that says user A changed a row user B changed. It cannot be reproduced in single-threaded development, and because the field looks populated nobody suspects it. A `ContextVar` is isolated per thread and per async task; passing the actor explicitly avoids the question entirely.
- 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
What each tier can answer — and where it stops
Together
Which write paths keep the fields honest
Together
Remember: 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.
See also: three kinds of record · what actually needs auditing · idempotency events and history

