Serializers¶
Django-Bolt provides a powerful Serializer class built on top of msgspec.Struct. It offers field validation, computed fields, dynamic field selection, and Django model integration - all with excellent performance.
Why use Serializers?¶
In Django REST Framework, you often need multiple serializer classes for different views:
# DRF approach - multiple serializers
class UserListSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username']
class UserDetailSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'created_at']
class UserAdminSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'created_at', 'is_staff']
With Django-Bolt's Serializer, you define one class and create views dynamically:
from django_bolt.serializers import Serializer
class UserSerializer(Serializer):
id: int
username: str
email: str
created_at: str
is_staff: bool = False
class Config:
field_sets = {
"list": ["id", "username"],
"detail": ["id", "username", "email", "created_at"],
"admin": ["id", "username", "email", "created_at", "is_staff"],
}
# Use different views from the same serializer
UserListSerializer = UserSerializer.fields("list")
UserDetailSerializer = UserSerializer.fields("detail")
Basic usage¶
Creating a serializer¶
from django_bolt.serializers import Serializer
class UserSerializer(Serializer):
username: str
email: str
Create instances just like dataclasses:
Converting to dict¶
user = UserSerializer(username="alice", email="alice@example.com")
data = user.to_dict()
# {'username': 'alice', 'email': 'alice@example.com'}
Using dump() for output¶
The dump() method serializes instances and respects configuration:
user = UserSerializer(username="alice", email="alice@example.com")
data = user.dump()
# {'username': 'alice', 'email': 'alice@example.com'}
Options for dump():
# Exclude None values
data = user.dump(exclude_none=True)
# Exclude default values
data = user.dump(exclude_defaults=True)
Default values¶
class UserSerializer(Serializer):
username: str
email: str = "no-email@example.com"
user = UserSerializer(username="bob")
print(user.email) # "no-email@example.com"
Optional fields¶
class UserSerializer(Serializer):
username: str
email: str | None = None
user = UserSerializer(username="alice")
print(user.email) # None
Field validation¶
The field_validator decorator¶
Use @field_validator to validate and transform individual fields:
from django_bolt.serializers import Serializer, field_validator
class UserSerializer(Serializer):
email: str
@field_validator("email")
def validate_email(cls, value):
if "@" not in value:
raise ValueError("Invalid email")
return value
Invalid data raises msgspec.ValidationError:
Transforming values¶
Validators can transform values before storage:
class UserSerializer(Serializer):
email: str
@field_validator("email")
def normalize_email(cls, value):
return value.lower().strip()
user = UserSerializer(email=" ALICE@EXAMPLE.COM ")
print(user.email) # "alice@example.com"
Multiple validators¶
Apply multiple validators to a single field:
class UserSerializer(Serializer):
password: str
@field_validator("password")
def check_length(cls, value):
if len(value) < 8:
raise ValueError("Password too short")
return value
@field_validator("password")
def check_complexity(cls, value):
if not any(c.isupper() for c in value):
raise ValueError("Password must have uppercase")
return value
For the same field, validators run in order - if one fails, subsequent validators on that field don't run.
Multi-error collection¶
Django-Bolt collects all validation errors from @field_validator and @model_validator across all fields before raising:
class UserSerializer(Serializer):
email: str
password: str
@field_validator("email")
def validate_email(cls, value):
if "@" not in value:
raise ValueError("Invalid email")
return value
@field_validator("password")
def validate_password(cls, value):
if len(value) < 8:
raise ValueError("Password too short")
return value
# Both email AND password are invalid
try:
UserSerializer(email="invalid", password="short")
except RequestValidationError as e:
# Returns ALL errors, not just the first one
print(e.errors())
# [
# {"loc": ["body", "email"], "msg": "Invalid email", "type": "value_error"},
# {"loc": ["body", "password"], "msg": "Password too short", "type": "value_error"}
# ]
This matches Pydantic's behavior and provides a better user experience - users can fix all issues at once instead of discovering them one at a time.
Understanding validation layers¶
Django-Bolt Serializer has two validation layers:
| Layer | Source | Raw msgspec | With model_validate() |
|---|---|---|---|
| Meta constraints | Meta(min_length, pattern, ge, le, ...) |
Fail-fast | Collects all errors |
| Custom validators | @field_validator, @model_validator |
N/A | Collects all errors |
Meta constraints use msgspec.Meta and are validated by msgspec's high-performance C code:
from typing import Annotated
from msgspec import Meta
class UserSerializer(Serializer):
name: Annotated[str, Meta(min_length=2, max_length=100)]
email: Annotated[str, Meta(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
age: Annotated[int, Meta(ge=0, le=150)]
When using raw msgspec (msgspec.convert() or direct decoding), Meta constraints are fail-fast. However, when using model_validate() or model_validate_json(), all Meta constraint errors are collected.
Custom validators run after msgspec validation and also collect all errors:
class UserSerializer(Serializer):
# Meta constraints (validated by msgspec)
name: Annotated[str, Meta(min_length=2)]
email: str
password: str
# Custom validators
@field_validator("email")
def validate_email(cls, value):
if "@" not in value:
raise ValueError("Invalid email")
return value.lower()
@field_validator("password")
def validate_password(cls, value):
if len(value) < 8:
raise ValueError("Password too short")
return value
Validation order:
- msgspec parses and validates - Type checking and Meta constraints
@field_validatorruns - Custom field validation@model_validatorruns - Cross-field validation- All errors raised together - Single
RequestValidationErrorwith all Meta and custom validator errors
For best performance, use Meta constraints for simple validations (length, range, pattern) and reserve @field_validator for complex logic or value transformations.
Using msgspec.Meta for constraints¶
For declarative validation, use Annotated with msgspec.Meta:
from typing import Annotated
from msgspec import Meta
from django_bolt.serializers import Serializer
class AuthorSerializer(Serializer):
id: int
name: Annotated[str, Meta(min_length=2)]
email: Annotated[str, Meta(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
Meta constraints are enforced during deserialization via msgspec.convert().
Model-level validation¶
The model_validator decorator¶
Use @model_validator for cross-field validation after all fields are set:
from django_bolt.serializers import Serializer, model_validator
class PasswordSerializer(Serializer):
password: str
password_confirm: str
@model_validator
def check_passwords_match(self):
if self.password != self.password_confirm:
raise ValueError("Passwords don't match")
Execution order¶
Field validators run first, then model validators:
class TestSerializer(Serializer):
value: str
@field_validator("value")
def field_val(cls, v):
print("Field validator")
return v
@model_validator
def model_val(self):
print("Model validator")
TestSerializer(value="test")
# Prints: "Field validator" then "Model validator"
Computed fields¶
Basic computed fields¶
Use @computed_field to add derived values to output:
from django_bolt.serializers import Serializer, computed_field
class UserSerializer(Serializer):
first_name: str
last_name: str
@computed_field
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
user = UserSerializer(first_name="John", last_name="Doe")
result = user.dump()
# {'first_name': 'John', 'last_name': 'Doe', 'full_name': 'John Doe'}
Computed fields with aliases¶
@computed_field(alias="displayName")
def display_name(self) -> str:
return f"{self.first_name} {self.last_name}".upper()
Chaining computed fields¶
Computed fields can use other computed fields:
class ProductSerializer(Serializer):
price: float
quantity: int
@computed_field
def total(self) -> float:
return self.price * self.quantity
@computed_field
def formatted_total(self) -> str:
return f"${self.total():.2f}" # Call as method
Dynamic field selection¶
One of the most powerful features: create different views from a single serializer.
Using only()¶
Select specific fields:
class UserSerializer(Serializer):
id: int
name: str
email: str
created_at: str
user = UserSerializer(id=1, name="John", email="john@example.com", created_at="2024-01-01")
# Get only id and name
result = UserSerializer.only("id", "name").dump(user)
# {'id': 1, 'name': 'John'}
Using exclude()¶
Exclude specific fields:
Using field_sets with use()¶
Define reusable field sets in Config:
class UserSerializer(Serializer):
id: int
name: str
email: str
password: str
created_at: str
updated_at: str
class Config:
field_sets = {
"list": ["id", "name", "email"],
"detail": ["id", "name", "email", "created_at", "updated_at"],
"minimal": ["id", "name"],
}
# Use predefined field sets
list_result = UserSerializer.use("list").dump(user)
detail_result = UserSerializer.use("detail").dump(user)
Chaining field selection¶
view = UserSerializer.only("id", "name", "email").exclude("email")
result = view.dump(user)
# {'id': 1, 'name': 'John'}
Field selection with computed fields¶
Computed fields work with field selection:
class UserSerializer(Serializer):
first_name: str
last_name: str
@computed_field
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
user = UserSerializer(first_name="John", last_name="Doe")
# Include computed field
result = UserSerializer.only("first_name", "full_name").dump(user)
# {'first_name': 'John', 'full_name': 'John Doe'}
Creating type-safe serializer subsets¶
The subset() method¶
Create actual subclasses with only specific fields:
class UserSerializer(Serializer):
id: int
name: str
email: str
password: str
# Create a type-safe subset
UserMiniSerializer = UserSerializer.subset("id", "name")
# UserMiniSerializer is a proper class
user = UserMiniSerializer(id=1, name="John")
The fields() method¶
Create subsets from field_sets:
class UserSerializer(Serializer):
id: int
name: str
email: str
class Config:
field_sets = {
"list": ["id", "name"],
"detail": ["id", "name", "email"],
}
UserListSerializer = UserSerializer.fields("list")
UserDetailSerializer = UserSerializer.fields("detail")
# These are proper subclasses with type annotations
assert issubclass(UserListSerializer, Serializer)
Converting from parent to subset¶
UserMini = UserSerializer.subset("id", "name")
# Create full instance
full_user = UserSerializer(id=1, name="John", email="john@example.com", password="secret")
# Convert to mini
mini_user = UserMini.from_parent(full_user)
print(mini_user.dump()) # {'id': 1, 'name': 'John'}
Write-only fields¶
Hide sensitive fields from output:
class UserCreateSerializer(Serializer):
email: str
password: str
class Config:
write_only = {"password"}
user = UserCreateSerializer(email="test@example.com", password="secret123")
result = user.dump()
# {'email': 'test@example.com'} # password excluded
Nested serializers¶
Basic nesting¶
class AddressSerializer(Serializer):
street: str
city: str
zip_code: str
class UserSerializer(Serializer):
id: int
name: str
address: AddressSerializer
address = AddressSerializer(street="123 Main St", city="NYC", zip_code="10001")
user = UserSerializer(id=1, name="John", address=address)
result = user.dump()
# {
# 'id': 1,
# 'name': 'John',
# 'address': {'street': '123 Main St', 'city': 'NYC', 'zip_code': '10001'}
# }
Lists of nested serializers¶
class TagSerializer(Serializer):
id: int
name: str
class PostSerializer(Serializer):
id: int
title: str
tags: list[TagSerializer]
tags = [TagSerializer(id=1, name="python"), TagSerializer(id=2, name="django")]
post = PostSerializer(id=1, title="Hello World", tags=tags)
Using Nested marker for Django models¶
The Nested marker provides explicit control over nested serialization:
from typing import Annotated
from django_bolt.serializers import Serializer, Nested
class AuthorSerializer(Serializer):
id: int
name: str
email: str
class BlogPostSerializer(Serializer):
id: int
title: str
author: Annotated[AuthorSerializer, Nested(AuthorSerializer)]
tags: Annotated[list[TagSerializer], Nested(TagSerializer, many=True)]
Django model integration¶
From model to serializer¶
Use from_model() to create serializer instances from Django models:
class ArticleSerializer(Serializer):
id: int
title: str
content: str
# From Django model
article = await Article.objects.aget(id=1)
serializer = ArticleSerializer.from_model(article)
With select_related¶
When using from_model() with ForeignKey relationships, use select_related:
# Without select_related - may cause N+1 queries
post = await BlogPost.objects.aget(id=1)
serializer = BlogPostSerializer.from_model(post) # author might be just an ID
# With select_related - nested object included
post = await BlogPost.objects.select_related("author").aget(id=1)
serializer = BlogPostSerializer.from_model(post) # author is full object
Bulk serialization¶
# Serialize multiple instances
users = [
UserSerializer(id=1, name="John"),
UserSerializer(id=2, name="Jane"),
]
result = UserSerializer.only("id", "name").dump_many(users)
# [{'id': 1, 'name': 'John'}, {'id': 2, 'name': 'Jane'}]
Serializer inheritance¶
Serializers support inheritance:
class BaseUserSerializer(Serializer):
username: str
email: str
@field_validator("email")
def validate_email(cls, value):
if "@" not in value:
raise ValueError("Invalid email")
return value
class AdminSerializer(BaseUserSerializer):
is_admin: bool = False
# Child inherits validators
admin = AdminSerializer(username="alice", email="alice@example.com", is_admin=True)
Built-in type aliases¶
Django-Bolt provides pre-defined type aliases for common patterns:
from django_bolt.serializers import (
# String lengths
Char50, Char100, Char255,
# Validated strings
Email, URL, Slug, UUID,
# Integers
PositiveInt, NonNegativeInt,
# Network
IPv4, IPv6, Port,
# Auth
Username, Password,
# And more...
)
class UserSerializer(Serializer):
username: Username # max 150 chars, validated pattern
email: Email # validated email format
website: URL | None # validated URL
Using with API handlers¶
from django_bolt import BoltAPI
from django_bolt.serializers import Serializer
api = BoltAPI()
class UserSerializer(Serializer):
id: int
username: str
email: str
class Config:
field_sets = {
"list": ["id", "username"],
"detail": ["id", "username", "email"],
}
UserListSerializer = UserSerializer.fields("list")
UserDetailSerializer = UserSerializer.fields("detail")
@api.get("/users")
async def list_users() -> list[UserListSerializer]:
users = []
async for user in User.objects.all()[:20]:
users.append(UserListSerializer.from_model(user))
return users
@api.get("/users/{user_id}")
async def get_user(user_id: int) -> UserDetailSerializer:
user = await User.objects.aget(id=user_id)
return UserDetailSerializer.from_model(user)
Serializer vs raw msgspec.Struct¶
Django-Bolt's Serializer extends msgspec.Struct with important enhancements, particularly around error handling:
Error handling differences¶
| Feature | msgspec.Struct |
Django-Bolt Serializer |
|---|---|---|
| Meta constraint errors | Fail-fast (first error only) | Collects all errors via model_validate()/model_validate_json() |
| Custom validators | Not supported | @field_validator, @model_validator with multi-error collection |
| Error format | msgspec.ValidationError (string) |
RequestValidationError with structured errors() list |
| Direct instantiation | No validation | Runs custom validators (Meta constraints bypassed) |
Multi-error collection with model_validate()¶
When using model_validate() or model_validate_json(), the Serializer collects all validation errors before raising:
from typing import Annotated
from msgspec import Meta
from django_bolt.serializers import Serializer
from django_bolt.exceptions import RequestValidationError
class UserSerializer(Serializer):
name: Annotated[str, Meta(min_length=2)]
email: Annotated[str, Meta(pattern=r"^[^@]+@[^@]+$")]
age: Annotated[int, Meta(ge=0, le=150)]
# All three fields are invalid
try:
UserSerializer.model_validate({
"name": "X", # Too short
"email": "invalid", # No @ symbol
"age": 200 # Over 150
})
except RequestValidationError as e:
# Returns ALL errors, not just the first
for err in e.errors():
print(f"{err['loc']}: {err['msg']}")
# ('body', 'name'): Expected `str` of length >= 2
# ('body', 'email'): Expected `str` matching regex...
# ('body', 'age'): Expected `int` <= 150
This is inspired by Litestar's approach to validation - validating each field individually when the initial validation fails, then collecting all errors.
When errors are collected vs fail-fast¶
| Method | Behavior |
|---|---|
Serializer(field=value) |
Runs custom validators (multi-error), bypasses Meta constraints |
model_validate(dict) |
Collects all Meta + custom validator errors |
model_validate_json(json) |
Collects all Meta + custom validator errors |
msgspec.convert(data, Serializer) |
Fail-fast (raw msgspec behavior) |
msgspec.json.decode(json, type=Serializer) |
Fail-fast (raw msgspec behavior) |
Recommendation: Always use model_validate() or model_validate_json() for user input to get comprehensive error messages.
Why direct instantiation bypasses Meta constraints¶
This is msgspec's design - Meta constraints (min_length, pattern, ge, etc.) are for parsing external data, not for constructing objects in Python:
class User(Serializer):
age: Annotated[int, Meta(ge=0)]
# Direct instantiation - Meta constraint NOT checked
user = User(age=-5) # Works! No error raised
# Parsing - Meta constraint IS checked
User.model_validate({"age": -5}) # Raises RequestValidationError
Custom validators (@field_validator, @model_validator) always run, regardless of how the instance is created.
Performance tips¶
-
Use field selection for lists: Only include fields you need in list views.
-
Use select_related with from_model(): Prevent N+1 queries when serializing relationships.
-
Use subset() for type safety: Creates actual classes that editors can type-check.
-
Use write_only for sensitive data: Password fields should never appear in output.
-
Use Meta constraints for simple validation: They're validated by msgspec's C code - much faster than Python validators.