Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 033 / 100

DRF serializers in depth: a hands-on guide for engineers

Serializers are where validation and shape live.

Expona Admin7 min read

Introduction

Serializers are where validation and shape live in Django REST Framework, and getting them right is the difference between a fragile API and one that scales. Over the past 32 days we've built models, views, and basic endpoints, but without mastering serializers, you'll find yourself debugging invalid data slipping through, inconsistent response formats across endpoints, and validation logic scattered throughout your codebase. This guide walks you through DRF serializers in depth: a hands-on guide for engineers who need to handle real-world complexities—nested relationships, separate read and write contracts, and custom validation—without cargo-culting solutions from Stack Overflow.

Understanding validate_field and validate Methods

DRF serializers offer two levels of validation: field-level and object-level. Field-level validation runs automatically on individual fields during deserialization, while object-level validation runs after all fields have been validated and gives you access to the entire dataset.

Field-level validation happens through a validate_<field_name> method. When you define one, DRF calls it automatically after the field's built-in validators run. This is useful for checks that don't require context beyond the field itself.

python
from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'email', 'age', 'username']
    
    def validate_age(self, value):
        if value < 18:
            raise serializers.ValidationError("Users must be 18 or older.")
        return value
    
    def validate_email(self, value):
        if User.objects.filter(email=value).exists():
            raise serializers.ValidationError("This email is already registered.")
        return value

Object-level validation, using the validate method, runs after all fields pass their individual checks. Use it when your logic depends on multiple fields or the overall state of the object. This is where you catch things like "password and confirm_password must match" or "end_date cannot be before start_date."

python
class EventSerializer(serializers.ModelSerializer):
    class Meta:
        model = Event
        fields = ['id', 'name', 'start_date', 'end_date', 'capacity', 'registered_count']
    
    def validate(self, data):
        if data.get('end_date') and data.get('start_date'):
            if data['end_date'] <= data['start_date']:
                raise serializers.ValidationError(
                    "end_date must be after start_date."
                )
        
        # Cross-field validation: capacity logic
        if data.get('capacity') and data.get('registered_count'):
            if data['registered_count'] > data['capacity']:
                raise serializers.ValidationError(
                    "Cannot register more attendees than capacity."
                )
        
        return data

The key distinction: use validate_<field_name> for isolated field rules, and use validate for anything requiring multiple fields or business logic context. Both methods must return the validated value or data dict; raise ValidationError if something fails.

In production APIs, your responses often need to include related objects. A blog post endpoint should return not just the author ID, but author details. That's where nested serializers shine. They let you compose serializers recursively, treating related objects the same way you'd handle top-level data.

Consider a scenario where you have posts, authors, and comments. A post serializer might look like this:

python
class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = ['id', 'name', 'email']

class CommentSerializer(serializers.ModelSerializer):
    author = AuthorSerializer(read_only=True)
    
    class Meta:
        model = Comment
        fields = ['id', 'text', 'author', 'created_at']

class PostSerializer(serializers.ModelSerializer):
    author = AuthorSerializer(read_only=True)
    comments = CommentSerializer(many=True, read_only=True)
    
    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'comments', 'created_at']

When you return a Post, the serializer automatically expands the author and comments, giving you a full tree. However, nesting for writes requires care. If you want to allow creating a post and its comments in one request, you need to handle writing to nested serializers explicitly:

python
class CommentWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ['text', 'author_id']

class PostWriteSerializer(serializers.ModelSerializer):
    comments = CommentWriteSerializer(many=True, required=False)
    
    class Meta:
        model = Post
        fields = ['title', 'content', 'author_id', 'comments']
    
    def create(self, validated_data):
        comments_data = validated_data.pop('comments', [])
        post = Post.objects.create(**validated_data)
        
        for comment_data in comments_data:
            Comment.objects.create(post=post, **comment_data)
        
        return post

Nested serializers are powerful, but be aware of the N+1 query problem. Always use select_related() or prefetch_related() in your queryset to avoid hammering the database when expanding relationships.

Separate Read and Write Serializers

A fundamental design principle in mature APIs: the shape of data you return is often different from what you accept. Read serializers may include computed fields, nested details, and read-only properties. Write serializers focus on the minimal set of fields needed to create or update a record, with stricter validation. Conflating them leads to bloated payloads and confusing error messages.

Here's a real-world pattern we use across our services:

python
class OrderReadSerializer(serializers.ModelSerializer):
    customer = CustomerSerializer(read_only=True)
    items = OrderItemSerializer(many=True, read_only=True)
    total_price = serializers.SerializerMethodField()
    status_display = serializers.CharField(source='get_status_display', read_only=True)
    
    class Meta:
        model = Order
        fields = ['id', 'customer', 'items', 'total_price', 'status_display', 'created_at']
    
    def get_total_price(self, obj):
        return sum(item.price * item.quantity for item in obj.items.all())

class OrderWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ['customer_id', 'status']
    
    def validate_customer_id(self, value):
        if not Customer.objects.filter(id=value).exists():
            raise serializers.ValidationError("Customer does not exist.")
        return value

In your viewset, you choose the serializer based on the action:

python
class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    
    def get_serializer_class(self):
        if self.action in ['create', 'update', 'partial_update']:
            return OrderWriteSerializer
        return OrderReadSerializer

This separation keeps your API contract clean. Clients know exactly what fields they can write, and they get rich responses on reads without being confused about which fields are writable. It also makes validation more focused: write serializers validate only what matters for creation and updates.

Use SerializerMethodField Sparingly

SerializerMethodField is convenient for computed or derived data in responses. It takes a method name and calls that method during serialization to produce a value. We see it overused, though, and it can become a performance trap.

python
class ProductSerializer(serializers.ModelSerializer):
    discount_percentage = serializers.SerializerMethodField()
    available_quantity = serializers.SerializerMethodField()
    
    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'discount_percentage', 'available_quantity']
    
    def get_discount_percentage(self, obj):
        if obj.sale_price:
            return round((1 - obj.sale_price / obj.price) * 100, 2)
        return 0
    
    def get_available_quantity(self, obj):
        return obj.inventory.filter(status='available').count()

The problem: each call to get_available_quantity runs a database query. If you're serializing 100 products, that's 100+ extra queries. You should instead compute these values in your view's queryset using annotations or bulk logic:

python
from django.db.models import Case, When, F, Count, Q

class ProductViewSet(viewsets.ReadOnlyModelViewSet):
    def get_queryset(self):
        return Product.objects.annotate(
            discount_percentage=Case(
                When(sale_price__isnull=False, 
                     then=F('price') - F('sale_price')),
                default=0
            ),
            available_quantity=Count(
                'inventory',
                filter=Q(inventory__status='available')
            )
        )
    
    serializer_class = ProductSerializer

Then access these annotated values in your serializer via SerializerMethodField or simple fields. SerializerMethodField is fine for lightweight transformations—formatting a date, concatenating strings—but avoid it for anything involving database queries. Make it a rule: if you're hitting the database in a SerializerMethodField, refactor it into your view's queryset.

Common Mistakes

  • Not annotating querysets before nested reads. If your nested serializer needs computed fields, annotate them in the queryset, not in SerializerMethodField. Otherwise, you'll face N+1 queries.
  • Mixing read and write concerns in one serializer. This makes error messages confusing and API contracts ambiguous. Always separate them.
  • Using validate_<field_name> for cross-field logic. Field-level validators should only inspect one field. Save cross-field checks for the validate method.
  • Forgetting to return the value from validate_<field_name>. Many developers raise an error correctly but forget to return the validated value on the happy path, leading to silent failures.
  • Leaving deeply nested serializers unoptimized. A POST endpoint with three levels of nested creation can easily become slow. Validate that your create/update methods don't trigger extra queries.

Key Takeaways

  • Field-level validation via validate_<field_name> runs on individual fields; object-level validation via validate runs on the entire data dict and should handle cross-field logic.
  • Nested serializers are powerful for representing related data, but always optimize your queryset with select_related() or prefetch_related() to avoid N+1 queries.
  • Separate read and write serializers so responses and inputs have distinct shapes, keeping your API contract clear and validation focused.
  • Use SerializerMethodField only for lightweight transformations. Move any database-hitting logic into your queryset annotations.
  • Serializers are the gatekeepers of your API. Well-designed ones catch bad data early, document your contracts clearly, and make your codebase maintainable for the next engineer.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles