· 8 min read · Engineering
Advanced Django — Part 4: Input and output serializers

This is Part 4 of the Advanced Django series. Part 1 covered the model layer, Part 2 covered custom querysets and managers (and when I don't reach for them), and Part 3 covered selectors and services — the read and write layers between the ORM and everything else.
Part 4 covers serializers. Specifically, how I split them.
Most tutorials teach one clever ModelSerializer per model — it handles list, detail, create, and update. That works right up until it doesn't: the create payload needs one shape, the response needs a different shape, and now the "one serializer" has write_only=True on half its fields, read_only=True on the other half, and a to_representation override that guesses what to hide based on the request method. That's the state I've watched teams end up in more than once, and it's why I split.
Two serializers per operation. One shape for input — what the client sends. One shape for output — what the client sees. They live in different directories, they inherit from different base classes, and they never overlap.
Companion repo: the patterns in this series live in
ademolaidowu/django-saas-foundationon GitHub — a working Django + DRF project you can clone, run, and read alongside the posts.
The directory split
Every app has a serializers/ package with two subdirectories:
apps/orders/
├── serializers/
│ ├── input/
│ │ ├── __init__.py
│ │ ├── order.py
│ │ └── line_item.py
│ └── output/
│ ├── __init__.py
│ ├── order.py
│ └── line_item.py
Same domain has a file in both directories. input/order.py holds every input serializer for the order domain (create, update, list filters). output/order.py holds every output serializer (mini, full).
Imports read cleanly:
from orders.serializers.input.order import OrderCreateInputSerializer
from orders.serializers.output.order import OrderOutputSerializerThere's no clever base class hiding upstream. Each file starts with a plain from rest_framework import serializers and builds what it needs.
The base class rule
The shortest way to describe the pattern:
- Input serializers subclass
serializers.Serializer(plain). NoModelSerializer. Ever. - Output serializers subclass
serializers.ModelSerializerwithread_only_fields = fields.
The reason for the asymmetry is that input and output do different jobs.
Input validates a request payload. It doesn't create rows, it doesn't touch the database, and it doesn't know about the model. It shapes a validated_data dict that the service layer uses to make decisions. A plain Serializer is exactly right for that — no model coupling, no .save() on it, no create() or update() methods buried inside. The service is where mutations happen.
Output shapes a model instance into a response. That's what ModelSerializer is for. It knows how to enumerate fields, apply source= mappings, and hand off to nested serializers. Setting read_only_fields = fields on every output serializer makes the "this is not for writing" contract explicit — even though the view will never call .save() on an output serializer, the read-only marker means DRF can't be tricked into treating it as writable.
Input serializers
The rule I stick to: one input serializer per operation shape. Create has its own, update has its own, list filters have their own. They diverge because required= on a create field differs from required=False on an update, and pretending otherwise leads to bugs.
Here's a full input serializer for order creation:
# apps/orders/serializers/input/order.py
from rest_framework import serializers
from core.constants import CHAR_MAX_LENGTH, SCHAR_MAX_LENGTH
from orders.constants import OrderStatus
from orders.serializers.input.line_item import LineItemInputSerializer
class OrderCreateInputSerializer(serializers.Serializer):
"""Body for `POST /orders/`."""
order_number = serializers.CharField(max_length=SCHAR_MAX_LENGTH)
customer_id = serializers.UUIDField()
shipping_address = serializers.CharField(max_length=CHAR_MAX_LENGTH)
items = LineItemInputSerializer(many=True)
notes = serializers.CharField(
max_length=CHAR_MAX_LENGTH,
required=False,
allow_null=True,
allow_blank=True,
)
class OrderUpdateInputSerializer(serializers.Serializer):
"""Body for `PATCH /orders/<order_id>/`. Every field optional for partial updates."""
shipping_address = serializers.CharField(max_length=CHAR_MAX_LENGTH, required=False)
notes = serializers.CharField(
max_length=CHAR_MAX_LENGTH,
required=False,
allow_null=True,
allow_blank=True,
)
status = serializers.ChoiceField(
choices=OrderStatus.as_choices(),
required=False,
)
class OrderListInputSerializer(serializers.Serializer):
"""Query params for `GET /orders/`."""
search = serializers.CharField(required=False)
status = serializers.ChoiceField(
choices=OrderStatus.as_choices(),
required=False,
)
active_only = serializers.BooleanField(default=True)Four conventions to notice.
Foreign keys are UUIDField, not PrimaryKeyRelatedField. The input serializer doesn't need to hit the database — it just validates that a UUID was sent. The service resolves it to a model instance inside the transaction. That resolution is a domain concern (does this UUID belong to the tenant? is it soft-deleted? has it been fulfilled?) and belongs in the service, not the serializer.
Enums come through ChoiceField(choices=OrderStatus.as_choices()). Every enum defines an as_choices() classmethod (from the BaseTextChoices pattern in Part 1). One import, one line, no drift between what the model accepts and what the API accepts.
Update is a distinct class with required=False everywhere. The create serializer says order_number = CharField() (required by default); the update version says shipping_address = CharField(required=False). This is where "one clever ModelSerializer" breaks — mapping "required at create, optional at update" onto a shared class means smuggling that logic into __init__ based on context, and it's harder to read than two classes.
List filters are their own serializer. Query params get validated too, and the input serializer is the natural place. Views pass serializer.validated_data into list_orders_for_organization(...) — that's how the search string, status filter and active-only flag reach the selector.
Nested input
Nested inputs are just nested serializers. The order example includes items = LineItemInputSerializer(many=True). That's a full input serializer for line items:
# apps/orders/serializers/input/line_item.py
from decimal import Decimal
from rest_framework import serializers
class LineItemInputSerializer(serializers.Serializer):
product_id = serializers.UUIDField()
quantity = serializers.IntegerField(min_value=1)
unit_price = serializers.DecimalField(max_digits=12, decimal_places=2)validated_data["items"] on the parent becomes a list of dicts. The service picks it up, resolves each product_id to a Product instance, and creates the LineItem rows in a single bulk_create — the pattern from Part 3.
Validation
Two seams for validation in input serializers.
validate_<field> for single-field checks:
def validate_shipping_address(self, value: str) -> str:
if len(value.strip()) < 5:
raise serializers.ValidationError("Shipping address too short.")
return value.strip()validate for cross-field rules:
def validate(self, data):
if not data.get("items"):
raise serializers.ValidationError({"items": "At least one line item is required."})
return dataThe serializer validation catches shape-level problems — types, presence, cross-field consistency. Business-rule validation ("this customer isn't allowed to order past their credit limit") stays in the service, because the service is what enforces it for API calls, Celery tasks, and management commands alike. If it lives in the serializer, it only fires on API traffic.
Output serializers
Output serializers are ModelSerializer subclasses that turn a model instance into a dict. The pattern is uniform across every one I've written:
# apps/orders/serializers/output/order.py
from rest_framework import serializers
from orders.models import Order
from accounts.serializers.output.user import UserMiniOutputSerializer
from orders.serializers.output.line_item import LineItemOutputSerializer
class OrderMiniOutputSerializer(serializers.ModelSerializer):
"""Compact shape for list rows and nested references."""
id = serializers.UUIDField(source="public_id", read_only=True)
customer = UserMiniOutputSerializer(read_only=True)
class Meta:
model = Order
fields = [
"id",
"order_number",
"status",
"total",
"customer",
"created_at",
]
read_only_fields = fields
class OrderOutputSerializer(serializers.ModelSerializer):
"""Full shape for detail views."""
id = serializers.UUIDField(source="public_id", read_only=True)
customer = UserMiniOutputSerializer(read_only=True)
line_items = LineItemOutputSerializer(many=True, read_only=True)
line_item_count = serializers.SerializerMethodField()
class Meta:
model = Order
fields = [
"id",
"order_number",
"status",
"total",
"shipping_address",
"notes",
"customer",
"line_items",
"line_item_count",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_line_item_count(self, obj: Order) -> int:
return obj.line_items.count()Five conventions worth naming.
id = UUIDField(source="public_id", read_only=True) on every output. The wire always shows public_id, never the internal BigAutoField — that's the rule from Part 1. The source= remap means the client sees {"id": "018f2c..."} while the code is still using the sortable UUID that lives on public_id.
read_only_fields = fields — literally that. Set every field read-only. Output serializers exist to shape responses; they will never be handed a POST body. Making the read-only status explicit prevents any code path from accidentally calling .save() on one and getting mysterious behavior.
Nested objects use nested serializers. customer = UserMiniOutputSerializer(read_only=True) renders the customer as a small object with a name, email and id. Same pattern for line_items — it's a LineItemOutputSerializer(many=True) and the client sees a list of line-item objects, not a list of IDs.
Two sizes per domain: Mini and full. OrderMiniOutputSerializer for list rows and nested references (compact, ~5-6 fields). OrderOutputSerializer for detail views (everything). The naming is uniform — every domain has {Entity}MiniOutputSerializer and {Entity}OutputSerializer — so grepping either finds every occurrence quickly.
Computed fields via SerializerMethodField. get_line_item_count(self, obj) returns whatever the field should hold. If the underlying query is expensive, prefetch it in the selector — the serializer stays cheap. If you're using drf-spectacular for OpenAPI schema generation, decorate the get_... method with @extend_schema_field(...) so the schema reflects the real type.
Helpers with a leading underscore
Sometimes a nested shape only makes sense in one file — a compact "provider" object embedded in an order response that isn't reused. I write those as private helpers with a leading underscore, in the same file:
class _MerchantMiniOutputSerializer(serializers.Serializer):
id = serializers.UUIDField(source="public_id", read_only=True)
name = serializers.CharField(read_only=True)
code = serializers.CharField(read_only=True)
class OrderOutputSerializer(serializers.ModelSerializer):
merchant = _MerchantMiniOutputSerializer(read_only=True)
# ... rest of the fieldsThe underscore prefix means "don't import this from another module." One-file scope, no reuse pressure.
Why services never touch output serializers
One rule that's saved me a lot of confusion: services return model instances, views serialize them. A service never returns a serialized dict. OrderService.create_order(...) returns an Order. If a Celery task calls the same service, the task gets an Order back — it doesn't need to unserialize a dict just to touch model attributes.
The output serializer only runs when a wire response is about to happen. That means:
- Two entry points (an API view and a WebSocket event, say) can shape the same
Orderdifferently — with two different output serializers — because the shape is a wire concern. - Services stay HTTP-agnostic. They can be tested without DRF anywhere in scope.
- Debugging is easier: the object flowing through the code has attributes and methods, not a dict where you have to remember the key names.
The direction is one-way: service → model → view → output serializer → response. Never the other way.
Two questions I get about this split
"Why not use ModelSerializer for input too?" Because a ModelSerializer binds the input shape to the model shape. But the model has an id, a public_id, a created_at, a created_by, and a bunch of internal columns that the client never sends. So you end up with read_only_fields = [...] on half of them and it's not really validating a payload anymore — it's fighting the model. A plain Serializer is exactly what it says: a schema for the payload, no more.
"Isn't read_only_fields = fields redundant?" Yes, and I do it anyway. It's a load-bearing comment. When someone new to the codebase looks at an output serializer, that line tells them "this is only for shaping responses, don't try to .save() it." Ceremony that clarifies intent is worth the two words.
What's next in the series
Part 5 covers views and permissions — the final layer, where everything you've built so far gets wired up to URLs. DRF viewsets kept deliberately thin, permission classes that consume a pre-computed permission set (not one query per check), the pattern for URL routing, and how the whole vertical stitches together and finally starts to look like a real backend.
If you split input and output differently — or if you've made a "one serializer per model" approach work at real scale — I'd genuinely like to hear about it. That's the kind of pushback that makes these posts better.
- django
- django rest framework
- python
- software development