Skip to content

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:

user = UserSerializer(username="alice", email="alice@example.com")
print(user.username)  # "alice"

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:

UserSerializer(email="invalid")  # Raises 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

Validators run in order. If the first fails, subsequent validators don't run.

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:

result = UserSerializer.exclude("password", "secret_key").dump(user)

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)

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)

Performance tips

  1. Use field selection for lists: Only include fields you need in list views.

  2. Use select_related with from_model(): Prevent N+1 queries when serializing relationships.

  3. Use subset() for type safety: Creates actual classes that editors can type-check.

  4. Use write_only for sensitive data: Password fields should never appear in output.