Django’s audit log for model changes is less about what happened and more about why and how it happened, turning your database into a time machine that remembers not just state, but intent.

Let’s see it in action. Imagine a simple Product model:

# models.py
from django.db import models
from simple_history.models import HistoricalRecords

class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    description = models.TextField(blank=True)

    history = HistoricalRecords()

    def __str__(self):
        return self.name

And the corresponding migration. Now, when you create, update, or delete a Product instance, simple_history (a popular library for this) automatically logs the changes.

# In a Django shell or view
from myapp.models import Product

# Create
product1 = Product.objects.create(name="Laptop", price="1200.00", description="Powerful computing")
print(product1.history.count()) # Output: 1

# Update
product1.price = "1150.00"
product1.save()
print(product1.history.count()) # Output: 2

# Another update
product1.description = "Sleek and powerful computing"
product1.save()
print(product1.history.count()) # Output: 3

# Accessing history
latest_version = product1.history.latest()
print(latest_version.price) # Output: 1150.00
print(latest_version.history_date) # Timestamp of the change

previous_version = product1.history.earlier()
print(previous_version.price) # Output: 1200.00

# Delete
product1.delete()
print(product1.history.count()) # Output: 4 (The delete itself is logged as a historical record)

The core problem this solves is the "who did what, when, and why" question that plain database logs often can’t answer. You get a granular, per-field history of every modification. This isn’t just a backup; it’s an auditable trail. The HistoricalRecords object, when added to your model, creates a parallel historical model. Every time the original model is saved, a new record is created in this historical model, capturing the state of the object before the save. This includes who made the change (if you’re using Django’s authentication system) and when.

The history attribute on your model instance is your gateway. history.all() gives you all historical versions. history.latest() and history.earlier() navigate through time. You can also query historical records directly:

# Find all historical versions of Product with price > 1000
from simple_history.models import HistoricalRecords
from django.utils import timezone

historical_products = HistoricalRecords.get_history_for_model(Product).filter(price__gt=1000)
for record in historical_products:
    print(f"Product: {record.name}, Price: {record.price}, Changed at: {record.history_date}")

The actual mechanism involves a signal handler that fires on pre_save and post_save (and post_delete). When a model instance is about to be saved, the pre_save handler fetches the current state of the object from the database. If the object already exists, this state is then used to create a new historical record before the actual save operation modifies the original object. For new objects, it records the initial creation. Deletions also trigger a final historical record.

The history_user field is populated automatically if you have django.contrib.auth configured and the request context is available. This is crucial for accountability. If you’re not using Django’s auth or need to log changes initiated by background tasks, you’ll need to manually set history_user or use a custom manager to provide the user context.

One of the most powerful, yet often overlooked, aspects is the ability to revert to previous states. This isn’t a simple undo button; it’s a programmatic way to reconstruct a past state.

# Revert to the state before the last price change
product_to_revert = Product.objects.get(id=1)
previous_state = product_to_revert.history.earlier() # Get the state *before* the current one

# Create a new instance with the old data
reverted_product = Product(
    id=product_to_revert.id, # Keep the same ID, or create a new one
    name=previous_state.name,
    price=previous_state.price,
    description=previous_state.description
)
reverted_product.save() # This will create a *new* history entry reflecting the revert.

This process demonstrates that history records are immutable snapshots. You don’t modify a historical record; you create a new record that reflects the desired past state. The history_object_id field on the historical model links back to the original object’s primary key, allowing you to track all historical versions associated with a specific instance.

The next logical step is to integrate this audit trail into your application’s UI for non-technical users, perhaps by building a "version history" view for each model.

Want structured learning?

Take the full Django course →