python 105 lines · 3 tabs

Auditing Django Model Field Changes in an Overridden save() Method

Shared by codesnips Aug 2026
3 tabs
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction

from .middleware import get_current_user


class AuditLogEntry(models.Model):
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.CharField(max_length=64)
    target = GenericForeignKey("content_type", "object_id")
    actor_id = models.IntegerField(null=True, blank=True)
    changes = models.JSONField(default=dict)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [models.Index(fields=["content_type", "object_id"])]
        ordering = ["-created_at"]


class AuditableModel(models.Model):
    TRACKED_FIELDS = ()

    class Meta:
        abstract = True

    @classmethod
    def from_db(cls, db, field_names, values):
        instance = super().from_db(db, field_names, values)
        instance._loaded_values = dict(zip(field_names, values))
        return instance

    def _diff_tracked_fields(self):
        loaded = getattr(self, "_loaded_values", {})
        changes = {}
        for field in self.TRACKED_FIELDS:
            old = loaded.get(field)
            new = getattr(self, field)
            if old != new:
                changes[field] = {"from": old, "to": new}
        return changes

    def save(self, *args, **kwargs):
        changes = self._diff_tracked_fields()
        with transaction.atomic():
            super().save(*args, **kwargs)
            if changes:
                actor = get_current_user()
                AuditLogEntry.objects.create(
                    content_type=ContentType.objects.get_for_model(self),
                    object_id=str(self.pk),
                    actor_id=getattr(actor, "id", None),
                    changes=changes,
                )
        self._loaded_values = {
            field: getattr(self, field) for field in self.TRACKED_FIELDS
        }
3 files · python Explain with highlit

Audit logging answers a recurring question in line-of-business apps: who changed what, and when? This snippet captures per-field changes for a Django model at the moment it is persisted, writing one durable AuditLogEntry row per save that mutated tracked fields. The approach keeps the audit trail close to the write path rather than reconstructing it after the fact, so the recorded diff reflects exactly the transition that hit the database.

In models.py, AuditLogEntry is a plain model that stores a JSON changes payload and points at the audited object through a GenericForeignKey, so a single table can audit many model types. The AuditableModel abstract base adds the mechanics: TRACKED_FIELDS declares which columns matter, and from_db stashes the values loaded from the database in _loaded_values. Snapshotting at load time is the crux — it gives a reliable "before" image without a second query, and it correctly treats brand-new instances (never loaded) as having no prior state.

The overridden save computes changed_fields by comparing _loaded_values against current attribute values, delegates to super().save() inside a transaction.atomic() block, and only then writes the AuditLogEntry. Wrapping both writes in one transaction means the audit row and the data change commit or roll back together, so the log can never claim a change that didn't happen. After saving, _loaded_values is refreshed so a second save on the same in-memory instance diffs against the new baseline.

Capturing the acting user is handled in middleware.py. Because model code has no request context, AuditContextMiddleware stashes the current user in a thread-local and exposes it via get_current_user, which save reads to populate actor_id. This is a common pragmatic pattern, though it relies on thread-per-request semantics and should be used cautiously under async or thread-pooled workers.

Finally, orders.py shows a concrete Order subclass listing its tracked fields. The trade-offs: from_db snapshots add a little memory per instance, update() querysets bypass this entirely, and the JSON diff intentionally omits fields not listed. For fine-grained, save-time audit trails on a handful of important models, this is a focused and testable alternative to heavier signal-based frameworks.


Related snips

Share this code

Here's the card — post it anywhere.

Auditing Django Model Field Changes in an Overridden save() Method — share card
Link copied