Skip to content
Development

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

CodingBowl··6 min read
Photo by Harsh Bairwa on Unsplash

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.