Model Serializer¶
ModelSerializer is a powerful abstract mixin for Django models that centralizes schema generation and serialization configuration directly on the model class.
Overview¶
Goals:
- Eliminate duplication between Model and separate serializer classes
 - Provide clear extension points (sync + async hooks, custom synthetic fields)
 - Auto-generate Ninja schemas from model metadata
 - Support nested serialization for relationships
 
Key Features:
- Declarative schema configuration via inner classes
 - Automatic CRUD schema generation
 - Nested relationship handling
 - Sync and async lifecycle hooks
 - Custom field support (computed/synthetic fields)
 
Quick Start¶
from django.db import models
from ninja_aio.models import ModelSerializer
class User(ModelSerializer):
    username = models.CharField(max_length=150, unique=True)
    email = models.EmailField(unique=True)
    class CreateSerializer:
        fields = ["username", "email"]
    class ReadSerializer:
        fields = ["id", "username", "email"]
    def __str__(self):
        return self.username
Inner Configuration Classes¶
CreateSerializer¶
Describes how to build a create (input) schema for a model.
Attributes:
| Attribute | Type | Description | 
|---|---|---|
fields | 
list[str] | 
REQUIRED model field names for creation | 
optionals | 
list[tuple[str, type]] | 
Optional model fields: (field_name, python_type) | 
customs | 
list[tuple] | 
Synthetic inputs. Tuple forms: (name, type) = required (no default); (name, type, default) = optional (literal or callable) | 
excludes | 
list[str] | 
Field names rejected on create | 
Example:
class User(ModelSerializer):
    username = models.CharField(max_length=150)
    email = models.EmailField()
    password = models.CharField(max_length=128)
    bio = models.TextField(blank=True)
    class CreateSerializer:
        fields = ["username", "email", "password"]
        optionals = [
            ("bio", str),
        ]
        customs = [
            ("password_confirm", str),          # required (no default) equivalent of ("password_confirm", str, ...)
            ("send_welcome_email", bool, True), # optional with default
        ]
        excludes = ["id", "created_at"]
Resolution Order for customs:
- Payload value (if provided)
 - If default present and callable → invoked
 - Literal default (if provided)
 - If tuple was only (name, type) and no value supplied → validation error (required)
 
Conceptual Equivalent (django-ninja):
# Without ModelSerializer
from ninja import ModelSchema
class UserIn(ModelSchema):
    class Meta:
        model = User
        fields = ["username", "email"]
ReadSerializer¶
Describes how to build a read (output) schema for a model.
Attributes:
| Attribute   | Type                     | Description                                                                                                                                                                                                                                       |
| ----------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| fields    | list[str]              | REQUIRED model field / related names explicitly included in the read (output) schema.                                                                                                                                                             |
| excludes  | list[str]              | Field / related names to always omit (takes precedence over fields and optionals). Use for sensitive or noisy data (e.g., passwords, internal flags).                                                                                          |
| customs   | list[tuple]            | Computed / synthetic output values. Tuples:(name, type) = required resolvable attribute (object attribute or property) else serialization error.(name, type, default) = optional; default may be callable (lambda obj: ...) or literal. |
Example:
class User(ModelSerializer):
    first_name = models.CharField(max_length=150)
    last_name = models.CharField(max_length=150)
    email = models.EmailField()
    password = models.CharField(max_length=128)
    class ReadSerializer:
        fields = ["id", "first_name", "last_name", "email", "created_at"]
        excludes = ["password"]
        customs = [
            ("full_name", str, lambda obj: f"{obj.first_name} {obj.last_name}".strip()),
            ("is_premium", bool, lambda obj: obj.subscription.is_active if hasattr(obj, 'subscription') else False),
        ]
Resolution Order for customs:
- Attribute / property on instance
 - Callable default (if provided)
 - Literal default
 - If required (2‑tuple) and still unresolved → error
 
Generated Output:
{
  "id": 1,
  "first_name": "John",
  "last_name": "Doe",
  "email": "john@example.com",
  "created_at": "2024-01-15T10:30:00Z",
  "full_name": "John Doe",
  "is_premium": true
}
UpdateSerializer¶
Describes how to build an update (partial/full) input schema.
Attributes:
| Attribute | Type | Description | 
|---|---|---|
fields | 
list[str] | 
REQUIRED fields for update (rarely used) | 
optionals | 
list[tuple[str, type]] | 
Updatable optional fields (typical for PATCH) | 
customs | 
list[tuple] | 
Instruction fields: (name, type) required; (name, type, default) optional | 
excludes | 
list[str] | 
Immutable fields that cannot be updated | 
Example:
class User(ModelSerializer):
    username = models.CharField(max_length=150, unique=True)
    email = models.EmailField()
    bio = models.TextField(blank=True)
    is_active = models.BooleanField(default=True)
    class UpdateSerializer:
        optionals = [
            ("email", str),
            ("bio", str),
            ("is_active", bool),
        ]
        customs = [
            ("reset_password", bool, False),  # optional flag
            ("rotate_token", bool),          # required instruction
        ]
        excludes = ["username", "created_at", "id"]
Usage (PATCH request):
Schema Generation¶
Auto-Generated Schemas¶
ModelSerializer automatically generates four schema types:
| Method | Schema Type | Purpose | 
|---|---|---|
generate_create_s() | 
Input ("In") | POST endpoint payload | 
generate_update_s() | 
Input ("Patch") | PATCH/PUT endpoint payload | 
generate_read_s(depth=1) | 
Output ("Out") | Response with nested relations | 
generate_related_s() | 
Output ("Related") | Compact nested representation | 
Example:
class User(ModelSerializer):
    username = models.CharField(max_length=150)
    email = models.EmailField()
    class CreateSerializer:
        fields = ["username", "email"]
    class ReadSerializer:
        fields = ["id", "username", "email"]
# Auto-generate schemas
UserCreateSchema = User.generate_create_s()
UserReadSchema = User.generate_read_s()
UserUpdateSchema = User.generate_update_s()
UserRelatedSchema = User.generate_related_s()
Nested Relationship Handling¶
ModelSerializer automatically serializes relationships if the related model is also a ModelSerializer.
ForeignKey (Forward)¶
class Profile(ModelSerializer):
    bio = models.TextField()
    class ReadSerializer:
        fields = ["id", "bio"]
class User(ModelSerializer):
    username = models.CharField(max_length=150)
    profile = models.ForeignKey(Profile, on_delete=models.CASCADE)
    class ReadSerializer:
        fields = ["id", "username", "profile"]
Output:
ManyToMany¶
class Tag(ModelSerializer):
    name = models.CharField(max_length=50)
    class ReadSerializer:
        fields = ["id", "name"]
class Article(ModelSerializer):
    title = models.CharField(max_length=200)
    tags = models.ManyToManyField(Tag, related_name="articles")
    class ReadSerializer:
        fields = ["id", "title", "tags"]
Output:
{
  "id": 1,
  "title": "Getting Started",
  "tags": [
    { "id": 1, "name": "python" },
    { "id": 2, "name": "django" }
  ]
}
Reverse Relationships¶
class Author(ModelSerializer):
    name = models.CharField(max_length=200)
    class ReadSerializer:
        fields = ["id", "name", "books"]  # Reverse FK
class Book(ModelSerializer):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
    class ReadSerializer:
        fields = ["id", "title"]
Output:
{
  "id": 1,
  "name": "J.K. Rowling",
  "books": [
    { "id": 1, "title": "Harry Potter" },
    { "id": 2, "title": "Fantastic Beasts" }
  ]
}
Async Extension Points¶
queryset_request(request)¶
Filter queryset based on request context (user, permissions, tenant, etc.).
class Article(ModelSerializer):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    is_published = models.BooleanField(default=False)
    @classmethod
    async def queryset_request(cls, request):
        qs = cls.objects.select_related('author').all()
        # Non-authenticated users see only published
        if not request.auth:
            return qs.filter(is_published=True)
        # Authors see their own + published
        return qs.filter(
            models.Q(author=request.auth) | models.Q(is_published=True)
        )
post_create()¶
Execute async logic after object creation.
class User(ModelSerializer):
    email = models.EmailField()
    async def post_create(self):
        # Send welcome email
        from myapp.tasks import send_welcome_email
        await send_welcome_email(self.email)
        # Create related objects
        await Profile.objects.acreate(user=self)
        # Log creation
        await AuditLog.objects.acreate(
            action="user_created",
            user_id=self.id
        )
custom_actions(payload)¶
React to synthetic/custom fields from the payload.
class User(ModelSerializer):
    password = models.CharField(max_length=128)
    class CreateSerializer:
        fields = ["username", "email", "password"]
        customs = [
            ("password_confirm", str, None),
            ("send_welcome_email", bool, True),
        ]
    async def custom_actions(self, payload: dict):
        # Validate password confirmation
        if "password_confirm" in payload:
            if payload["password_confirm"] != self.password:
                raise ValueError("Passwords do not match")
        # Send welcome email if requested
        if payload.get("send_welcome_email", True):
            await send_email(self.email, "Welcome!")
Sync Lifecycle Hooks¶
Save Hooks¶
class User(ModelSerializer):
    username = models.CharField(max_length=150)
    slug = models.SlugField(unique=True, blank=True)
    def before_save(self):
        """Executed before every save (create + update)"""
        if not self.slug:
            self.slug = slugify(self.username)
    def on_create_before_save(self):
        """Executed only on creation, before save"""
        self.set_password(self.password)  # Hash password
    def after_save(self):
        """Executed after every save"""
        cache.delete(f"user:{self.id}")
    def on_create_after_save(self):
        """Executed only after creation"""
        send_welcome_email_sync(self.email)
Execution Order:
CREATE:
1. on_create_before_save()
2. before_save()
3. super().save()
4. on_create_after_save()
5. after_save()
UPDATE:
1. before_save()
2. super().save()
3. after_save()
Delete Hook¶
class User(ModelSerializer):
    def on_delete(self):
        """Executed after object deletion"""
        # Clean up related data
        logger.info(f"User {self.username} deleted")
        # Remove from cache
        cache.delete(f"user:{self.id}")
        # Archive data
        ArchivedUser.objects.create(
            username=self.username,
            deleted_at=timezone.now()
        )
Utility Methods¶
has_changed(field)¶
Check if a field value has changed compared to database.
class Article(ModelSerializer):
    title = models.CharField(max_length=200)
    status = models.CharField(max_length=20)
    def before_save(self):
        if self.has_changed('status'):
            if self.status == 'published':
                self.published_at = timezone.now()
                notify_subscribers(self)
verbose_name_path_resolver()¶
Get slugified plural verbose name for URL routing.
class BlogPost(ModelSerializer):
    class Meta:
        verbose_name_plural = "blog posts"
# Returns: "blog-posts"
path = BlogPost.verbose_name_path_resolver()
ModelUtil¶
Helper class for async CRUD operations with ModelSerializer.
Overview¶
Key Responsibilities:
- Introspect model metadata
 - Normalize inbound/outbound payloads
 - Handle FK resolution and base64 decoding
 - Prefetch reverse relations
 - Invoke serializer hooks
 
Key Methods¶
get_object(request, pk=None, filters=None, getters=None)¶
Fetch single object or queryset with optimized queries.
# Get single object
user = await util.get_object(request, pk=1)
# Get with filters
active_users = await util.get_object(
    request,
    filters={"is_active": True}
)
# Get with custom lookup
user = await util.get_object(
    request,
    getters={"email": "john@example.com"}
)
Features:
- Automatic 
select_related()andprefetch_related() - Respects 
queryset_request()filtering - Raises 
SerializeError(404) if not found 
parse_input_data(request, data)¶
Convert incoming schema to model-ready dict.
Transformations:
- Strips custom fields (stored separately)
 - Removes optional fields with 
Nonevalue - Decodes BinaryField (base64 → bytes)
 - Resolves FK IDs to model instances
 
from ninja import Schema
class UserCreateSchema(Schema):
    username: str
    email: str
    profile_id: int
    avatar: str  # base64 for BinaryField
    send_welcome: bool  # custom field
data = UserCreateSchema(
    username="john",
    email="john@example.com",
    profile_id=5,
    avatar="iVBORw0KG...",  # base64
    send_welcome=True
)
payload, customs = await util.parse_input_data(request, data)
# payload = {
#     "username": "john",
#     "email": "john@example.com",
#     "profile": <Profile instance>,
#     "avatar": b'\x89PNG\r\n...'
# }
# customs = {"send_welcome": True}
parse_output_data(request, data)¶
Post-process serialized output for consistency.
Transformations:
- Replaces nested FK dicts with actual instances
 - Rewrites nested FK keys to 
<field>_idformat 
# Before
{
    "id": 1,
    "author": {"id": 10, "profile": {"id": 5}}
}
# After parse_output_data
{
    "id": 1,
    "author": <Author instance>,
    "author_id": 10,
    "profile_id": 5
}
CRUD Operations¶
create_s(request, data, obj_schema)¶
user_data = UserCreateSchema(username="john", email="john@example.com")
result = await util.create_s(request, user_data, UserReadSchema)
# Executes:
# 1. parse_input_data (normalize)
# 2. Model.objects.acreate()
# 3. custom_actions(customs)
# 4. post_create()
# 5. read_s (serialize response)
read_s(request, obj, obj_schema)¶
user = await User.objects.aget(id=1)
result = await util.read_s(request, user, UserReadSchema)
# Returns parsed dict ready for API response
update_s(request, data, pk, obj_schema)¶
update_data = UserUpdateSchema(email="newemail@example.com")
result = await util.update_s(request, update_data, 1, UserReadSchema)
# Executes:
# 1. get_object(pk)
# 2. parse_input_data
# 3. Update changed fields
# 4. custom_actions(customs)
# 5. obj.asave()
# 6. read_s (serialize updated object)
delete_s(request, pk)¶
Error Handling¶
from ninja_aio.exceptions import SerializeError
try:
    user = await util.get_object(request, pk=999)
except SerializeError as e:
    # e.details = {"user": "not found"}
    # e.status_code = 404
    pass
try:
    await util.create_s(request, bad_data, UserReadSchema)
except SerializeError as e:
    # e.details = {"avatar": "Invalid base64"}
    # e.status_code = 400
    pass
Complete Example¶
from django.db import models
from ninja_aio.models import ModelSerializer
from django.utils.text import slugify
class Category(ModelSerializer):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True, blank=True)
    class CreateSerializer:
        fields = ["name"]
    class ReadSerializer:
        fields = ["id", "name", "slug"]
    class UpdateSerializer:
        optionals = [("name", str)]
    def before_save(self):
        if not self.slug:
            self.slug = slugify(self.name)
class Author(ModelSerializer):
    name = models.CharField(max_length=200)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    class CreateSerializer:
        fields = ["name", "email"]
        optionals = [("bio", str)]
    class ReadSerializer:
        fields = ["id", "name", "email", "bio"]
        customs = [
            ("post_count", int, lambda obj: obj.articles.count()),
        ]
    class UpdateSerializer:
        optionals = [
            ("name", str),
            ("email", str),
            ("bio", str),
        ]
class Article(ModelSerializer):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    content = models.TextField()
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="articles")
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    tags = models.ManyToManyField('Tag', related_name="articles")
    is_published = models.BooleanField(default=False)
    views = models.IntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    class CreateSerializer:
        fields = ["title", "slug", "content", "author", "category"]
        customs = [
            ("notify_subscribers", bool, True),
        ]
    class ReadSerializer:
        fields = [
            "id", "title", "slug", "content",
            "author", "category", "tags",
            "is_published", "views", "created_at"
        ]
    class UpdateSerializer:
        optionals = [
            ("title", str),
            ("content", str),
            ("category", int),
            ("is_published", bool),
        ]
        excludes = ["slug", "author", "created_at"]
    @classmethod
    async def queryset_request(cls, request):
        qs = cls.objects.select_related('author', 'category').prefetch_related('tags')
        if not request.auth:
            return qs.filter(is_published=True)
        return qs.filter(
            models.Q(author=request.auth) | models.Q(is_published=True)
        )
    async def post_create(self):
        await AuditLog.objects.acreate(
            action="article_created",
            article_id=self.id,
            author_id=self.author_id
        )
    async def custom_actions(self, payload: dict):
        if payload.get("notify_subscribers"):
            await notify_new_article(self)
    def before_save(self):
        if self.has_changed('is_published') and self.is_published:
            self.published_at = timezone.now()
class Tag(ModelSerializer):
    name = models.CharField(max_length=50, unique=True)
    class ReadSerializer:
        fields = ["id", "name"]
Custom Fields Normalization¶
Custom tuples are normalized by ModelSerializer.get_custom_fields() to (name, python_type, default).
Accepted forms:
(name, type)-> stored withdefault = Ellipsis(treated as required)(name, type, default)-> default kept (callable or literal)
Invalid lengths raise ValueError.
Example mix:
class CreateSerializer:
    customs = [
        ("password_confirm", str),           # required
        ("send_welcome", bool, True),        # optional
        ("initial_quota", int, lambda: 100)  # optional callable
    ]
At runtime:
# Normalized
[
  ("password_confirm", str, Ellipsis),
  ("send_welcome", bool, True),
  ("initial_quota", int, <function ...>)
]
Required customs (Ellipsis) must be provided in input (create/update) or resolvable (read) or an error is raised.
Error Cases¶
| Situation | Result | 
|---|---|
| 1‑item or 4‑item tuple | ValueError | 
| Missing required custom (2‑tuple) in payload | Validation error | 
| Unresolvable required read custom | Serialization error | 
Best Practices¶
- Always exclude sensitive fields:
 
- Use optionals for PATCH operations:
 
- Leverage customs for computed data:
 
- Optimize queries in queryset_request:
 
@classmethod
async def queryset_request(cls, request):
    return cls.objects.select_related('author').prefetch_related('tags')
- Keep hooks focused:
 
See Also¶
- Model Util - Deep dive into ModelUtil
 - API ViewSet - Using ModelSerializer with ViewSets
 - Authentication - Securing endpoints