deep-dive2024-05-15·8·303/348

Django REST Framework serializer 검증

A practical guide to DRF serializer validation — field-level, object-level, custom validators, and handling nested data correctly.

Django REST Framework serializer 검증

Introduction

Django REST Framework (DRF) serializers are the backbone of API data validation. They transform incoming request data into Python objects and validate it before it reaches your views. Yet many developers either skip serializer validation entirely or use only the most basic features, leading to APIs that accept invalid data.

I built a REST API for a trading platform in Lisbon and learned the importance of thorough serializer validation the hard way. An API endpoint accepted a trade order with a negative quantity, which slipped through to the database and caused financial discrepancies.

Environment

Django 4.2.11
Django REST Framework 3.15.1
Python 3.12.3
PostgreSQL 15.4

Problem

Problem 1: Basic validation misses edge cases

from rest_framework import serializers

class OrderSerializer(serializers.Serializer):
    product_id = serializers.IntegerField()
    quantity = serializers.IntegerField()
    price = serializers.DecimalField(max_digits=10, decimal_places=2)

# This accepts negative quantities!
serializer = OrderSerializer(data={
    "product_id": 1,
    "quantity": -5,  # Bug: negative quantity
    "price": "10.00"
})
serializer.is_valid()  # Returns True!

Problem 2: Nested serializer validation fails silently

class ItemSerializer(serializers.Serializer):
    name = serializers.CharField()
    price = serializers.DecimalField(max_digits=10, decimal_places=2)

class OrderSerializer(serializers.Serializer):
    items = ItemSerializer(many=True)
    total = serializers.DecimalField(max_digits=10, decimal_places=2)

# Nested validation errors are not obvious
serializer = OrderSerializer(data={
    "items": [
        {"name": "Widget", "price": "10.00"},
        {"name": "", "price": "-5.00"}  # Invalid: empty name, negative price
    ],
    "total": "5.00"
})
serializer.is_valid()
print(serializer.errors)
# {'items': {1: {'name': ['This field may not be blank.'], 
#               'price': ['Ensure this value is greater than or equal to 0.01.']}}}

Problem 3: Custom validation not called

class OrderSerializer(serializers.Serializer):
    product_id = serializers.IntegerField()
    quantity = serializers.IntegerField()

    def validate_quantity(self, value):
        if value <= 0:
            raise serializers.ValidationError("Quantity must be positive")
        return value

    # This method is never called automatically!
    def validate(self, data):
        if data['product_id'] == 1 and data['quantity'] > 100:
            raise serializers.ValidationError("Too many widgets")
        return data

serializer = OrderSerializer(data={
    "product_id": 1,
    "quantity": -5
})
serializer.is_valid()  # Returns True — validate_quantity not called!

Analysis

DRF serializers validate data at three levels.

Field-level validation: Each field type has built-in validation. IntegerField checks that the value is an integer, DecimalField checks format and range.

Method-level validation: Custom validate_<field>() methods run after field-level validation. These are for field-specific business rules.

Object-level validation: The validate() method runs after all field-level validations. This is for cross-field validation rules.

Common mistakes:

  1. Not calling is_valid() before accessing serializer.data
  2. Using raise_exception=True in validate() but not in validate_<field>()
  3. Forgetting to return the validated data from validate() methods
  4. Not handling many=True serialization correctly

Check validation errors:

serializer = OrderSerializer(data=invalid_data)
if not serializer.is_valid():
    print(serializer.errors)
    print(serializer.error_messages)

Solution

Fix 1: Add proper field-level validation

from rest_framework import serializers

class OrderSerializer(serializers.Serializer):
    product_id = serializers.IntegerField(min_value=1)
    quantity = serializers.IntegerField(min_value=1, max_value=10000)
    price = serializers.DecimalField(
        max_digits=10, 
        decimal_places=2,
        min_value=Decimal("0.01")
    )
    
    def validate_quantity(self, value):
        if value % 1 != 0:
            raise serializers.ValidationError("Quantity must be a whole number")
        return value

Fix 2: Add cross-field validation

class OrderSerializer(serializers.Serializer):
    product_id = serializers.IntegerField()
    quantity = serializers.IntegerField(min_value=1)
    price = serializers.DecimalField(max_digits=10, decimal_places=2)
    discount = serializers.DecimalField(max_digits=5, decimal_places=2, required=False)

    def validate(self, data):
        """Object-level validation."""
        # Check discount doesn't exceed price
        if data.get('discount') and data['discount'] > data['price']:
            raise serializers.ValidationError({
                'discount': 'Discount cannot exceed price'
            })
        
        # Check product-specific rules
        if data['product_id'] == 1 and data['quantity'] > 100:
            raise serializers.ValidationError({
                'quantity': 'Maximum 100 units for product 1'
            })
        
        return data

Fix 3: Use validators for reusable validation logic

from rest_framework import serializers, validators

def validate_positive_quantity(value):
    """Reusable validator for positive quantity."""
    if value <= 0:
        raise serializers.ValidationError("Quantity must be positive")
    return value

class OrderSerializer(serializers.Serializer):
    product_id = serializers.IntegerField()
    quantity = serializers.IntegerField(
        validators=[validate_positive_quantity]
    )

# Or use Validator classes
class OrderQuantityValidator:
    def __call__(self, attrs):
        if attrs['product_id'] == 1 and attrs['quantity'] > 100:
            raise serializers.ValidationError("Too many widgets")
        return attrs

Fix 4: Handle nested serializer validation properly

class ItemSerializer(serializers.Serializer):
    name = serializers.CharField(max_length=100)
    price = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"))
    quantity = serializers.IntegerField(min_value=1)

class OrderSerializer(serializers.Serializer):
    items = ItemSerializer(many=True, min_length=1)
    notes = serializers.CharField(required=False, allow_blank=True)

    def validate_items(self, items):
        """Validate the list of items."""
        if len(items) > 50:
            raise serializers.ValidationError("Maximum 50 items per order")
        return items

# Usage
serializer = OrderSerializer(data={
    "items": [
        {"name": "Widget", "price": "10.00", "quantity": 5}
    ],
    "notes": ""
})

if serializer.is_valid():
    validated_data = serializer.validated_data
    # validated_data['items'] is a list of OrderedDicts

Fix 5: Custom error messages

class OrderSerializer(serializers.Serializer):
    product_id = serializers.IntegerField(
        error_messages={
            'required': 'Product ID is required',
            'invalid': 'Product ID must be a valid integer',
            'min_value': 'Product ID must be positive',
        }
    )
    
    class Meta:
        # Global error messages
        error_messages = {
            'invalid': 'Invalid data format',
            'null': 'This field cannot be null',
        }

Lessons Learned

  • Always call is_valid(raise_exception=True) or check is_valid() before accessing serializer.validated_data.
  • Use validate_<field>() for field-specific rules and validate() for cross-field rules.
  • Return the validated data from all validation methods — not just the value.
  • Use validators parameter for reusable validation logic across serializers.
  • Test serializer validation separately from views to catch edge cases early.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.