Recently, I worked on a project using Django and Django Rest Framework (DRF). One of the key functional requirements of the project was to track the history of model modifications by keeping information about the user who created each record and the user who last updated it.
Specifically, we needed to implement a mechanism that would automatically record:
each time the data was updated.
Very basic and classic requirement
# common/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone
User = settings.AUTH_USER_MODEL
class AuditModelMixin(models.Model):
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
User,
related_name="%(class)s_created",
on_delete=models.SET_NULL,
null=True,
blank=True,
editable=False,
)
updated_by = models.ForeignKey(
User,
related_name="%(class)s_updated",
on_delete=models.SET_NULL,
null=True,
blank=True,
editable=False,
)
class Meta:
abstract = True
# todos/models.py
from django.db import models
# Create your models here.
# apps/todos/models.py
from django.db import models
from common.models import AuditModelMixin
class Todo(AuditModelMixin):
title = models.CharField(max_length=255)
done = models.BooleanField(default=False)
def __str__(self):
return self.title
python manage.py makemigrations && python manage.py mograte
# todos/serializers.py
from rest_framework import serializers
from .models import Todo
class TodoSerializer(serializers.ModelSerializer):
class Meta:
model = Todo
fields = "__all__"
read_only_fields = (
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
)
# todos/views.py
from rest_framework import viewsets, permissions
from .models import Todo
from .serializers import TodoSerializer
class TodoViewSet(viewsets.ModelViewSet):
queryset = Todo.objects.all()
serializer_class = TodoSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
serializer.save(
created_by=self.request.user,
updated_by=self.request.user
)
def perform_update(self, serializer):
serializer.save(updated_by=self.request.user)
create two users or superuser; here for this example I created a adminuser and a classicuser for the test
Set the urls in the todos app and in the main urls.py file
# todos/urls.py
from rest_framework import routers
from . import views
router = routers.DefaultRouter()
router.register(prefix=r"todos", viewset=views.TodoViewSet, basename="todos")
urlpatterns = router.urls
# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [path("admin/", admin.site.urls), path("", include("todos.urls"))]
Run the dev server
python manage.py runserver
Go to http://localhost:8000/admin/ and login as adminuser then go to http://localhost:8000/todos/ and add a Todo entry.[]
The reponse should be something like this
{
"id": 1,
"created_at": "2025-07-26T12:34:42.100587Z",
"updated_at": "2025-07-26T12:34:42.100951Z",
"title": "Do the code",
"done": false,
"created_by": 2,
"updated_by": 2
}
Now Logout and re-login with another user; here I will log in with the classic user and modify the first todo to put done to true the response should be like this
{
"id": 1,
"created_at": "2025-07-26T12:34:42.100587Z",
"updated_at": "2025-07-26T12:38:34.335263Z",
"title": "Do the code",
"done": true,
"created_by": 2,
"updated_by": 3
}
So the To do was created by the user with the id 2 and modified by the user with the id 3
Every thing works as expected
I don’t like this solution because I have to override every viewset. A better approach might be to create a parent viewset class that inherits from the other viewsets and overrides the perform_create and perform_update methods. However, I don’t like placing business logic in the view layer; I prefer handling these modifications in the serializer layer.
# common/serializers.py
from rest_framework import serializers
class AuditSerializerMixin(serializers.Serializer):
created_by = serializers.HiddenField(
default=serializers.CreateOnlyDefault(serializers.CurrentUserDefault())
)
updated_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
# todos/serializers.py
from rest_framework import serializers
from common.serializers import AuditSerializerMixin
from .models import Todo
class TodoSerializer(AuditSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Todo
fields = "__all__"
read_only_fields = (
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
)
# todos/views.py
from rest_framework import viewsets, permissions
from .models import Todo
from .serializers import TodoSerializer
class TodoViewSet(viewsets.ModelViewSet):
queryset = Todo.objects.all()
serializer_class = TodoSerializer
permission_classes = [permissions.IsAuthenticated]
No create another Todo entry the response should be like this
{
"id": 2,
"created_at": "2025-07-26T12:52:47.821956Z",
"updated_at": "2025-07-26T12:52:47.822179Z",
"title": "This is the second Test",
"done": false
}
We can notice that the created_at and the updated_at no longer presented in the respone; serializers.HiddenField is a DRF field that is never exposed to the client (it’s not required in request data and not shown in browsable API forms). Instead, it is filled automatically by a default value or function provided in its default argument.
The Role of CurrentUserDefault and CreateOnlyDefault
- CurrentUserDefault(): A DRF default class that returns request.user automatically.
- CreateOnlyDefault(default): A wrapper that ensures the default value is only set when creating objects, and not overwritten during updates.
So to solve this problem we can override the to_representation method on the AuditSerializerMixin like this
# common/serializers.py
from rest_framework import serializers
class AuditSerializerMixin(serializers.Serializer):
created_by = serializers.HiddenField(
default=serializers.CreateOnlyDefault(serializers.CurrentUserDefault())
)
updated_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
def to_representation(self, instance):
rep = super().to_representation(instance)
rep["created_by"] = instance.created_by.id
rep["updated_by"] = instance.updated_by.id
return rep
re create another Todo entry and every thing will work, the response should be something like this
{
"id": 3,
"created_at": "2025-07-26T13:00:19.686324Z",
"updated_at": "2025-07-26T13:00:19.686846Z",
"title": "This will Work",
"done": true,
"created_by": 3,
"updated_by": 3
}
Now login as the adminuser and modify the Todo with the id 3 and put the done to false the response should be like this
{
"id": 3,
"created_at": "2025-07-26T13:00:19.686324Z",
"updated_at": "2025-07-26T13:04:16.150382Z",
"title": "This will Work",
"done": false,
"created_by": 3,
"updated_by": 2
}
The HiddenField approach in Django Rest Framework is a cleaner and more maintainable solution for automatically managing audit fields like created_by and updated_by. Unlike the traditional method of overriding perform_create and perform_update in each ViewSet, this approach moves the responsibility to the serialization layer, which is the natural place for handling data preparation and validation before it reaches the database. This not only reduces boilerplate code and keeps ViewSets focused solely on request handling and permissions, but also strengthens data integrity, as these fields are completely hidden from the client and cannot be tampered with. By leveraging CurrentUserDefault and CreateOnlyDefault, the serializer ensures that created_by is set only once during object creation while updated_by is updated on every modification, guaranteeing consistent behavior across all endpoints. Ultimately, this architecture aligns with DRF’s design philosophy by making the serializer the single source of truth for how data is processed, resulting in a more scalable, secure, and DRY codebase.