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
Which write paths keep the fields honest
- 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
Command
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.