Skip to content

Why Not Use ModelSchema?#

2024 iThome Ironman

This is the 15th post in the Django Ninja tutorial series.

Django API Responses are often produced by selecting and processing fields from Model objects—that is, database records.

For example, the "Get Single Post" API actually selects fields from the Post object and then serializes them.

During this process, we need to consider how to convert model objects into API response structures while keeping the code maintainable and flexible.

To address this, Django REST framework (hereafter, DRF) provides a highly practical, specialized serializer: ModelSerializer, a core feature for DRF developers.

Although Django Ninja offers a similar feature, ModelSchema, I find it less compelling and rarely use it.

Such a difference is undoubtedly caused by the different core design philosophies of the two.

We discussed the main differences in functionality between the two in Post 3. This article will use the representative topic of "serialization of Django model objects" to explain "why I prefer writing Django Ninja over DRF".

GitHub Example Project#

👉 Django-Ninja-Tutorial


Highlights of ModelSerializer#

ModelSerializer in DRF is a very powerful tool. It can automatically convert Django models into the data structures needed by the API—serializers, greatly simplifying the process of "defining fields for serializers".

As a side note, DRF serializers are equivalent to the Schemas used by Django Ninja. The concepts of the two are very similar; both are used for data validation and serialization.

If we rewrite the "Get Single Post" API response using ModelSerializer, it will look like this:

from rest_framework import serializers

# Author Serializer
class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email']

# Post Serializer
class PostSerializer(serializers.ModelSerializer):
    author = AuthorSerializer()  # Nested Author Serializer

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'created_at', 'updated_at']

As you can see, through ModelSerializer, we only need a small amount of code to define the serializer, thereby avoiding the repetition and hassle of manual configuration.


Hidden Concerns of ModelSerializer#

However, this convenience also brings certain hidden concerns.

Since you don't have to define fields yourself, ModelSerializer performs a lot of implicit conversion of fields for you—converting Django Model fields into serializer fields.

Why do we call it "implicit"? Because you might not be clear about the details of the serializer fields after automatic conversion, such as field types, characteristics, and whether they are read-only (read_only).

In other words, ModelSerializer not only automatically generates fields but also automatically infers the field types, attributes, attribute parameters, etc.

An Example Illustration#

This explanation is a bit abstract and might be hard to understand for readers who haven't written DRF. Let's look directly at an example:

from django.db import models

class Person(models.Model):
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)

This is an extremely simple Django Model, which I quoted from the Django Official Documentation.

It has two fields: first_name and last_name. In fact, it also has an id field automatically generated by Django, which is not shown in the code.

The "Magic" of ModelSerializer#

Using ModelSerializer, we can define the serializer like this:

from rest_framework import serializers
from .models import Person

class PersonSerializer(serializers.ModelSerializer):
    class Meta:
        model = Person
        fields = ['id', 'first_name', 'last_name']

The code is very simple, but there is a lot of "magic" behind it.

Because the actual serializer and fields look like this:

from rest_framework import serializers

class PersonSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    first_name = serializers.CharField(max_length=30)
    last_name = serializers.CharField(max_length=30)

    def create(self, validated_data):
        return Person.objects.create(**validated_data)

    def update(self, instance, validated_data):
        instance.first_name = validated_data.get('first_name', instance.first_name)
        instance.last_name = validated_data.get('last_name', instance.last_name)
        instance.save()
        return instance

Are you a bit surprised?

Implicit Conversion Does a Lot#

Among them, the id field is automatically given read_only=True, and first_name and last_name are automatically given max_length=30.

This is still under the condition that the design and field parameters of the Django Model are relatively simple. When Model fields get more complex, the "magic" of ModelSerializer becomes even more complicated.

Many conversion rules operate behind the scenes, sometimes forcing developers to learn these hidden rules when the inferred result does not match their requirements and must be overridden manually.

In short, automatic inference and conversion do save the trouble of manual settings. However, when you need to adjust certain details or understand the specific conversion logic, this implicit behavior might make you feel confused.

The Cost of Magic#

In practical development, the "magic" of this implicit conversion makes developers lose their understanding and control over the conversion process. You might very likely find that the serialization result is not completely consistent with what you expected!

At this point, we often need to look up DRF's official documentation to understand how these field conversions are handled internally, but not every detail is written clearly.

For developers, especially when dealing with complex APIs, it significantly increases learning and maintenance costs.

This is exactly my experience!

Even after writing DRF for 2 years, when encountering serialization issues, I still often need to recheck the documentation.


ModelSchema#

Django Ninja's ModelSchema, compared to ModelSerializer, appears much more "bare-bones".

How so? Let's look at the example in the official documentation:

from django.contrib.auth.models import User
from ninja import ModelSchema

class UserSchema(ModelSchema):
    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name']

# Will create schema like this:
#
# class UserSchema(Schema):
#     id: int
#     username: str
#     first_name: str
#     last_name: str

We say it is bare-bones because it only helps you automatically convert and define the "type" of the fields. Other field details, such as max_length, must be configured using FieldModelSchema will not do this for you.

Meanwhile, DRF's ModelSerializer, as mentioned earlier, "does much more".

Since the automatic conversion of ModelSchema is relatively straightforward, why do I still not recommend using it? There are two reasons.

The first reason is the very reason why "I prefer Django Ninja", as stated in the title.


Reason 1: Low Coupling + Explicit Is Better Than Implicit#

Django Ninja emphasizes the developer's mastery over the API structure, whereas DRF leans toward providing highly integrated and convenient tools.

This difference is reflected in how they treat Django model serialization, and also affects the style and way of thinking of developers when using these two frameworks.

Django REST Framework and Django Are Highly Coupled#

We can see that DRF is almost an API development tool "highly customized for Django".

Although this tight integration brings convenience, it also means that DRF heavily relies on Django's internal structure and features. This is true for both Generic views and the ModelSerializer discussed in this article.

The advantage of tight coupling is that you can write much less code; the cost is that you must understand exactly what the framework is doing for you.

Explicit Is Better Than Implicit#

Compared to DRF, the coupling level between Django Ninja and Django is much lower.

In my opinion, Django Ninja prefers "explicit is better than implicit." The Schema definition in Django Ninja is based on Pydantic, which requires developers to explicitly define every field, whether input or output.

Although this is relatively tedious, the benefits it brings are obvious.

Two Major Advantages of Explicitness#

First, manually defining the Schema gives developers absolute control over the data structure. There are no hidden rules or black-box operations; everything is crystal clear.

Second, this method effectively reduces the coupling between the model layer and the API layer. In practical development, model designs might update as requirements change, but this should not directly affect the API.

In general, Django Ninja emphasizes Schema-centric control, making API design more stable and flexible, and granting developers complete control over the data flow.


Reason 2: Better and More Readable API Documentation#

In Post 18, we will discuss in detail the impact of Schema field settings on API documentation.

In short, if ModelSchema is used, the rendered API documentation will be quite bare-bones.

This does not align with my pursuit of clarity and explicitness in API documentation.


Conclusion#

Admittedly, Django REST framework has some very convenient and thoughtful designs, such as the source= parameter mentioned in the previous article, which is intuitive and elegant.

Django Ninja requires developers to manually define each field as much as possible to reduce the coupling between models and the API layer. This is more in line with the "explicit is better than implicit" principle in the Zen of Python, while avoiding potential issues brought by implicit behaviors.

This is exactly why I prefer Django Ninja.

Django Ninja's pursuit of explicitness makes me feel more relaxed most of the time when developing and maintaining APIs.