Build a Lightweight Audit Trail in Django with a Custom TrackingField (10-Step Guide)
Learn how to build a lightweight audit trail in Django using a custom TrackingField backed by JSONField. In this step-by-step tutorial, you'll create a reusable field that automatically tracks changes to important model attributes, records previous and new values with timestamps, and helps simplify
Tracking changes to important database fields is essential when debugging production issues.
Instead of relying solely on application logs or installing heavy audit libraries,
you can build a lightweight audit system directly into your Django models using a custom
JSONField.
In this tutorial, you'll build a reusable TrackingField that automatically records changes whenever selected model fields are updated. By the end of this guide, you'll have a simple auditing solution that works across multiple models.
Prerequisites
-
Python 3.10+
-
Django 4.x or newer
-
Basic knowledge of Django models
-
Basic understanding of Django signals
Step 1 — Understand the Problem
Imagine an order suddenly changes from Pending directly to Shipped.
The customer complains, but your logs don't clearly explain what happened. You check the database, yet the original value is already gone.
This is a common problem because databases only store the current state—not the history of how a record reached its current value.
Common approaches include:
-
Application logging
-
History tables
-
Third-party audit packages
While these work well, they may introduce additional complexity, storage overhead, or require significant configuration.
Our goal is much simpler: automatically record changes for only the fields that matter.
Step 2 — Create a New Django App
If you don't already have an application for reusable model utilities, create one.
`python manage.py startapp core`
Your project structure might look like this:
`project/
│
├── core/
│ ├── fields.py
│ ├── models.py
│ └── apps.py
│
├── orders/
├── manage.py
└── settings.py
`
We'll place our reusable field inside
core/fields.py.
Step 3 — Create the TrackingField
Our custom field extends Django's built-in
JSONField.
The purpose of this class is to:
-
Store change history as JSON
-
Know which fields should be monitored
-
Provide reusable functionality across models
core/fields.py
`
from django.db import models
class TrackingField(models.JSONField):
def __init__(self, track_fields=None, *args, **kwargs):
self.track_fields = track_fields or []
kwargs.setdefault("default", dict)
kwargs.setdefault("blank", True)
super().__init__(*args, **kwargs)
`
What does this code do?
Code Purpose
models.JSONField Stores change history as JSON
track_fields Defines which fields should be monitored
default=dict Creates an empty JSON object for new records
blank=True Makes the field optional in Django forms
At this point, the field only stores configuration. It doesn't detect changes yet—that's what we'll implement in the next section using Django signals.
Step 4 — Detect Changes Before Saving
Now that we have our custom TrackingField, the next step is to
detect when one of the tracked fields changes.
Django provides model signals that allow us to execute custom logic before or
after a model is saved. In this implementation, we'll use the
pre_save signal so we can compare the existing database record with
the updated object before it is written to the database.
Update core/fields.py
`
from django.db import models
from django.db.models.signals import pre_save
class TrackingField(models.JSONField):
def __init__(self, track_fields=None, *args, **kwargs):
self.track_fields = track_fields or []
kwargs.setdefault("default", dict)
kwargs.setdefault("blank", True)
super().__init__(*args, **kwargs)
def contribute_to_class(self, cls, name):
super().contribute_to_class(cls, name)
pre_save.connect(self.track_changes, sender=cls)
def track_changes(self, sender, instance, **kwargs):
if not instance.pk:
return
try:
original = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
return
# Comparison will be implemented next.
`
How It Works
Method Purpose
contribute_to_class() Registers the signal when the model is loaded.
pre_save Runs before every save().
sender.objects.get() Retrieves the existing database record for comparison.
At this point we know both versions of the object:
-
The original record stored in the database.
-
The updated object waiting to be saved.
Step 5 — Record the Changes Automatically
Once we have both objects, we simply compare each tracked field. Whenever a value changes, we'll store the old value, new value and timestamp inside our JSONField.
`
from django.utils.timezone import now
def track_changes(self, sender, instance, **kwargs):
if not instance.pk:
return
try:
original = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
return
history = getattr(instance, self.attname) or {}
updated_fields = {}
for field in self.track_fields:
old_value = getattr(original, field)
new_value = getattr(instance, field)
if old_value != new_value:
updated_fields[field] = {
"from": old_value,
"to": new_value
}
if updated_fields:
history[now().isoformat()] = updated_fields
setattr(instance, self.attname, history)
`
Example Output
`
{
"2026-07-21T09:42:11": {
"status": {
"from": "Pending",
"to": "Paid"
},
"total": {
"from": 150,
"to": 200
}
}
}
`
Every save automatically records only the fields that actually changed, keeping the audit history concise and easy to read.
Step 6 — Add TrackingField to Your Model
Now that the custom field is complete, integrating it into a Django model only requires one additional field.
orders/models.py
`
from django.db import models
from core.fields import TrackingField
class Order(models.Model):
STATUS_CHOICES = [
("Pending", "Pending"),
("Paid", "Paid"),
("Shipped", "Shipped"),
("Cancelled", "Cancelled"),
]
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default="Pending"
)
total = models.DecimalField(
max_digits=10,
decimal_places=2
)
assigned_to = models.ForeignKey(
"auth.User",
null=True,
blank=True,
on_delete=models.SET_NULL
)
field_history = TrackingField(
track_fields=[
"status",
"total",
"assigned_to"
]
)
`
That's it. Every time an order is updated, the selected fields are automatically audited.
Updating a Record
`
order = Order.objects.get(pk=1)
order.status = "Paid"
order.total = 250
order.save()
`
After saving, the history field is updated automatically without writing any additional audit logic.
Because the logic lives inside the custom field, every model can reuse the same implementation without duplicated code.
Step 7 — Query the Audit History
Since the audit information is stored as JSON, retrieving it is straightforward. You can expose it through your API, display it in Django Admin, or use it while investigating production issues.
View an Object's History
`
order = Order.objects.get(pk=1)
print(order.field_history)
`
Find Records That Have History
`
orders = Order.objects.exclude(
field_history={}
)
`
Display History in Django Admin
`
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
readonly_fields = [
"field_history"
]
`
Common Use Cases
-
Debug unexpected production behaviour
-
Review status changes
-
Track assignment history
-
Display audit history in admin panels
-
Include change history in REST APIs
Final Thoughts
A lightweight audit trail can save hours of debugging by recording only the
changes that matter. By combining a reusable custom TrackingField
with Django's pre_save signal, you gain valuable insight into how
your data evolves over time without introducing additional database tables or
external dependencies.
Note: This implementation is ideal for lightweight auditing.
If your application requires complete object versioning, indexed history
queries, or compliance-grade auditing, consider using dedicated packages such
as django-simple-history.
Inspiration
This tutorial was inspired by the article "The Django Field That Has Saved Me the Most Bugs in Production" by Jose Daniel García Suero. The implementation and explanations presented here are an original walkthrough designed to help developers understand how to build a lightweight audit trail using Django's extensibility features.