Django REST Framework (DRF) serializers do two jobs. On the way out they turn model instances and querysets into native Python types that render to JSON; on the way in they validate and deserialize request data into objects you can safely save. ModelSerializer covers the common cases automatically, but production APIs almost always need more: computed fields, renamed or remapped fields, custom validation, writable nested objects, reshaped payloads, and queries that don't trigger N+1 problems.
This guide walks through the customization hooks you'll reach for most often, with modern, runnable code (Django 5.x, DRF 3.15+). The main levers are:
- Declaring fields explicitly, including extra (non-model) fields.
SerializerMethodFieldfor read-only computed values.sourceto rename a field or traverse a relation.read_only/write_onlyto control direction.- Validation with
validate_<field>,validate(), and validators. - Nested serializers, including writable nesting with custom
create()/update(). to_representation()/to_internal_value()to reshape output and input.- Dynamic fields and performance tuning with
select_related/prefetch_related.
Serializer vs ModelSerializer
Use serializers.Serializer when the shape of your data doesn't map cleanly to a single model (aggregations, request payloads, third-party responses). Use serializers.ModelSerializer when you're reading or writing a model: it generates fields from Meta.fields and gives you working create() and update() methods for free. You can override anything ModelSerializer generates.
# serializers.py
from rest_framework import serializers
from catalog.models import Product
# Plain Serializer: you declare every field, no model binding.
class PriceSummarySerializer(serializers.Serializer):
currency = serializers.CharField(max_length=3)
total = serializers.DecimalField(max_digits=10, decimal_places=2)
# ModelSerializer: fields are derived from the model.
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'sku', 'price', 'is_active']
read_only_fields = ['id']Declaring custom and extra fields
A ModelSerializer field doesn't have to come from the model. Declare any DRF field on the class and it's added to the output (and, unless read-only, the input). Use extra_kwargs in Meta to tweak generated fields without redeclaring them in full.
class ProductSerializer(serializers.ModelSerializer):
# Extra field that is not on the Product model.
discount_label = serializers.CharField(default='None', read_only=True)
# Override a generated field's options.
name = serializers.CharField(max_length=120, trim_whitespace=True)
class Meta:
model = Product
fields = ['id', 'name', 'sku', 'price', 'discount_label']
extra_kwargs = {
'sku': {'required': False},
'price': {'min_value': 0},
}SerializerMethodField for computed read-only values
SerializerMethodField is the standard way to add a read-only value computed in Python. By convention it calls get_<field_name>(self, obj); pass method_name to use a different method. Because it runs Python per object, keep the body cheap and avoid hitting the database inside it (see the performance section).
class ProductSerializer(serializers.ModelSerializer):
is_on_sale = serializers.SerializerMethodField()
review_count = serializers.SerializerMethodField('count_reviews')
class Meta:
model = Product
fields = ['id', 'name', 'price', 'is_on_sale', 'review_count']
def get_is_on_sale(self, obj):
return obj.sale_price is not None and obj.sale_price < obj.price
def count_reviews(self, obj):
# Prefer an annotation/prefetch over a query here (see Performance).
return getattr(obj, 'review_count', obj.reviews.count())Using source to rename and traverse relations
source decouples the JSON key from where the value comes from. Use it to rename a field, to traverse related objects with dotted paths, or pass source='*' to feed the whole instance into a nested serializer. Dotted traversal is also where N+1 queries sneak in, so pair it with select_related / prefetch_related.
class OrderSerializer(serializers.ModelSerializer):
# Rename: model field is `placed_at`, output key is `created`.
created = serializers.DateTimeField(source='placed_at', read_only=True)
# Traverse a relation: customer.full_name -> `customer_name`.
customer_name = serializers.CharField(source='customer.full_name', read_only=True)
# Reach across two hops.
country = serializers.CharField(source='customer.address.country', read_only=True)
class Meta:
model = Order
fields = ['id', 'created', 'customer_name', 'country', 'total']Read-only and write-only fields
read_only=True includes a field in responses but ignores it in input (great for server-set values like timestamps and computed fields). write_only=True accepts a field in input but never echoes it back (passwords, tokens). You can't set both read_only and write_only on the same field.
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
created_at = serializers.DateTimeField(read_only=True)
class Meta:
model = User
fields = ['id', 'email', 'password', 'created_at']
def create(self, validated_data):
password = validated_data.pop('password')
user = User(**validated_data)
user.set_password(password)
user.save()
return userField-level and object-level validation
DRF gives you three validation hooks, run in this order:
- Reusable validators attached to a field (or
Meta.validatorsfor cross-field rules likeUniqueTogetherValidator). validate_<field>(self, value)for a single field; return the (optionally cleaned) value or raiseserializers.ValidationError.validate(self, data)for object-level rules that compare multiple fields.
Field-level methods only run if the field passed its built-in checks, and validate() only runs once all field-level validation succeeds.
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
def no_test_domains(value):
if value.endswith('@example.com'):
raise serializers.ValidationError('Test domains are not allowed.')
class EventSerializer(serializers.ModelSerializer):
organizer_email = serializers.EmailField(validators=[no_test_domains])
class Meta:
model = Event
fields = ['id', 'venue', 'organizer_email', 'starts_at', 'ends_at']
validators = [
UniqueTogetherValidator(
queryset=Event.objects.all(),
fields=['venue', 'starts_at'],
)
]
def validate_organizer_email(self, value):
return value.strip().lower()
def validate(self, data):
if data['ends_at'] <= data['starts_at']:
raise serializers.ValidationError(
{'ends_at': 'End time must be after the start time.'}
)
return dataNested serializers (read and writable)
Nesting another serializer renders related objects inline. Reading is automatic. Writing through a nested serializer is not — DRF can't guess how to create or update the related rows, so you override create() and update() yourself. Remember to pop() the nested data out of validated_data before creating the parent.
class TrackSerializer(serializers.ModelSerializer):
class Meta:
model = Track
fields = ['order', 'title', 'duration']
class AlbumSerializer(serializers.ModelSerializer):
tracks = TrackSerializer(many=True)
class Meta:
model = Album
fields = ['id', 'title', 'artist', 'tracks']
def create(self, validated_data):
tracks_data = validated_data.pop('tracks')
album = Album.objects.create(**validated_data)
Track.objects.bulk_create(
[Track(album=album, **track) for track in tracks_data]
)
return album
def update(self, instance, validated_data):
tracks_data = validated_data.pop('tracks', None)
instance.title = validated_data.get('title', instance.title)
instance.artist = validated_data.get('artist', instance.artist)
instance.save()
if tracks_data is not None:
# Simple strategy: replace the child set.
instance.tracks.all().delete()
Track.objects.bulk_create(
[Track(album=instance, **track) for track in tracks_data]
)
return instanceShaping output with to_representation()
Override to_representation() when you need to reshape the whole output object rather than tweak one field: adding derived keys, flattening or wrapping data, or stripping nulls. Call super() first to get DRF's default dict, then modify it.
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'price', 'sale_price']
def to_representation(self, instance):
data = super().to_representation(instance)
data['display_price'] = data['sale_price'] or data['price']
# Drop keys that are None to keep payloads lean.
return {key: value for key, value in data.items() if value is not None}Shaping input with to_internal_value()
to_internal_value() is the inbound counterpart: it runs before field validation and converts raw request data into validated native values. Override it to normalize input (trim, lowercase), accept a legacy payload shape, or unwrap an envelope before the usual validation runs.
class SignupSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['email', 'full_name']
def to_internal_value(self, data):
# Accept either {'user': {...}} or a flat payload.
if 'user' in data:
data = data['user']
# Normalize before per-field validation runs.
if 'email' in data:
data = {**data, 'email': str(data['email']).strip().lower()}
return super().to_internal_value(data)Dynamic fields (and passing context)
Sometimes a single endpoint should return different field sets depending on the caller. The DRF docs' DynamicFieldsModelSerializer pattern lets you pop unwanted fields at runtime — for example, driven by a ?fields= query parameter.
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""A ModelSerializer that accepts a `fields` argument to limit output."""
def __init__(self, *args, **kwargs):
fields = kwargs.pop('fields', None)
super().__init__(*args, **kwargs)
if fields is not None:
allowed = set(fields)
for field_name in set(self.fields) - allowed:
self.fields.pop(field_name)
class ProductSerializer(DynamicFieldsModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'sku', 'price', 'is_active']
# views.py - read the query param and pass it through.
class ProductList(generics.ListAPIView):
queryset = Product.objects.all()
serializer_class = ProductSerializer
def get_serializer(self, *args, **kwargs):
requested = self.request.query_params.get('fields')
if requested:
kwargs['fields'] = requested.split(',')
return super().get_serializer(*args, **kwargs)Every serializer can also read context (the request, the view, anything you inject) via self.context. That's how you make decisions based on the current user, feature flags, or extra data the view supplies. Context is a deep topic of its own — see our companion guide, How to pass extra context data to serializers in Django REST Framework, for the full breakdown of get_serializer_context() and using context inside validate().
Performance: avoiding N+1 queries
The most common DRF performance bug is the N+1 query: a serializer touches a related object per row, firing one extra query each time. Fix it at the queryset, not the serializer — select_related for forward foreign keys / one-to-one, prefetch_related for many-to-many and reverse relations, and annotate for counts and aggregates so SerializerMethodField reads an attribute instead of querying. Always serialize collections with many=True.
from django.db.models import Count
class CommentSerializer(serializers.ModelSerializer):
author = serializers.CharField(source='author.username', read_only=True)
like_count = serializers.IntegerField(read_only=True) # from annotate()
class Meta:
model = Comment
fields = ['id', 'author', 'body', 'like_count']
# views.py - resolve relations and aggregates in ONE round trip.
queryset = (
Comment.objects
.select_related('author') # forward FK -> JOIN
.prefetch_related('tags') # M2M -> second query, not N
.annotate(like_count=Count('likes'))
)
data = CommentSerializer(queryset, many=True).dataWhich tool for the job?
SerializerMethodField, source, and to_representation() overlap, so pick by intent:
| Need | Use | Direction | Notes |
|---|---|---|---|
| Computed value from Python logic | SerializerMethodField |
Read-only | Runs get_<field> per object; watch for N+1 |
| Rename a key or traverse a relation | source |
Read + write | Dotted paths like customer.address.city |
| Hide input or output | read_only / write_only |
One way | Can't set both on one field |
| Reshape the whole output dict | to_representation() |
Read-only | Add/remove/wrap keys after super() |
| Normalize or accept legacy input | to_internal_value() |
Write-only | Runs before field validation |
| Validate one field | validate_<field> |
Write-only | Return cleaned value or raise |
| Validate across fields | validate() |
Write-only | Runs after all field validation |
| Write related objects inline | Nested serializer + create/update |
Write | Pop nested data before saving parent |
Frequently Asked Questions
What is the difference between Serializer and ModelSerializer?
ModelSerializer is a Serializer subclass that auto-generates fields from a model's Meta and provides default create() and update() implementations. Use it whenever you read or write a model. Reach for the plain Serializer when the data has no single backing model — request payloads, aggregated reports, or responses you assemble by hand — where you declare every field explicitly.
When should I use SerializerMethodField instead of source?
Use source when the value already exists on the instance or a related object and you only need to rename it or follow a relation (source='customer.email'). Use SerializerMethodField when the value needs Python logic — formatting, branching, or combining several attributes. Note SerializerMethodField is always read-only; source works for both reading and writing.
How do I make a writable nested serializer in DRF?
Declare the nested serializer (with many=True for lists) on the parent, then override the parent's create() and update(). Inside them, pop() the nested data out of validated_data, save the parent first, then create or replace the child rows — bulk_create keeps it to one query. DRF deliberately leaves this to you because there's no single correct way to sync a child collection.
What is the difference between field-level and object-level validation?
validate_<field>(self, value) validates one field in isolation and runs only if that field passed its built-in checks; return the cleaned value or raise ValidationError. validate(self, data) is object-level and runs after every field-level check succeeds, so it's where you compare fields against each other (for example, end date after start date).
How do I add or remove serializer fields dynamically at runtime?
Override __init__ to pop fields you don't want, as in the DynamicFieldsModelSerializer pattern, and feed it a list (often from a ?fields= query param via the view). For decisions based on the request or current user, read self.context instead — see our extra context data guide for how to populate and use it.
How can I avoid N+1 queries in DRF serializers?
Tune the queryset, not the serializer. Add select_related() for forward foreign keys and one-to-one relations, prefetch_related() for many-to-many and reverse relations, and annotate() for counts so a SerializerMethodField reads a precomputed attribute rather than firing a query per row. Serialize lists with many=True and profile with Django Debug Toolbar or django-silk.
Building DRF APIs with MicroPyramid
Clean serializers are where a Django API earns its reliability — predictable payloads, validation you can trust, and query patterns that stay fast as data grows. For 12+ years and across 50+ delivered projects, MicroPyramid has built and maintained Django and DRF APIs for startups and enterprises, including writable nested resources, custom validation layers, and N+1 cleanups on existing codebases. Explore our Django development services, broader Python development services, and FastAPI development services for high-throughput APIs, or talk to us about custom software development end to end.