Photo by Luis Aceves on Unsplash

Django DRY in action: How to correctly enforce validators with DRF

Adrien Van Thong
Django Unleashed
Published in
6 min readSep 3, 2024

--

As a seasoned Django app developer, I absolutely love all the rich functionality the framework brings me and how it allows me to be DRY (Don’t Repeat Yourself). However, one thing that drives me absolutely nuts about Django is how it doesn’t always automatically call the validators when saving a model record.

For an example in action, let’s imagine the following model definition below, with 3 fields:

class NumbersModel(models.Model):
num_per_box = models.PositiveIntegerField() # Must always be an even number!
qty_boxes = models.PositiveIntegerField()
total_items = models.PositiveIntegerField() # Must be the product of the other two fields

The two fields, num_per_box and qty_boxes, need to be a positive integers, for obvious reasons. (Can’t have -2.3 items in a box or -1.6 boxes). The third field is calculated to be the product of the first two fields. Ordinarily, this should be a model method instead of a model field, but for the purposes of this example we’ll make it a field.

To make this example interesting, let’s decide that the first field, num_per_box should always be an even number. Next, let’s use the Django framework to try to enforce that rule.

Enforcing values with Django validators

Because we used the PositiveInteger field for these values, Django will always ensure these values are positive integers, so we get validation logic for free! However, we do still need to write code to enforce num_per_box is an even number. Most readers will know the best way to do this is via Django validators, for example:

from django.db import models
from django.core.exceptions import ValidationError

def validate_even(value):
if value % 2 != 0:
raise ValidationError('Value must be an even number!')

class NumbersModel(models.Model):
num_per_box = models.PositiveIntegerField(validators=[validate_even])
qty_boxes = models.PositiveIntegerField()
total_items = models.PositiveIntegerField()

While this associates the num_per_box field tightly with the validate_even validator, we’ll need to do something a bit different for the total_items field since it involves all 3 fields: for validating field values that rely on other fields, we’ll need to put that logic in the clean() method, seen below:

from django.db import models
from django.core.exceptions import ValidationError

class NumbersModel(models.Model):
num_per_box = models.PositiveIntegerField(validators=[validate_even])
qty_boxes = models.PositiveIntegerField()
total_items = models.PositiveIntegerField()

def validate_product(self):
if self.total_items != self.num_per_box * self.qty_boxes:
raise ValidationError('total_items must equal num_per_box times qty_boxes')

def clean(self):
super().clean()
self.validate_product()

Problem solved, right? Any new records of the NumbersModel class will have all these values enforced, right? Well, not so fast.

Validating the validators

Let’s try writing a simple UpdateView for this model, to check that our validators are properly being invoked:

from .models import NumbersModel

class NumberUpdateView(UpdateView):
template_name = 'form.html'
model = NumbersModel
fields = '__all__'

With our handy new view, let’s fire up the browser and put our form to the test. Notice how attempting to enter odd numbers for the first field results in an error:

“Num per box” field is properly validated

Likewise for the total_items field, the form prevents invalid values in the total_items field:

“Total items” field is also properly validated

Therefore, any records attempted to be saved via UpdateView or CreateView and their descendants are correctly validated, and the errors are gracefully displayed back to the user.

So far, so good. Now, let’s try this via the shell and see what happens:

>>> from .models import NumbersModel
>>> n = NumbersModel.objects.create(num_per_box=1, qty_boxes=5, total_ordered=5)
>>> n
<NumbersModel: NumbersModel object (1)>

Uh oh. The record was created despite the invalid value provided to num_per_box.

Seasoned Django users will recognize this maddening behaviour of Django. The clean() method, which is responsible for executing all the validators (as well as the custom logic we’ve written), is not always run by default.

In one final example, let’s check the same behaviour via DRF REST API. Let’s create a new REST endpoint for this model:

from rest_framework import serializers, viewsets
from .models import NumbersModel

class NumbersSerializer(serializers.ModelSerializer):
class Meta:
model = NumbersModel
fields = '__all__'

class NumbersViewSet(viewsets.ModelViewSet):
queryset = NumbersModel.objects.all()
serializer_class = NumbersSerializer

After updating urls.py let’s try out the new HTTP endpoints using the requests module:

>>> import requests
>>> data = {
... 'num_per_box': 1,
... 'qty_boxes': 2,
... 'total_items': 10
... }
>>> r = requests.post('http://localhost:8000/api/numbers/', data=data)
>>> r.content
'{"num_per_box":["Value must be an even number!"]}'
>>> r.status_code
400

Okay, so far DRF appears to be properly evaluating the field validators. Let’s check whether the other validator is invoked. This one should also fail:

>>> data = {
... 'num_per_box': 2,
... 'qty_boxes': 5,
... 'total_items': 6
... }
>>> r = requests.post('http://localhost:8000/api/numbers/', data=data)
>>> r.status_code
201
>>> r.content
'{"id":3,"num_per_box":2,"qty_boxes":5,"total_items":6}'

This time, the validator was not invoked. What this tells us is that DRF also does not call the clean() method by default.

3 different access methods, 3 different results. This begs the question: when exactly is the clean() method invoked, and if we have a model that requires it, how can we ensure it is always invoked?

Anti-patterns to avoid

Some potential solutions to this problem you may want to avoid are highlighted below:

  • Calling clean() from inside save(): Since the save method is always invoked, it’s tempting to easily fix the problem by overwriting the save() method to call clean() but this is a bad idea because the save() method does not catch the ValidationError exceptions, leading to uncaught exceptions when validation rules are broken.
  • Implementing validation logic directly inside the CBV or DRF: Another tempting solution is to validate the data at the point of ingest. While a cleaner alternative to the solution above, if there are multiple points of ingress for this model, that logic will need to be repeated in each of those views, thus breaking the DRY principle.
  • Implementing validation logic directly inside the Form or Serializer validate methods: for the same reasons as the previous point, this breaks the DRY principles. The serializer’s validate methods should only be used for validation logic that should only be enforced at APIs.

Although I mention avoiding re-implementing logic in the validate methods in the last bullet, inheritance can provide us a way out. Let’s explore that.

Mixins to the rescue

Thankfully, Django mixins gives us a very DRY alternative to solve this problem! We can create a base class for our serializers which overwrites the validate method to call the model’s full_clean method:

class FullCleanValidatorSerializerMixin():
def validate(self, attrs):
"""
Create an instance of what the record will look like after the REST call, and then validate on that resulting
instance by calling the `full_clean` method.
"""
request = self.context.get('request')
if request.method in ['PUT', 'POST']:
instance = self.Meta.model(**attrs)
elif request.method == 'PATCH':
instance = getattr(self, 'instance', None)
for attr, value in attrs.items():
setattr(instance, attr, value)
if instance:
instance.full_clean()
return super().validate(attrs)

Before we call full_clean we need to first modify our instance to mimic what it would look like after the user’s inputted changes. For the PUT and POST verbs, this is simply constructing a new instance of the object with all the attributes provided by the user. In the case of PATCH it’s a bit more complicated, as we need to modify the existing object with only the attributes provided by the user. In any case, once the new instance object is created, we call full_clean to validate it. If any ValidationErrors are thrown, the framework will gracefully handle them and present them back to the client.

With this new mixin, whenever we have aDjango model where we want all its validators automatically enforced via DRF, all we need to do is have its serializer inherit the mixin:

class NumbersSerializer(serializers.ModelSerializer, FullCleanValidatorSerializerMixin):
class Meta:
model = NumbersModel
fields = '__all__'

While Django’s validation logic is not always consistently enforced, thankfully we can use the framework itself to create a Mixin which we can optionally inherit whenever we want to force validation on specific models via DRF.

--

--