Module 9: Django REST Framework — Serializers
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Understand what serializers are and why APIs need them
- Build
ModelSerializerclasses that auto-generate fields from models - Create nested serializers to include related object data
- Use
SerializerMethodFieldfor computed API fields - Control field visibility with
read_onlyandwrite_only - Implement custom validation in serializers
- Handle ManyToMany write operations with custom
create()andupdate() - Build separate serializers for list vs. detail views
- Validate complex business rules like course capacity limits
📖 1. What is a Serializer?
In a traditional Django website, views render HTML templates. In a modern web application or mobile app, views return JSON data that React, Vue, Android, or iOS consumes.
A serializer is the bridge between your Django models (Python objects) and JSON (the language of APIs).
┌─────────────┐ Serializer ┌─────────────┐
│ Django Model │ ───────────────► │ JSON │
│ (Python) │ │ (Frontend) │
└─────────────┘ └─────────────┘
▲ │
└──────────────◄──────────────┘
Deserializer (POST/PUT data)
What serializers do:
- Serialize: Convert model instances → JSON (for API responses)
- Deserialize: Convert JSON → Python objects → Model instances (for API requests)
- Validate: Ensure incoming data meets your rules before saving
🏗️ 2. Setting Up the API App
Before writing serializers, ensure your api app (created in Module 1) is ready.
1# api/serializers.py 2 3from rest_framework import serializers 4from django.contrib.auth import get_user_model 5from blog.models import Post, Category, Comment, Tag 6 7User = get_user_model()
💡 Best practice: Keep serializers in a dedicated
serializers.pyfile within your app.
👤 3. User Serializers
The Read Serializer (for API responses)
When returning user data in an API, you usually want to hide the password and add computed fields like full name or post count.
1class UserSerializer(serializers.ModelSerializer): 2 """ 3 Serializes user data for API responses. 4 Includes computed fields that don't exist in the database. 5 """ 6 7 # source='get_full_name' calls the model's get_full_name() method 8 full_name = serializers.CharField(source='get_full_name', read_only=True) 9 10 # source='blog_posts.count' traverses the related_name and counts 11 post_count = serializers.IntegerField(source='blog_posts.count', read_only=True) 12 13 class Meta: 14 model = User 15 fields = [ 16 'id', 'username', 'email', 'first_name', 'last_name', 17 'full_name', 'role', 'is_verified', 'post_count', 'date_joined' 18 ] 19 read_only_fields = ['id', 'is_verified', 'date_joined']
Key concepts:
source='get_full_name': Tells the serializer to populate this field from the model'sget_full_name()methodread_only=True: This field appears in API responses but is ignored in POST/PUT requestsread_only_fields: Shortcut to mark multiple fields as read-only
The Write Serializer (for registration)
When creating a user, you need to accept a password (twice) but never return it.
1class UserRegistrationSerializer(serializers.ModelSerializer): 2 """ 3 Handles user registration. 4 Accepts password + confirmation, but never exposes them in responses. 5 """ 6 7 # write_only=True means this field is accepted in POST but hidden in responses 8 password = serializers.CharField( 9 write_only=True, 10 min_length=8, 11 style={'input_type': 'password'} # Renders as <input type="password"> in browsable API 12 ) 13 14 password_confirm = serializers.CharField( 15 write_only=True, 16 style={'input_type': 'password'} 17 ) 18 19 class Meta: 20 model = User 21 fields = ['username', 'email', 'first_name', 'last_name', 'password', 'password_confirm'] 22 23 def validate(self, data): 24 """ 25 Form-level validation: check that both passwords match. 26 Called automatically after individual field validation. 27 """ 28 if data['password'] != data['password_confirm']: 29 raise serializers.ValidationError({ 30 'password_confirm': 'Passwords do not match.' 31 }) 32 return data 33 34 def create(self, validated_data): 35 """ 36 Called when serializer.save() is invoked on POST. 37 We remove password_confirm and use create_user() to hash the password. 38 """ 39 validated_data.pop('password_confirm') 40 user = User.objects.create_user(**validated_data) 41 return user
Why create_user() instead of create()?
create_user() automatically hashes the password using Django's secure hashing algorithm. If you used User.objects.create(password='...'), the password would be stored as plain text — a massive security risk.
🏷️ 4. Category & Tag Serializers
These are straightforward since they have no complex relationships.
1class CategorySerializer(serializers.ModelSerializer): 2 """ 3 Lightweight serializer for categories. 4 Includes post_count as a computed read-only field. 5 """ 6 post_count = serializers.IntegerField(source='posts.count', read_only=True) 7 8 class Meta: 9 model = Category 10 fields = ['id', 'name', 'slug', 'description', 'post_count', 'is_active'] 11 12 13class TagSerializer(serializers.ModelSerializer): 14 """ 15 Simple serializer for tags. 16 """ 17 class Meta: 18 model = Tag 19 fields = '__all__' # Shortcut to include all model fields
⚠️ Caution:
fields = '__all__'is convenient but risky. If you add a sensitive field to the model later, it automatically gets exposed in the API. For production APIs, always use explicit field lists.
💬 5. Comment Serializer (with Nested Data)
Comments need to show who wrote them, but we don't want to expose the author's password or email to everyone.
1class CommentSerializer(serializers.ModelSerializer): 2 """ 3 Serializes comments with nested author data. 4 Prevents nested replies beyond one level. 5 """ 6 7 # Nest the UserSerializer so API shows author details inline 8 author = UserSerializer(read_only=True) 9 10 # Count replies without loading them all 11 replies_count = serializers.IntegerField(source='replies.count', read_only=True) 12 13 class Meta: 14 model = Comment 15 fields = [ 16 'id', 'post', 'author', 'parent', 'content', 17 'is_approved', 'replies_count', 'created_at' 18 ] 19 read_only_fields = ['is_approved', 'created_at'] 20 21 def validate_parent(self, value): 22 """ 23 Field-level validation: prevent nested replies beyond level 1. 24 A reply cannot have its own parent (no threads deeper than 2 levels). 25 """ 26 if value and value.parent: 27 raise serializers.ValidationError( 28 'Nested replies are not allowed beyond one level.' 29 ) 30 return value
What validate_parent does:
If someone tries to POST a comment where parent is itself a reply (has its own parent), the serializer rejects it. This keeps your comment system flat and manageable.
📝 6. Post Serializers: List vs. Detail
A common professional pattern is using different serializers for different API endpoints:
| Endpoint | Serializer | Purpose |
|---|---|---|
/api/posts/ | PostListSerializer | Lightweight, fast, many records |
/api/posts/<slug>/ | PostDetailSerializer | Full data, single record |
POST /api/posts/ | PostCreateUpdateSerializer | Handles write logic, tags as strings |
The List Serializer (Lightweight)
1class PostListSerializer(serializers.ModelSerializer): 2 """ 3 Optimized serializer for listing many posts. 4 Avoids loading full content and all comments. 5 """ 6 author = UserSerializer(read_only=True) 7 category = CategorySerializer(read_only=True) 8 tag_list = TagSerializer(source='tags', many=True, read_only=True) 9 comment_count = serializers.IntegerField(source='comments.count', read_only=True) 10 11 class Meta: 12 model = Post 13 fields = [ 14 'id', 'title', 'slug', 'author', 'category', 15 'tag_list', 'excerpt', 'featured_image', 16 'status', 'published_at', 'view_count', 17 'comment_count', 'reading_time' 18 ]
Why separate list and detail?
When listing 20 posts on a homepage, you don't need the full content (which could be 5000 words each) or every comment. The list serializer keeps responses small and fast.
The Detail Serializer (Full Data)
1class PostDetailSerializer(serializers.ModelSerializer): 2 """ 3 Full serializer for single post detail view. 4 Includes nested comments and an 'is_author' flag. 5 """ 6 author = UserSerializer(read_only=True) 7 category = CategorySerializer(read_only=True) 8 tags = TagSerializer(many=True, read_only=True) 9 comments = CommentSerializer(many=True, read_only=True) 10 11 # SerializerMethodField calls a custom method to compute its value 12 is_author = serializers.SerializerMethodField() 13 14 class Meta: 15 model = Post 16 fields = '__all__' 17 read_only_fields = ['slug', 'view_count', 'created_at', 'updated_at'] 18 19 def get_is_author(self, obj): 20 """ 21 SerializerMethodField automatically looks for get_<field_name>. 22 Returns True if the requesting user is the post's author. 23 """ 24 request = self.context.get('request') 25 if request and request.user.is_authenticated: 26 return obj.author == request.user 27 return False 28 29 def validate_title(self, value): 30 """ 31 Ensure no duplicate titles exist (excluding the current instance on update). 32 """ 33 queryset = Post.objects.filter(title=value) 34 if self.instance: 35 queryset = queryset.exclude(pk=self.instance.pk) 36 if queryset.exists(): 37 raise serializers.ValidationError('A post with this title already exists.') 38 return value
Key concepts:
SerializerMethodField: A computed field. Django REST Framework looks for a method namedget_<field_name>.self.context.get('request'): Accesses the current HTTP request. You must passcontext={'request': request}when instantiating the serializer in your view.self.instance: The existing model instance during an update (PATCH/PUT).Noneduring create (POST).
The Write Serializer (Create & Update)
The detail serializer is great for reading, but it can't handle creating tags from simple strings. We need a dedicated write serializer.
1class PostCreateUpdateSerializer(serializers.ModelSerializer): 2 """ 3 Handles POST and PUT/PATCH operations. 4 Accepts tags as a list of strings, not Tag objects. 5 """ 6 7 # ListField accepts ["python", "django"] instead of [{"id": 1, "name": "python"}] 8 tags = serializers.ListField( 9 child=serializers.CharField(max_length=50), 10 write_only=True, # Accept in POST, don't include in response 11 required=False 12 ) 13 14 class Meta: 15 model = Post 16 fields = [ 17 'title', 'content', 'category', 'tags', 18 'featured_image', 'status', 'excerpt' 19 ] 20 21 def create(self, validated_data): 22 """ 23 Custom creation logic: 24 1. Extract tag strings from validated data 25 2. Create the Post object 26 3. Create or fetch Tag objects and attach them 27 """ 28 tags_data = validated_data.pop('tags', []) 29 post = Post.objects.create(**validated_data) 30 31 for tag_name in tags_data: 32 # get_or_create returns (object, created_boolean) 33 tag, _ = Tag.objects.get_or_create(name=tag_name.lower().strip()) 34 post.tags.add(tag) 35 36 return post 37 38 def update(self, instance, validated_data): 39 """ 40 Custom update logic: 41 1. Update basic fields 42 2. If tags provided, clear old tags and set new ones 43 """ 44 tags_data = validated_data.pop('tags', None) 45 46 # Update standard fields 47 for attr, value in validated_data.items(): 48 setattr(instance, attr, value) 49 instance.save() 50 51 # Handle tags if provided 52 if tags_data is not None: 53 instance.tags.clear() 54 for tag_name in tags_data: 55 tag, _ = Tag.objects.get_or_create(name=tag_name.lower().strip()) 56 instance.tags.add(tag) 57 58 return instance
Why write_only=True on tags?
The frontend sends ["python", "django"] but the database stores Tag objects. The write serializer handles the conversion. The read serializer (PostDetailSerializer) handles the reverse.
🔧 7. Serializer Field Types Reference
Django REST Framework provides many field types beyond what Django models offer:
| Field | Use Case | Key Arguments |
|---|---|---|
CharField | Strings | max_length, min_length, allow_blank |
IntegerField | Whole numbers | min_value, max_value |
BooleanField | True/False | default |
DateTimeField | Timestamps | format='%Y-%m-%d %H:%M' |
EmailField | Emails | required, allow_blank |
URLField | Web links | verify_exists (deprecated, use validators) |
SlugField | URL slugs | allow_unicode |
FileField | File uploads | use_url=True (returns full URL) |
🧪 8. Practice Task — Enrollment Serializer with Capacity Validation
Task Requirements
Create a serializer for the Enrollment model (from Module 4) that:
- Serializes enrollment data with nested student and course info
- Validates that a student cannot enroll if the course is at full capacity (assume
Coursehas amax_studentsfield) - Prevents duplicate enrollments
- Validates that
progressis between 0 and 100
Solution
1# api/serializers.py 2 3from rest_framework import serializers 4from django.db.models import Count 5from blog.models import Course, Enrollment 6 7 8class EnrollmentSerializer(serializers.ModelSerializer): 9 """ 10 Serializes enrollment records with capacity validation. 11 """ 12 student = UserSerializer(read_only=True) 13 course_title = serializers.CharField(source='course.title', read_only=True) 14 15 class Meta: 16 model = Enrollment 17 fields = [ 18 'id', 'student', 'course', 'course_title', 19 'enrolled_at', 'completed', 'progress' 20 ] 21 read_only_fields = ['enrolled_at'] 22 23 def validate_progress(self, value): 24 """ 25 Ensure progress is between 0 and 100. 26 """ 27 if value < 0 or value > 100: 28 raise serializers.ValidationError('Progress must be between 0 and 100.') 29 return value 30 31 def validate(self, data): 32 """ 33 Form-level validation: 34 1. Check course capacity 35 2. Prevent duplicate enrollment 36 """ 37 # Get the course (from data on create, from instance on update) 38 course = data.get('course') or self.instance.course 39 40 # Check capacity (assuming Course has max_students field) 41 current_enrollments = Enrollment.objects.filter(course=course).count() 42 if current_enrollments >= course.max_students: 43 raise serializers.ValidationError({ 44 'course': 'This course has reached maximum capacity.' 45 }) 46 47 # Prevent duplicate enrollment (only on create) 48 student = data.get('student') or self.instance.student if self.instance else None 49 if not self.instance: # Creating new enrollment 50 if Enrollment.objects.filter(student=student, course=course).exists(): 51 raise serializers.ValidationError({ 52 'course': 'You are already enrolled in this course.' 53 }) 54 55 return data
Usage in a view:
1# Create enrollment 2serializer = EnrollmentSerializer(data={ 3 'course': course_id, 4 'progress': 0 5}) 6serializer.is_valid(raise_exception=True) 7serializer.save(student=request.user)
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
fields = '__all__' | Accidentally exposes sensitive data | Always use explicit field lists in production |
Missing read_only on id | Clients can overwrite primary keys | Add id to read_only_fields |
write_only on response field | Field missing in API response | Remove write_only if the field should be returned |
create() without save_m2m() | ManyToMany relations not saved | Use post.tags.add() or call save_m2m() |
validate() without return data | Validation passes but data is lost | Always return data at the end of validate() |
source='wrong_field' | AttributeError | Ensure the source attribute exists on the model |
many=True missing | TypeError when serializing querysets |
✅ Module 9 Summary
| Concept | Key Takeaway |
|---|---|
| Serializer | Converts models ↔ JSON and validates data |
ModelSerializer | Auto-generates fields from a Django model |
read_only=True | Field appears in responses, ignored in requests |
write_only=True | Field accepted in requests, hidden in responses |
source='...' | Maps serializer field to a different model attribute/method |
SerializerMethodField | Computed field using custom get_<field>() logic |
validate_<field>() | Field-level validation |
validate() | Form-level validation across multiple fields |
create() | Custom logic when saving a new object |
update() | Custom logic when modifying an existing object |
many=True | Required when serializing/deserializing lists |
context | Pass extra data (like ) to serializers |
🚪 What's Next?
In Module 10, we will build the API Views and ViewSets. You'll learn how to connect your serializers to actual URL endpoints using generic API views, ViewSets, and routers. We'll implement pagination, filtering, search, throttling, and custom actions like "approve comment" or "like post."
Before proceeding, make sure:
- Your
api/serializers.pycontains all serializer classes - You understand the difference between
read_onlyandwrite_only - You can explain why we use three different Post serializers
- The
UserRegistrationSerializerhashes passwords withcreate_user() - You understand how
SerializerMethodFieldandget_is_author()work - You completed the Enrollment serializer with capacity validation
Your serializers are transforming data like a pro. Ready to wire them up to API endpoints? 🔌🚀