Serializing models and Querysets

Quote of the day

There are things that are easy.
There are things that seem easy.
Usually they are different things.

Nikita Sobolev, CPython core developer

Django is built around its QuerySet type. Of course, we have to make sure that it is supported.

Prepare yourself for a wild ride! This section will not only be about queryset, but also about architecture of Django applications.

Important

We offer a brand new way of working with QuerySet.

It is the best thing that ever happened to Django’s serialization.

We built our serialization patterns on:

  1. Simplicity

  2. Database table independence from the serialization schema

  3. Customizability: you can change anything at anytime

  4. Performance: no extra fields, no extra queries, fast serialization

  5. Everything must be typed at all times

Oversimplied example

Let’s start with the very simple definition of a regular model, with no foreign keys or many-to-many fields.

Our approach is to always move all the business logic away from the view. Even in examples, because they form the habit of developers and LLMs who are reading it.

Model

Our regular Model definition:

For this example, the model won’t have any FK or M2M fields. In the next examples we will show how to work with them as well.

Serializer schemas

Next, let’s define serializer schemas to get the incoming data and return the response.

You can use any schema type, including pydantic.BaseModel, attrs.define(), typing.TypedDict, etc. For this example we will use pydantic, because it is the most familiar tools for the most programmers:

Important

This step is really important! Because we have to separate database models and serializer schemas from each other. Why?

  1. Because it gives us more control over the serialization process both ways

  2. Because new database fields will not automatically appear in your API spec

  3. Because removing a database field will not break the API contract

  4. Because versioning the API becomes much easier

  5. Because explicit typing will let you catch more errors earlier

Services

Now, let’s define our business logic.

Note

We don’t give any architectural advice here, you can use any approach that you like for your projects: DDD, Clean or Hexagonal Architecture, Functional Core and Imperative Shell, whatever you like.

But, you business logic must be separated from views for better testing and better composition.

For this example, we will use the simplest services.py layer for our business logic:

There’s nothing fancy about it. Just creating a model from the typed input data.

Views

Now, we can define views that will use everything from the above.

Just convert models from attributes, using the builtin .model_validate() converter. For msgspec one can use msgspec.convert().

  • The main reason to use this approach is that it is short and easy

  • The main reason not to use this approach is that errors will only show up during tests. Not during type-checking. For example, removing customer_service_uid from models.py (for some business reason) will not trigger any type checking errors. If this line is not covered with tests, your API will not work:

    Traceback (most recent call last):
      File "server/apps/model_simple/views/minimalistic.py", line 42
        return UserSchema.model_validate(
        ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
    pydantic.ValidationError: Object missing required field `customer_service_uid`
    

Run result

$ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"email": "minimalistic@example.com", "customer_service_uid": "e87035e1-27a6-4e6b-a61a-d395bd4e221a"}' -H 'Content-Type: application/json'
{"email":"minimalistic@example.com","customer_service_uid":"e87035e1-27a6-4e6b-a61a-d395bd4e221a","id":1,"created_at":"2026-04-26T21:11:56.030661Z"}

$ curl http://127.0.0.1:8000/api/users/ -X GET
[{"email":"minimalistic@example.com","customer_service_uid":"e87035e1-27a6-4e6b-a61a-d395bd4e221a","id":1,"created_at":"2026-04-26T21:11:56.030661Z"}]

Now you have your REST API that returns fully typed model responses and can work with QuerySet and Model instances.

Realistic example

Now, let’s see how a more realistic layout may look like. It will include:

  • ForeignKey relationship

  • ManyToMany relationship

  • DI for realistic expectations

  • Mappers and services separate layers

Models

We start with models definitions.

We added two models:

  • Role for foreign key relation

  • Tag for many-to-many relation

Serializer schemas

Next, let’s see how serializer schemas are defined.

Note that we model foreign key and many-to-many relations here as nested schemas or list of nested schemas. However, you can also model the same thing as role_id: int and tags: list[int] to support ids for linking. That’s the beautify of this extremely simple approach: it is customizable to the core.

Views

In this example, it would be easier to start with views.py:

Run result

$ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"email": "test@example.com", "role": {"name": "admin"}, "tags": [{"name": "paid"}]}' -H 'Content-Type: application/json'
{"email":"test@example.com","role":{"name":"admin"},"tags":[{"name":"paid"}],"id":1,"created_at":"2026-04-26T21:11:58.048587Z"}

$ curl http://127.0.0.1:8000/api/users/ -X GET
{"count":1,"num_pages":1,"per_page":10,"page":{"number":1,"object_list":[{"email":"test@example.com","role":{"name":"admin"},"tags":[{"name":"paid"}],"id":1,"created_at":"2026-04-26T21:11:58.048587Z"}]}}

What happens here?

  1. We refactored our views to only run an instance of a specific service, which we get using HasContainer.resolve call, which is our DI

  2. We now don’t construct any serializer schemas inside our views, we move to its independent infra layer

  3. We now use Pagination to list all users

Our views here are reduced to a single line of code, which do everything inside the business logic. Exactly the way it should be for scalable and reliable applications.

DI

In this example we use punq as a simplistic DI container to show how big projects really handle such cases.

Note

You are not forced to use punq, we don’t enforce any DI framework. Our principle is to Bring your own DI.

The DI part looks like this:

What happens here?

  1. We define a class that will be used as a mixin for all future controllers

  2. This class provides a pre-built container with all the dependencies

  3. Users can call self.resolve inside controllers to resolve specific dependencies

Now, let’s see how we create objects in the database.

Services

Again, this is just an example. You are not forced to create this specific service-based architecture. Use whatever layers separation practice as you want. Our big example uses usecases as the main logic entities and entry points.

However, our services.py is the simplest and the shortest way. That’s why we are using them as an example:

What happens here?

  1. We define three services: one per create operation

  2. Some of them have dependencies defined as dataclass fields, like _mapper: UserMap, these fields will be resolved by our DI

  3. Each service does just a single thing, it would be easy to compose them

Now we are ready for the final layer: mapping of the created database models.

Mappers

Mappers just map database models into serialization schemas.

Tip

In real projects, it is better to have a separate layer that does mapping of database models into serialization schemas

It will allow you to compose mappers freely. For example, it allows you to create compatibility layers, when some model have some database fields removed, but still need a way to send users the same API schema.

Or it can do some small representation logic, like combining first_name and last_name of users into full_name.

Or handle Pagination, as we do in this example.

Now we have a fully working application. With composable and flexible schemas, which will:

  1. Report any type errors early

  2. Be customizable to the core

  3. Can be used in a good architecture in big real business apps

  4. Change independently from models

Conclusions

Here’s the final table to help you decide what to use:

Your App

What to Use

Todo App

Minimalistic Example

Real Application

Mappers

django-mantle

If you want to automate the mapping part and automagically convert QuerySet into typed models, you can use django-mantle.

  • Allows you to move your business logic into type-safe Python classes, decoupled from the Django ORM.

  • Provides automatic generation (with declarative overrides) of efficient ORM queries, including limited field fetches with only() and defer() and prefetching related objects, avoiding N+1 query problems.

  • Uses a modern and performant approach to serialisation and validation.

  • Provides a progressive API, with a minimal surface area by default, and depth when needed.

It’s your type-safe layer around Django’s liquid core.

Run result

$ curl http://127.0.0.1:8000/api/users/ -X GET
[{"email":"test@example.com","is_active":true,"username":"test_user"}]

OpenAPI Schema

Preview openapi.json
{
  "components": {
    "schemas": {
      "ErrorDetail": {
        "description": "Base schema for error details description.",
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "integer"
                },
                {
                  "type": "string"
                }
              ]
            },
            "type": "array"
          },
          "msg": {
            "type": "string"
          },
          "type": {
            "type": "string"
          }
        },
        "required": [
          "msg"
        ],
        "title": "ErrorDetail",
        "type": "object"
      },
      "ErrorModel": {
        "description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
        "properties": {
          "detail": {
            "items": {
              "$ref": "#/components/schemas/ErrorDetail"
            },
            "type": "array"
          }
        },
        "required": [
          "detail"
        ],
        "title": "ErrorModel",
        "type": "object"
      },
      "_UserModel": {
        "properties": {
          "email": {
            "type": "string"
          },
          "is_active": {
            "type": "boolean"
          },
          "username": {
            "type": "string"
          }
        },
        "required": [
          "username",
          "email",
          "is_active"
        ],
        "title": "_UserModel",
        "type": "object"
      }
    },
    "securitySchemes": {}
  },
  "info": {
    "title": "Django Modern Rest",
    "version": "0.1.0"
  },
  "openapi": "3.2.0",
  "paths": {
    "/api/userscontroller/": {
      "get": {
        "deprecated": false,
        "operationId": "getUserscontrollerApiUserscontroller",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/_UserModel"
                  },
                  "type": "array"
                }
              }
            },
            "description": "OK"
          },
          "406": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorModel"
                }
              }
            },
            "description": "Raised when provided `Accept` header cannot be satisfied"
          },
          "422": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorModel"
                }
              }
            },
            "description": "Raised when returned response does not match the response schema"
          }
        }
      }
    }
  }
}