An audit trail is a typed changeset, not a log line
Most Django projects discover their audit requirement late. A compliance reviewer asks who moved an application to the next issuance tier and when, and the answer has to be assembled from application logs, a request id, and somebody's recollection of which deploy was live that week. That is forensics, not audit. An audit trail is a data structure the application produces at write time: a typed, field-level changeset with an actor already resolved and attached. Everything reconstructed afterwards is an inference with a convincing timestamp on it.
We kept rebuilding the same thing across compliance tooling, moderation panels and payment back offices, so we extracted it into a small library we maintain in the open: model-audit. It is pre-alpha and the public API is not stable yet, but the two decisions underneath it are the ones we would argue for in any codebase, with or without this package.
The failure mode: objects that were flattened into strings#
The usual first implementation is a log call in save() or in a signal handler: the model label, the primary key, the user, and an f-string of what changed. It survives contact with a code review because it is honest about being a log line. It fails later, when someone needs to answer a question rather than read a story.
Three things are lost in that flattening. The values were stringified, so "10" and 10 and Decimal("10.00") are now the same character sequence. The field list is whatever the author remembered to interpolate, which drifts the moment a field is added. And the actor is whatever happened to be in scope at that point in the call stack — often a request in a view, and nothing at all in a management command or a queue worker.
Once the record is a string, questions like "which invoices had total changed by a service rather than by a user" become a parsing exercise against log storage. The information was present at the moment of the write and was thrown away in the act of recording it.
A changeset is a value, and it should behave like one#
The alternative is to make the comparison produce a typed object. diff() takes two mappings of field values and returns a Changeset of frozen FieldChange dataclasses:
from model_audit import diff
stored = {"title": "Draft", "views": 10, "published": False}
incoming = {"title": "Release notes", "views": 10, "published": True}
changeset = diff(stored, incoming, exclude=["views"])
bool(changeset) # True
changeset.fields # ('title', 'published')
changeset.as_dict() # {'title': ('Draft', 'Release notes'), 'published': (False, True)}The changeset is ordered, iterable, indexable by field name, and truthy only when something actually differs. That last property is worth more than it looks: "an update that changed nothing produces no audit record" stops being a special case a caller has to remember and becomes a property of the value itself.
Two details in the comparison matter more than the container does.
The first is absence. A field present on only one side is reported with a MISSING marker on the other, exposed as is_addition and is_removal on the change. A field that was never set is a different state from a field explicitly set to None, and that distinction dies the moment you render either one into text. In an audit trail for a document workflow or a KYC review, "this attribute appeared" and "this attribute was cleared" are genuinely different events.
The second is equality. Python will happily tell you that True == 1, which means a naive comparison silently misses a column that changed from an integer flag to a boolean one:
from model_audit import diff
diff({"verified": 1}, {"verified": True}).fields # ('verified',)The diff treats a bool replacing a number as a real change. This is the kind of rule that is easy to state, easy to get wrong once, and impossible to notice afterwards — because the evidence that it was wrong is precisely the record that was never written.
The core has no framework in it#
The module that does the comparison imports dataclasses, typing and collections.abc. It does not import Django. The framework layer sits on top and does one job: feed the core two mappings of model state.
That separation is not architectural tidiness, it is what makes the guarantee testable. The claim an audit system makes is "we detect field-level change correctly and completely." If that claim can only be exercised through the ORM, every test of it also tests settings loading, database setup, signal wiring and save semantics, and the failures blur together. With a framework-free core, the claim is checked in isolation:
from model_audit import MISSING, diff
def test_field_present_on_one_side_is_an_addition():
changeset = diff({"status": "draft"}, {"status": "draft", "reviewer_id": 7})
assert changeset.fields == ("reviewer_id",)
change = changeset["reviewer_id"]
assert change.is_addition
assert change.old is MISSING
assert change.new == 7No database, no settings module, no fixtures. The second payoff is reuse: the same core compares an incoming API payload against stored state, or two versions of a configuration document, or records from a store that is not an ORM at all. We have written that comparison by hand in several systems that had nothing in common except needing to know what changed.
Who: resolved at the write, not inferred later#
The other half of an audit row is attribution, and it is where most implementations quietly reach for a global. A thread-local holding the current request is convenient and works until the change happens in a Celery task, a management command, or a webhook handler — at which point the audit row either says nothing or, worse, says whatever the last request left behind.
We made resolution explicit and total. The call site passes whatever it already has, and the resolver normalises it:
from model_audit import SYSTEM, Actor, resolve_actor
resolve_actor(request) # Actor(kind=USER, id='42', label='ada')
resolve_actor(request.user) # same
resolve_actor("billing-worker") # Actor(kind=SERVICE, id='billing-worker', ...)
resolve_actor(None) # UNKNOWN
resolve_actor(None, fallback=SYSTEM)
Actor.user(42, "ada").as_dict() # {'kind': 'user', 'id': '42', 'label': 'ada'}An unauthenticated user resolves to ANONYMOUS, an absent one to UNKNOWN. Neither is an error. An audit layer that raises inside a save turns a missing attribution into an outage, and the first production incident of that kind ends with the audit layer being disabled. Recording the gap honestly is better than refusing the write, and actor.is_known lets a reader separate an attributed change from an unattributed one instead of guessing.
Signals are the case where nothing can be threaded through, so there is an opt-in ambient actor bound for the duration of a block:
from model_audit import actor_context, current_actor, resolve_actor
with actor_context(request):
current_actor() # the request's actor
resolve_actor(None, ambient=True) # same, via the resolver
current_actor() # UNKNOWN againThe trade-off is deliberate and documented rather than hidden. The binding is context-local, so it follows await inside a task but is not inherited by threads or executor workers started within the block, and it makes the actor invisible at the call site. That is why resolve_actor() never reads it unless asked with ambient=True: the convenient path stays available, but you have to name it.
Recording on save, and what it costs to run#
Recording is opt-in per model. register() connects post_init and post_save for that model and leaves every other model untouched.
from model_audit import register, subscribe
register(Invoice, exclude=["search_vector"])
@subscribe
def write_to_log(record):
print(record.action, record.model, record.pk, record.changes.as_dict())
invoice.total = 120
invoice.save()
# updated app.Invoice 7 {'total': (100, 120)}A record carries the model label, the primary key, the Action, the actor, the changeset and a timestamp, and as_dict() flattens it for whatever store you keep it in. Because signals carry no call-site actor, the recorder reads the ambient one — wrapping the request or the job in actor_context() is what turns UNKNOWN into a name.
The running cost is one extra snapshot of the audited fields per loaded instance and one comparison per save on registered models. The field selection is where that stays cheap: exclude drops single fields and fields narrows to an allow-list, a foreign key can be named either way, deferred fields are skipped, and relations are read by their stored id so snapshotting never issues an extra query. Timestamps that every save touches are dropped by default through NOISY_FIELDS, because an audit trail in which every row reports updated_at teaches readers to skim.
An audit row is not a second copy of the table#
The last constraint is one we learned from systems where the audit store outlives and out-reads the data it watches. Retention on an audit table is usually longer, and the set of people who can read it is usually wider, than for the records it describes. So values of fields whose names read like a secret, a phone number or an identity document are replaced by REDACTED before the record reaches any receiver. The trail still proves that the field changed, and by whom, without becoming the easiest place in the system to harvest what changed.
That is the whole argument. An audit trail should be produced, not reconstructed; typed, not stringified; attributed at the write, not inferred from a log; and the part that decides what counts as a change should be small enough and plain enough to test without a framework in the room.