Model Auditing and Version History
Track changes to your Django models for compliance, debugging, and accountability. This guide covers two battle-tested approaches: snapshot-based history with django-simple-history and delta-based audit logs with django-auditlog.
Overview
Audit trails answer critical questions: Who changed this data? When? What was the previous value? In regulated industries, audit logs are legally required. In any production system, they’re invaluable for debugging and understanding data flow.
The modular monolith already provides BaseModel with created_at and last_modified_at timestamps. These tell you when data changed but not what changed or who changed it. Full audit logging captures this additional context.
Two approaches exist, each with distinct trade-offs:
Snapshot-based history (django-simple-history): Stores a complete copy of the model on every change. Enables time-travel queries (“what did this record look like last Tuesday?”) and one-click revert in the admin. Higher storage cost, but simpler queries and built-in recovery.
Delta-based audit logs (django-auditlog): Stores only the changed fields as JSON. Lower storage footprint, but reconstructing historical state requires walking the change log. Better for high-volume, write-heavy systems where storage matters.
Choosing an Approach
Use this decision matrix to select the right tool:
Consideration |
django-simple-history |
django-auditlog |
|---|---|---|
Storage model |
Full row snapshots |
JSON field deltas |
Time-travel queries |
Yes ( |
Manual reconstruction |
Revert capability |
Built-in admin action |
Manual implementation |
Storage efficiency |
Lower (full copies) |
Higher (changes only) |
Query complexity |
Simple (standard ORM) |
Moderate (JSON parsing) |
Admin integration |
Rich (view/revert UI) |
Basic (log display) |
Django/Python support |
4.2-6.0 / 3.10-3.14 |
4.2+ / 3.9+ |
Choose django-simple-history when:
You need point-in-time queries (“show me the order as of 3pm yesterday”)
Legal or compliance requirements mandate full historical records
Non-technical users need to view and revert changes in the admin
Models have relatively few writes and storage cost is acceptable
Choose django-auditlog when:
You primarily need “who changed what” audit trails
Models have frequent updates and storage efficiency matters
You don’t need to reconstruct full historical state often
You want a lighter-weight solution with minimal database overhead
Using both: In some systems, you might use django-simple-history for critical business entities (orders, contracts, user profiles) and django-auditlog for high-volume operational data (logs, metrics, temporary records).
django-simple-history
django-simple-history automatically creates a parallel “historical” model for each tracked model, storing a complete snapshot on every create, update, and delete.
Installation
Add the package to your requirements:
# requirements/base.txt
django-simple-history==3.11.0
Configure Django settings:
# config/settings/base.py
INSTALLED_APPS = [
# ... Django apps ...
"simple_history",
# ... your apps ...
]
MIDDLEWARE = [
# ... other middleware ...
"simple_history.middleware.HistoryRequestMiddleware",
]
The middleware automatically captures the current user from the request, so historical records include who made each change.
Run migrations to create the history tables:
docker compose -f docker-compose.local.yml run --rm django python manage.py migrate
Adding History to Models
Add HistoricalRecords to any model you want to track:
# {project_slug}/orders/models.py
from django.db import models
from simple_history.models import HistoricalRecords
from {project_slug}.core.models import BaseModel
class Order(BaseModel):
status = models.CharField(max_length=50, default="pending")
total = models.DecimalField(max_digits=10, decimal_places=2)
customer_notes = models.TextField(blank=True)
internal_notes = models.TextField(blank=True)
history = HistoricalRecords()
def __str__(self):
return f"Order {self.id} - {self.status}"
This creates a HistoricalOrder model with all the same fields plus:
history_id: Primary key for the historical recordhistory_date: When the change occurred (indexed by default)history_type:+(create),~(update), or-(delete)history_user: The user who made the change (via middleware)history_change_reason: Optional explanation for the change
Generate and run migrations:
docker compose -f docker-compose.local.yml run --rm django python manage.py makemigrations
docker compose -f docker-compose.local.yml run --rm django python manage.py migrate
Excluding Fields
For fields that change frequently but don’t need tracking (like last_login or cache fields), exclude them:
class Order(BaseModel):
# ... fields ...
cache_key = models.CharField(max_length=100, blank=True)
history = HistoricalRecords(excluded_fields=["cache_key"])
For fields inherited from BaseModel, you may want to exclude last_modified_at since the history already tracks timestamps:
history = HistoricalRecords(excluded_fields=["last_modified_at"])
Querying History
Access historical records through the history manager:
# {project_slug}/orders/selectors.py
from datetime import datetime
from django.db.models import QuerySet
from .models import Order
def order_history(*, order: Order) -> QuerySet:
"""Return all historical versions of an order."""
return order.history.all()
def order_as_of(*, order_id: int, timestamp: datetime) -> Order:
"""Return the order's state at a specific point in time."""
return Order.history.as_of(timestamp).get(id=order_id)
def order_changes_by_user(*, user_id: int) -> QuerySet:
"""Return all order changes made by a specific user."""
return Order.history.filter(history_user_id=user_id)
def order_recent_changes(*, hours: int = 24) -> QuerySet:
"""Return orders changed in the last N hours."""
from django.utils import timezone
from datetime import timedelta
cutoff = timezone.now() - timedelta(hours=hours)
return Order.history.filter(history_date__gte=cutoff)
Comparing Versions
Use diff_against() to see what changed between versions:
# {project_slug}/orders/services.py
def order_get_changes(*, order: Order) -> list[dict]:
"""Return a list of changes for an order."""
changes = []
history = order.history.all()
for i, record in enumerate(history):
if record.prev_record:
delta = record.diff_against(record.prev_record)
changes.append({
"date": record.history_date,
"user": record.history_user,
"type": record.history_type,
"changes": [
{
"field": change.field,
"old": change.old,
"new": change.new,
}
for change in delta.changes
],
})
return changes
Setting Change Reasons
Add context to changes by setting a reason:
# In a service function
from simple_history.utils import update_change_reason
def order_cancel(*, order: Order, reason: str, cancelled_by: User) -> Order:
"""Cancel an order with a documented reason."""
order.status = "cancelled"
order.save()
update_change_reason(order, f"Cancelled: {reason}")
return order
Admin Integration
Use SimpleHistoryAdmin to add history viewing and revert functionality:
# {project_slug}/orders/admin.py
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from .models import Order
@admin.register(Order)
class OrderAdmin(SimpleHistoryAdmin):
list_display = ["id", "status", "total", "created_at"]
list_filter = ["status"]
search_fields = ["id"]
# Fields to show in the history list view
history_list_display = ["status", "total"]
This adds a “History” button to each order’s change page, showing all versions with the ability to view details and revert to any previous state.
Disabling Revert in Production
If you want history viewing without revert capability:
# config/settings/production.py
SIMPLE_HISTORY_REVERT_DISABLED = True
Bulk Operations
Standard bulk_create() and bulk_update() bypass signals and won’t create history. Use the provided utilities:
# {project_slug}/orders/services.py
from simple_history.utils import bulk_create_with_history, bulk_update_with_history
from .models import Order
def orders_bulk_create(*, orders_data: list[dict]) -> list[Order]:
"""Create multiple orders with history tracking."""
orders = [Order(**data) for data in orders_data]
return bulk_create_with_history(orders, Order)
def orders_bulk_update_status(*, orders: list[Order], status: str) -> list[Order]:
"""Update status for multiple orders with history tracking."""
for order in orders:
order.status = status
bulk_update_with_history(orders, Order, ["status"])
return orders
django-auditlog
django-auditlog stores changes as JSON deltas in a single LogEntry table, providing a lightweight audit trail without the storage overhead of full snapshots.
Installation
Add the package to your requirements:
# requirements/base.txt
django-auditlog==3.3.0
Configure Django settings:
# config/settings/base.py
INSTALLED_APPS = [
# ... Django apps ...
"auditlog",
# ... your apps ...
]
MIDDLEWARE = [
# ... other middleware ...
"auditlog.middleware.AuditlogMiddleware",
]
Run migrations:
docker compose -f docker-compose.local.yml run --rm django python manage.py migrate
Registering Models
Register models in AppConfig.ready() for clean separation in the modular monolith:
# {project_slug}/orders/apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
name = "{project_slug}.orders"
verbose_name = "Orders"
def ready(self):
from auditlog.registry import auditlog
from .models import Order, OrderItem
auditlog.register(Order)
auditlog.register(OrderItem)
Alternative: Use the decorator directly on models:
# {project_slug}/orders/models.py
from auditlog.registry import auditlog
from {project_slug}.core.models import BaseModel
@auditlog.register()
class Order(BaseModel):
status = models.CharField(max_length=50, default="pending")
total = models.DecimalField(max_digits=10, decimal_places=2)
The registry approach is preferred in a modular monolith because it keeps model definitions clean and centralizes registration in the app configuration.
Include and Exclude Fields
Control which fields are tracked:
# {project_slug}/orders/apps.py
def ready(self):
from auditlog.registry import auditlog
from .models import Order
# Only track specific fields
auditlog.register(
Order,
include_fields=["status", "total"],
)
# Or exclude fields you don't care about
auditlog.register(
Order,
exclude_fields=["internal_notes", "last_modified_at"],
)
Querying Audit Logs
Query the LogEntry model to retrieve audit information:
# {project_slug}/orders/selectors.py
from auditlog.models import LogEntry
from django.contrib.contenttypes.models import ContentType
from django.db.models import QuerySet
from .models import Order
def order_audit_log(*, order: Order) -> QuerySet[LogEntry]:
"""Return all audit log entries for an order."""
return LogEntry.objects.get_for_object(order)
def orders_changed_by_user(*, user_id: int) -> QuerySet[LogEntry]:
"""Return all order-related changes by a specific user."""
content_type = ContentType.objects.get_for_model(Order)
return LogEntry.objects.filter(
content_type=content_type,
actor_id=user_id,
)
def audit_log_for_period(*, start_date, end_date) -> QuerySet[LogEntry]:
"""Return all audit entries within a date range."""
return LogEntry.objects.filter(
timestamp__range=(start_date, end_date),
).select_related("content_type", "actor")
Understanding LogEntry Fields
Each LogEntry contains:
log_entry.content_type # The model that was changed
log_entry.object_pk # Primary key of the changed object
log_entry.object_repr # String representation at time of change
log_entry.action # 0=create, 1=update, 2=delete
log_entry.changes # JSON dict of field changes
log_entry.actor # User who made the change
log_entry.timestamp # When the change occurred
log_entry.remote_addr # IP address (if middleware configured)
The changes field contains the old and new values:
# Example changes dict for an update
{
"status": ["pending", "shipped"],
"total": ["99.99", "109.99"],
}
Access changes programmatically:
def format_audit_entry(log_entry: LogEntry) -> dict:
"""Format a log entry for display."""
action_names = {0: "created", 1: "updated", 2: "deleted"}
return {
"action": action_names.get(log_entry.action, "unknown"),
"model": log_entry.content_type.model,
"object_id": log_entry.object_pk,
"user": str(log_entry.actor) if log_entry.actor else "system",
"timestamp": log_entry.timestamp,
"changes": log_entry.changes,
}
Admin Integration
View audit logs in the Django admin:
# {project_slug}/orders/admin.py
from auditlog.models import LogEntry
from django.contrib import admin
@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin):
list_display = [
"timestamp",
"content_type",
"object_repr",
"action",
"actor",
]
list_filter = ["action", "content_type", "timestamp"]
search_fields = ["object_repr", "actor__email"]
readonly_fields = [
"content_type",
"object_pk",
"object_repr",
"action",
"changes",
"actor",
"timestamp",
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
Integration with Domain Events
Connect audit logging to the modular monolith’s domain event system. This enables other modules to react to audit events without direct coupling.
Define an Audit Event
# {project_slug}/domain_events/events.py
from dataclasses import dataclass, field
from datetime import datetime
from .base import DomainEvent
@dataclass
class ModelAuditedEvent(DomainEvent):
"""Emitted when a model is created, updated, or deleted."""
model_name: str
instance_id: int
action: str # "create", "update", "delete"
changed_fields: list[str] = field(default_factory=list)
changed_by_id: int | None = None
timestamp: datetime = field(default_factory=datetime.now)
Emit Events from django-simple-history
Use the post_create_historical_record signal:
# {project_slug}/core/audit_events.py
from django.dispatch import receiver
from simple_history.signals import post_create_historical_record
from {project_slug}.domain_events.bus import event_bus
from {project_slug}.domain_events.events import ModelAuditedEvent
ACTION_MAP = {
"+": "create",
"~": "update",
"-": "delete",
}
@receiver(post_create_historical_record)
def emit_audit_domain_event(sender, instance, history_instance, **kwargs):
"""Emit a domain event when a historical record is created."""
# Get changed fields by comparing to previous record
changed_fields = []
if history_instance.prev_record:
delta = history_instance.diff_against(history_instance.prev_record)
changed_fields = [change.field for change in delta.changes]
event = ModelAuditedEvent(
model_name=instance.__class__.__name__,
instance_id=instance.pk,
action=ACTION_MAP.get(history_instance.history_type, "unknown"),
changed_fields=changed_fields,
changed_by_id=(
history_instance.history_user.id
if history_instance.history_user
else None
),
timestamp=history_instance.history_date,
)
event_bus.publish(event)
Register the signal handler in your app config:
# {project_slug}/core/apps.py
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "{project_slug}.core"
verbose_name = "Core"
def ready(self):
from . import audit_events # noqa: F401
Emit Events from django-auditlog
Use auditlog’s post-log signal:
# {project_slug}/core/audit_events.py
from auditlog.models import LogEntry
from django.db.models.signals import post_save
from django.dispatch import receiver
from {project_slug}.domain_events.bus import event_bus
from {project_slug}.domain_events.events import ModelAuditedEvent
ACTION_MAP = {
LogEntry.Action.CREATE: "create",
LogEntry.Action.UPDATE: "update",
LogEntry.Action.DELETE: "delete",
}
@receiver(post_save, sender=LogEntry)
def emit_audit_domain_event(sender, instance, created, **kwargs):
"""Emit a domain event when an audit log entry is created."""
if not created:
return
changed_fields = list(instance.changes.keys()) if instance.changes else []
event = ModelAuditedEvent(
model_name=instance.content_type.model,
instance_id=int(instance.object_pk),
action=ACTION_MAP.get(instance.action, "unknown"),
changed_fields=changed_fields,
changed_by_id=instance.actor_id,
timestamp=instance.timestamp,
)
event_bus.publish(event)
Subscribe to Audit Events
Other modules can react to audit events:
# {project_slug}/notifications/handlers.py
import structlog
from {project_slug}.domain_events.bus import event_bus
from {project_slug}.domain_events.events import ModelAuditedEvent
logger = structlog.get_logger(__name__)
def handle_sensitive_model_change(event: ModelAuditedEvent):
"""Alert on changes to sensitive models."""
sensitive_models = {"user", "payment", "contract"}
if event.model_name.lower() in sensitive_models:
logger.warning(
"sensitive_model_changed",
model=event.model_name,
instance_id=event.instance_id,
action=event.action,
changed_by=event.changed_by_id,
)
# Send alert, create compliance record, etc.
# Register in apps.py ready()
event_bus.subscribe(ModelAuditedEvent, handle_sensitive_model_change)
Testing Audit Functionality
django-simple-history Tests
# {project_slug}/orders/tests/test_audit.py
import pytest
from django.utils import timezone
from datetime import timedelta
from {project_slug}.orders.models import Order
from {project_slug}.orders.tests.factories import OrderFactory
@pytest.mark.django_db
class TestOrderHistory:
def test_create_generates_history_record(self):
order = OrderFactory()
assert order.history.count() == 1
record = order.history.first()
assert record.history_type == "+"
def test_update_generates_history_record(self):
order = OrderFactory(status="pending")
order.status = "shipped"
order.save()
assert order.history.count() == 2
latest = order.history.first()
assert latest.history_type == "~"
assert latest.status == "shipped"
def test_delete_generates_history_record(self):
order = OrderFactory()
order_id = order.id
order.delete()
# History persists after deletion
history = Order.history.filter(id=order_id)
assert history.count() == 2
assert history.first().history_type == "-"
def test_as_of_returns_historical_state(self):
order = OrderFactory(status="pending")
created_time = timezone.now()
# Wait briefly and update
order.status = "shipped"
order.save()
# Query historical state
historical = Order.history.as_of(created_time)
assert historical.filter(id=order.id).first().status == "pending"
def test_diff_against_shows_changes(self):
order = OrderFactory(status="pending", total=100)
order.status = "shipped"
order.total = 150
order.save()
latest = order.history.first()
delta = latest.diff_against(latest.prev_record)
field_names = [change.field for change in delta.changes]
assert "status" in field_names
assert "total" in field_names
def test_history_user_captured_from_request(self, client, user):
client.force_login(user)
# Make a change through a view that triggers history
# (Implementation depends on your views)
order = OrderFactory()
order.status = "updated_via_view"
order.save()
# In actual request context, history_user would be set
# This test demonstrates the pattern
django-auditlog Tests
# {project_slug}/orders/tests/test_audit.py
import pytest
from auditlog.models import LogEntry
from django.contrib.contenttypes.models import ContentType
from {project_slug}.orders.models import Order
from {project_slug}.orders.tests.factories import OrderFactory
@pytest.mark.django_db
class TestOrderAuditLog:
def test_create_generates_log_entry(self):
order = OrderFactory()
entries = LogEntry.objects.get_for_object(order)
assert entries.count() == 1
assert entries.first().action == LogEntry.Action.CREATE
def test_update_generates_log_entry_with_changes(self):
order = OrderFactory(status="pending")
order.status = "shipped"
order.save()
entries = LogEntry.objects.get_for_object(order)
update_entry = entries.filter(action=LogEntry.Action.UPDATE).first()
assert update_entry is not None
assert "status" in update_entry.changes
assert update_entry.changes["status"] == ["pending", "shipped"]
def test_excluded_fields_not_logged(self):
# Assuming internal_notes is excluded
order = OrderFactory(internal_notes="secret")
order.internal_notes = "updated secret"
order.save()
entries = LogEntry.objects.get_for_object(order)
for entry in entries:
assert "internal_notes" not in (entry.changes or {})
def test_actor_captured_from_middleware(self, rf, user):
from auditlog.context import set_actor
with set_actor(user):
order = OrderFactory()
entry = LogEntry.objects.get_for_object(order).first()
assert entry.actor == user
Performance Considerations
History Table Growth
Both approaches create additional database records. Plan for growth:
django-simple-history: Each tracked model gets its own history table. A model with 1 million rows that averages 5 updates per record will have ~5 million history rows.
# Check history table size
from {project_slug}.orders.models import Order
total_orders = Order.objects.count()
total_history = Order.history.count()
avg_versions = total_history / total_orders if total_orders else 0
django-auditlog: All changes go to a single LogEntry table. Monitor its size:
from auditlog.models import LogEntry
total_entries = LogEntry.objects.count()
entries_per_day = LogEntry.objects.filter(
timestamp__date=timezone.now().date()
).count()
Indexing
django-simple-history automatically indexes history_date (since version 3.1). For django-auditlog, consider adding indexes for common queries:
# Custom migration for auditlog
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0001_initial"),
]
operations = [
migrations.AddIndex(
model_name="logentry",
index=models.Index(
fields=["content_type", "object_pk"],
name="auditlog_content_object_idx",
),
),
]
Archiving Old History
For long-running systems, archive or delete old history:
# {project_slug}/core/management/commands/archive_audit_history.py
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
class Command(BaseCommand):
help = "Archive audit history older than specified days"
def add_arguments(self, parser):
parser.add_argument(
"--days",
type=int,
default=365,
help="Archive history older than this many days",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be archived without deleting",
)
def handle(self, *args, **options):
cutoff = timezone.now() - timedelta(days=options["days"])
# For django-simple-history
from {project_slug}.orders.models import Order
old_history = Order.history.filter(history_date__lt=cutoff)
count = old_history.count()
if options["dry_run"]:
self.stdout.write(f"Would archive {count} Order history records")
else:
old_history.delete()
self.stdout.write(f"Archived {count} Order history records")
Common Patterns
Compliance Reporting
Generate audit reports for compliance requirements:
# {project_slug}/orders/selectors.py
from datetime import datetime
from django.db.models import QuerySet
from .models import Order
def order_audit_report(
*,
start_date: datetime,
end_date: datetime,
) -> QuerySet:
"""Generate an audit report for orders in a date range."""
return (
Order.history.filter(history_date__range=(start_date, end_date))
.select_related("history_user")
.values(
"id",
"history_date",
"history_type",
"history_user__email",
"status",
"total",
)
.order_by("id", "history_date")
)
def changes_by_user_report(*, user_id: int) -> list[dict]:
"""Report all changes made by a specific user."""
from collections import defaultdict
changes = defaultdict(list)
for record in Order.history.filter(history_user_id=user_id):
changes[record.id].append({
"date": record.history_date,
"type": record.history_type,
"status": record.status,
})
return dict(changes)
API Endpoints for Audit History
Expose history through your API:
# {project_slug}/orders/api/serializers.py
from rest_framework import serializers
class OrderHistorySerializer(serializers.Serializer):
history_id = serializers.IntegerField()
history_date = serializers.DateTimeField()
history_type = serializers.CharField()
history_user = serializers.SerializerMethodField()
status = serializers.CharField()
total = serializers.DecimalField(max_digits=10, decimal_places=2)
def get_history_user(self, obj):
if obj.history_user:
return obj.history_user.email
return None
# {project_slug}/orders/api/views.py
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from ..models import Order
from .serializers import OrderHistorySerializer, OrderSerializer
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
@action(detail=True, methods=["get"])
def history(self, request, pk=None):
"""Return the change history for this order."""
order = self.get_object()
history = order.history.all().select_related("history_user")
serializer = OrderHistorySerializer(history, many=True)
return Response(serializer.data)
Soft Deletes with History
Combine soft deletes with audit history:
# {project_slug}/orders/models.py
from django.db import models
from simple_history.models import HistoricalRecords
from {project_slug}.core.models import BaseModel
class OrderQuerySet(models.QuerySet):
def active(self):
return self.filter(deleted_at__isnull=True)
class Order(BaseModel):
status = models.CharField(max_length=50, default="pending")
total = models.DecimalField(max_digits=10, decimal_places=2)
deleted_at = models.DateTimeField(null=True, blank=True)
objects = OrderQuerySet.as_manager()
history = HistoricalRecords()
def soft_delete(self):
from django.utils import timezone
self.deleted_at = timezone.now()
self.save() # This creates a history record
The history record shows when and by whom the soft delete occurred.
See Also
Service Layer Patterns - Organize audit-related business logic in services
Event-Driven Architecture - Emit domain events on model changes
Observability and Structured Logging - Structured logging for audit trail debugging