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 make sure that it is supported.
Prepare yourself for a wild ride! This section will not only be about queryset, but also about the 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:
Simplicity
Database table independence from the serialization schema
Customizability: you can change anything at any time
Performance: no extra fields, no extra queries, fast serialization
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.
See also
Full-featured example with the proper layers can be found here:
Model#
Our regular Model definition:
1from typing import final
2
3from django.db import models
4
5
6@final
7class User(models.Model):
8 # This is sent to us from another service:
9 email = models.EmailField(unique=True)
10 customer_service_uid = models.UUIDField(unique=True)
11
12 # Our own fields:
13 is_active = models.BooleanField(db_index=True, default=True)
14 created_at = models.DateTimeField(auto_now_add=True)
15 updated_at = models.DateTimeField(auto_now=True)
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, such as msgspec.Struct, pydantic.BaseModel,
attrs.define(), typing.TypedDict, etc.
We will use pydantic for this example, because
it is the most familiar concept among them for most developers:
1import datetime as dt
2import uuid
3from typing import Annotated, TypeAlias, final
4
5import pydantic
6
7DatabaseId: TypeAlias = Annotated[int, pydantic.Field(gt=0)]
8
9
10class SimpleUserCreateSchema(pydantic.BaseModel):
11 email: str
12 customer_service_uid: uuid.UUID
13
14
15@final
16class SimpleUserSchema(SimpleUserCreateSchema):
17 id: DatabaseId
18 created_at: dt.datetime
Important
This step is really important! We have to separate database models and serializer schemas from each other. Why?
Because it gives us more control over the serialization process both ways
Because new database fields will not automatically appear in your API spec
Because removing a database field will not break the API contract
Because versioning the API becomes much easier
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.
However, we strongly recommend that you separate your business logic from views for better testing and better composition.
For this example, we will use the simplest services.py
layer for our business logic:
1from typing import final
2
3from django.db import IntegrityError
4from django.db.models import QuerySet
5
6from server.apps.model_simple.models import User
7from server.apps.model_simple.serializers import SimpleUserCreateSchema
8
9
10@final
11class UniqueConstraintError(Exception):
12 """Fields ``email`` and ``customer_service_uid`` must be unique."""
13
14
15def user_create_service(user_schema: SimpleUserCreateSchema) -> User:
16 """This is a function just for the demo's purpose, it is usually a class."""
17 try:
18 return User.objects.create(
19 email=user_schema.email,
20 customer_service_uid=user_schema.customer_service_uid,
21 )
22 except IntegrityError:
23 # We don't raise `IntegrityError` here, because we prefer domain
24 # exceptions over Django ones. It is much easier to manage.
25 raise UniqueConstraintError from None
26
27
28def user_list_service() -> QuerySet[User]:
29 """Return all users."""
30 # Only can exclude fields that we won't use by passing as a param
31 # `UserSchema.__struct_fields__` tuple and calling `.only(*fields)` here:
32 return User.objects.all()
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 defined 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_uidfrommodels.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`
1from http import HTTPStatus
2from typing import Final, final
3
4import pydantic
5from django.http import HttpResponse
6from typing_extensions import override
7
8from dmr import Body, Controller, modify
9from dmr.endpoint import Endpoint
10from dmr.errors import ErrorType
11from dmr.metadata import ResponseSpec
12from dmr.plugins.pydantic import PydanticSerializer
13from server.apps.model_simple.serializers import (
14 SimpleUserCreateSchema,
15 SimpleUserSchema,
16)
17from server.apps.model_simple.services import (
18 UniqueConstraintError,
19 user_create_service,
20 user_list_service,
21)
22
23_UserList: Final = pydantic.TypeAdapter(list[SimpleUserSchema])
24
25
26@final
27class UserController(Controller[PydanticSerializer]):
28 def get(self) -> list[SimpleUserSchema]:
29 """List existing users."""
30 return _UserList.validate_python(
31 user_list_service(),
32 from_attributes=True,
33 )
34
35 @modify(
36 extra_responses=[
37 ResponseSpec(
38 Controller.error_model,
39 status_code=HTTPStatus.CONFLICT,
40 ),
41 ],
42 )
43 def post(
44 self,
45 parsed_body: Body[SimpleUserCreateSchema],
46 ) -> SimpleUserSchema:
47 """Create new user."""
48 return SimpleUserSchema.model_validate(
49 user_create_service(parsed_body),
50 from_attributes=True,
51 )
52
53 @override
54 def handle_error(
55 self,
56 endpoint: Endpoint,
57 controller: Controller[PydanticSerializer],
58 exc: Exception,
59 ) -> HttpResponse:
60 # Handle custom errors that can happen in this controller:
61 if isinstance(exc, UniqueConstraintError):
62 return self.to_error(
63 self.format_error(
64 'User `email` and `customer_service_uid` must be unique',
65 error_type=ErrorType.value_error,
66 ),
67 status_code=HTTPStatus.CONFLICT,
68 )
69 # Handle default errors:
70 return super().handle_error(endpoint, controller, exc)
71
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-10-05T11:18:06.969336Z"}
$ 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-10-05T11:18:06.969336Z"}]
Convert models to schemas manually. It might seem like a lot of code, but actually, it is pretty simple to do (especially with the help of an LLM).
The main reason to use this approach is correctness. For example, removing the
customer_service_uidmodel field will now trigger an early type-checking error, unlike the “Minimalistic” version, which will only fail at runtime:server/apps/model_simple/views/detailed.py:28: error: "User" has no attribute "customer_service_uid" [attr-defined] server/apps/model_simple/views/detailed.py:48: error: "User" has no attribute "customer_service_uid" [attr-defined]
The main reason not to use this approach is its verbosity
1from http import HTTPStatus
2from typing import final
3
4from django.http import HttpResponse
5from typing_extensions import override
6
7from dmr import Body, Controller, modify
8from dmr.endpoint import Endpoint
9from dmr.errors import ErrorType
10from dmr.metadata import ResponseSpec
11from dmr.plugins.pydantic import PydanticSerializer
12from server.apps.model_simple.serializers import (
13 SimpleUserCreateSchema,
14 SimpleUserSchema,
15)
16from server.apps.model_simple.services import (
17 UniqueConstraintError,
18 user_create_service,
19 user_list_service,
20)
21
22
23@final
24class UserController(Controller[PydanticSerializer]):
25 def get(self) -> list[SimpleUserSchema]:
26 """List existing users."""
27 return [
28 SimpleUserSchema(
29 id=user.pk,
30 email=user.email,
31 customer_service_uid=user.customer_service_uid,
32 created_at=user.created_at,
33 )
34 for user in user_list_service()
35 ]
36
37 @modify(
38 extra_responses=[
39 ResponseSpec(
40 Controller.error_model,
41 status_code=HTTPStatus.CONFLICT,
42 ),
43 ],
44 )
45 def post(
46 self,
47 parsed_body: Body[SimpleUserCreateSchema],
48 ) -> SimpleUserSchema:
49 """Create new user."""
50 user = user_create_service(parsed_body)
51 return SimpleUserSchema(
52 id=user.pk,
53 email=user.email,
54 customer_service_uid=user.customer_service_uid,
55 created_at=user.created_at,
56 )
57
58 @override
59 def handle_error(
60 self,
61 endpoint: Endpoint,
62 controller: Controller[PydanticSerializer],
63 exc: Exception,
64 ) -> HttpResponse:
65 # Handle custom errors that can happen in this controller:
66 if isinstance(exc, UniqueConstraintError):
67 return self.to_error(
68 self.format_error(
69 'User `email` and `customer_service_uid` must be unique',
70 error_type=ErrorType.value_error,
71 ),
72 status_code=HTTPStatus.CONFLICT,
73 )
74 # Handle default errors:
75 return super().handle_error(endpoint, controller, exc)
76
Run result
$ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"email": "detailed@example.com", "customer_service_uid": "616d51f2-2c31-4b04-8034-2f59eae042db"}' -H 'Content-Type: application/json'
{"email":"detailed@example.com","customer_service_uid":"616d51f2-2c31-4b04-8034-2f59eae042db","id":2,"created_at":"2026-10-05T11:18:08.001687Z"}
$ 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-10-05T11:18:06.969336Z"},{"email":"detailed@example.com","customer_service_uid":"616d51f2-2c31-4b04-8034-2f59eae042db","id":2,"created_at":"2026-10-05T11:18:08.001687Z"}]
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:
ForeignKeyrelationshipManyToManyrelationshipDI for realistic expectations
Mappers and services separate layers
Models#
We start with model definitions.
1from typing import final
2
3from django.db import models
4
5
6@final
7class Tag(models.Model):
8 name = models.CharField(max_length=100, unique=True)
9
10 created_at = models.DateTimeField(auto_now_add=True)
11 updated_at = models.DateTimeField(auto_now=True)
12
13
14@final
15class Role(models.Model):
16 name = models.CharField(max_length=100, unique=True)
17
18 created_at = models.DateTimeField(auto_now_add=True)
19 updated_at = models.DateTimeField(auto_now=True)
20
21
22@final
23class User(models.Model):
24 # This is sent to us from another service:
25 email = models.EmailField(unique=True)
26
27 # Linking:
28 role = models.ForeignKey(
29 Role,
30 on_delete=models.CASCADE,
31 related_name='users',
32 )
33 tags = models.ManyToManyField(Tag, related_name='users')
34
35 # Our own fields:
36 is_active = models.BooleanField(db_index=True, default=True)
37 created_at = models.DateTimeField(auto_now_add=True)
38 updated_at = models.DateTimeField(auto_now=True)
We added two models:
Rolefor a foreign key relationTagfor a many-to-many relation
Serializer schemas#
Next, let’s see how serializer schemas are defined.
1import datetime as dt
2from typing import Annotated, TypeAlias, final
3
4import pydantic
5
6DatabaseId: TypeAlias = Annotated[int, pydantic.Field(gt=0)]
7
8
9@final
10class PageQuery(pydantic.BaseModel):
11 page_size: int = pydantic.Field(default=10, ge=1, le=100)
12 page: int = pydantic.Field(default=1, ge=1)
13
14 model_config = pydantic.ConfigDict(extra='forbid')
15
16
17@final
18class TagSchema(pydantic.BaseModel):
19 name: str = pydantic.Field(max_length=100)
20
21
22@final
23class RoleSchema(pydantic.BaseModel):
24 name: str = pydantic.Field(max_length=100)
25
26
27class UserCreateSchema(pydantic.BaseModel):
28 email: str
29 role: RoleSchema
30 tags: list[TagSchema]
31
32
33@final
34class UserSchema(UserCreateSchema):
35 id: DatabaseId
36 created_at: dt.datetime
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 beauty of this extremely simple approach:
it is customizable to the core.
Views#
In this example, it is easier to start with views.py:
1from http import HTTPStatus
2from typing import final
3
4from django.http import HttpResponse
5from typing_extensions import override
6
7from dmr import Body, Controller, Query, modify
8from dmr.endpoint import Endpoint
9from dmr.errors import ErrorType
10from dmr.metadata import ResponseSpec
11from dmr.pagination import Paginated
12from dmr.plugins.pydantic import PydanticSerializer
13from server.apps.model_fk.implemented import HasContainer
14from server.apps.model_fk.serializers import (
15 PageQuery,
16 UserCreateSchema,
17 UserSchema,
18)
19from server.apps.model_fk.services import (
20 UniqueConstraintError,
21 UserCreate,
22 UserList,
23)
24
25
26@final
27class UserController(HasContainer, Controller[PydanticSerializer]):
28 def get(self, parsed_query: Query[PageQuery]) -> Paginated[UserSchema]:
29 """List existing users."""
30 return self.resolve(UserList)(parsed_query)
31
32 @modify(
33 extra_responses=[
34 ResponseSpec(
35 Controller.error_model,
36 status_code=HTTPStatus.CONFLICT,
37 ),
38 ],
39 )
40 def post(self, parsed_body: Body[UserCreateSchema]) -> UserSchema:
41 """Create new user."""
42 return self.resolve(UserCreate)(parsed_body)
43
44 @override
45 def handle_error(
46 self,
47 endpoint: Endpoint,
48 controller: Controller[PydanticSerializer],
49 exc: Exception,
50 ) -> HttpResponse:
51 # Handle custom errors that can happen in this controller:
52 if isinstance(exc, UniqueConstraintError):
53 return self.to_error(
54 self.format_error(
55 'User `email` must be unique',
56 error_type=ErrorType.value_error,
57 ),
58 status_code=HTTPStatus.CONFLICT,
59 )
60 # Handle default errors:
61 return super().handle_error(endpoint, controller, exc)
62
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-10-05T11:18:09.051247Z"}
$ 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-10-05T11:18:09.051247Z"}]}}
What happens here?
We refactored our views to only run an instance of a specific service, which we get using
HasContainer.resolvecall, which is our DIWe now don’t construct any serializer schemas inside our views, we move to its independent infra layer
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:
1from typing import Any, TypeVar
2
3import punq
4
5from server.apps.model_fk.mappers import RoleMap, TagMap, UserMap
6from server.apps.model_fk.services import (
7 RoleCreate,
8 TagsCreate,
9 UserCreate,
10 UserList,
11)
12
13_ItemT = TypeVar('_ItemT')
14
15
16class HasContainer:
17 __slots__ = ('_container',)
18
19 def __init__(self, *args: Any, **kwargs: Any) -> None:
20 super().__init__(*args, **kwargs)
21 # Will be created in import-time:
22 self._container = self._create_container()
23
24 def resolve(self, thing: type[_ItemT]) -> _ItemT:
25 return self._container.resolve(thing)
26
27 def _create_container(self) -> punq.Container:
28 container = punq.Container()
29 container.register(TagMap)
30 container.register(RoleMap)
31 container.register(UserMap)
32
33 container.register(TagsCreate)
34 container.register(RoleCreate)
35 container.register(UserCreate)
36 container.register(UserList)
37
38 return container
What happens here?
We define a class that will be used as a mixin for all future controllers
This class provides a pre-built container with all the dependencies
Users can call
self.resolveinside 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 layer separation practice 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:
1from collections.abc import Iterable
2from typing import final
3
4import attrs
5from django.db import IntegrityError, transaction
6
7from dmr.pagination import Paginated
8from server.apps.model_fk.mappers import UserMap
9from server.apps.model_fk.models import Role, Tag, User
10from server.apps.model_fk.serializers import (
11 PageQuery,
12 RoleSchema,
13 TagSchema,
14 UserCreateSchema,
15 UserSchema,
16)
17
18
19@final
20class UniqueConstraintError(Exception):
21 """Field ``email`` must be unique."""
22
23
24@final
25@attrs.define(frozen=True)
26class TagsCreate:
27 def __call__(self, tags: list[TagSchema]) -> Iterable[Tag]:
28 """Service for creating new tags with unique names."""
29 tags_names = {tag.name for tag in tags}
30 # Create new tags and ignore existing ones in 1 query:
31 Tag.objects.bulk_create(
32 [Tag(name=tag) for tag in tags_names],
33 ignore_conflicts=True,
34 )
35 # Since `bulk_create` does not return `ids`, we need to query them:
36 return Tag.objects.in_bulk(
37 tags_names,
38 field_name='name',
39 ).values()
40
41
42@final
43@attrs.define(frozen=True)
44class RoleCreate:
45 def __call__(self, role_schema: RoleSchema) -> Role:
46 """Service for getting an existing role or creating a new one."""
47 role, _ = Role.objects.get_or_create(name=role_schema.name)
48 return role
49
50
51@final
52@attrs.define(frozen=True)
53class UserCreate:
54 _role_create: RoleCreate
55 _tags_create: TagsCreate
56 _mapper: UserMap
57
58 def __call__(self, user_schema: UserCreateSchema) -> UserSchema:
59 """Service to create new users."""
60 return self._mapper.single(self._create_user(user_schema))
61
62 def _create_user(self, user_schema: UserCreateSchema) -> User:
63 with transaction.atomic():
64 role = self._role_create(user_schema.role)
65
66 try:
67 user = User.objects.create(
68 email=user_schema.email,
69 role=role,
70 )
71 except IntegrityError:
72 # We don't raise `IntegrityError` here, because we prefer domain
73 # exceptions over Django ones. It is much easier to manage.
74 raise UniqueConstraintError from None
75
76 # Handle m2m:
77 user.tags.set(self._tags_create(user_schema.tags))
78 return user
79
80
81@final
82@attrs.define(frozen=True)
83class UserList:
84 _mapper: UserMap
85
86 def __call__(self, parsed_query: PageQuery) -> Paginated[UserSchema]:
87 """Return all users."""
88 return self._mapper.multiple(
89 (
90 User.objects
91 .select_related('role')
92 .prefetch_related('tags')
93 .order_by('id')
94 .all() # it is still lazy
95 ),
96 parsed_query,
97 )
What happens here?
We define three services: one per create operation
Some of them have dependencies defined as dataclass fields, like
_mapper: UserMap. These fields will be resolved by our DI.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 has some database fields removed, but you 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.
1from typing import final
2
3import attrs
4from django.core.paginator import EmptyPage, Paginator
5from django.db.models import QuerySet
6
7from dmr.pagination import Page, Paginated
8from server.apps.model_fk.models import Role, Tag, User
9from server.apps.model_fk.serializers import (
10 PageQuery,
11 RoleSchema,
12 TagSchema,
13 UserSchema,
14)
15
16
17@final
18@attrs.define(frozen=True)
19class TagMap:
20 def single(self, tag: Tag) -> TagSchema:
21 return TagSchema(name=tag.name)
22
23 def multiple(self, tags: QuerySet[Tag]) -> list[TagSchema]:
24 return [self.single(tag) for tag in tags]
25
26
27@final
28@attrs.define(frozen=True)
29class RoleMap:
30 def single(self, role: Role) -> RoleSchema:
31 return RoleSchema(name=role.name)
32
33
34@final
35@attrs.define(frozen=True)
36class UserMap:
37 _role: RoleMap
38 _tag: TagMap
39
40 def single(self, user: User) -> UserSchema:
41 return UserSchema(
42 id=user.pk,
43 email=user.email,
44 created_at=user.created_at,
45 role=self._role.single(user.role),
46 tags=self._tag.multiple(user.tags.all()),
47 )
48
49 def multiple(
50 self,
51 users: QuerySet[User],
52 parsed_query: PageQuery,
53 ) -> Paginated[UserSchema]:
54 # Logic to paginate users:
55 paginator = Paginator(users, parsed_query.page_size)
56 try:
57 object_list = [
58 self.single(user)
59 for user in paginator.page(parsed_query.page).object_list
60 ]
61 except EmptyPage:
62 object_list = []
63 return Paginated(
64 count=paginator.count,
65 num_pages=paginator.num_pages,
66 per_page=paginator.per_page,
67 page=Page(
68 number=parsed_query.page,
69 object_list=object_list,
70 ),
71 )
Now we have a fully working application. With composable and flexible schemas, which will:
Report any type errors early
Be customizable to the core
Can be used in a good architecture in big real business apps
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()anddefer()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.
1import attrs
2
3from dmr import Controller
4from dmr.plugins.msgspec import MsgspecSerializer
5
6
7@attrs.define
8class _UserModel:
9 username: str
10 email: str
11 is_active: bool
12
13
14class UsersController(Controller[MsgspecSerializer]):
15 def get(self) -> list[_UserModel]:
16 # We have to do import here due to how our docs build systems works,
17 # but in real apps they must be on the module level:
18 from django.contrib.auth.models import User # noqa: PLC0415
19 from mantle import Query # noqa: PLC0415
20
21 return Query(User.objects.all(), _UserModel).all()
22
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"
}
}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/userscontroller/": {
"get": {
"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"
}
}
}
}
}
}
django-modern-schemas#
If you want to automate the schema definition part and derive your serializer schemas from the models you already have, you can use django-modern-schemas.
Generates
pydantic.BaseModelsubclasses from yourModeldefinitions, reusing field types, constraints, defaults, choices and relationships, so things likemax_lengthare declared once.Keeps the API contract explicit:
fields,excludeandoptionaldefine what a schema exposes, so read, create andPATCHschemas are built from the same model without repeating it.Maps model methods and dotted ORM paths into schema fields with
MethodSourceandSource, including reverse relations, nested schemas and aggregates.ModelSchemais generic in its model, so.create(),.update()and.save()are typed as returning that exact model.Needs nothing in
INSTALLED_APPS: schemas are regular Python classes.
From the controller’s point of view they are just pydantic schemas:
QuerySet
and Model instances are valid inputs,
and .save() writes a parsed body back to the database.
The very same schema serves a collection and a single object.
1from typing import Annotated, Final, final
2
3import pydantic
4from django.contrib.auth.models import User
5from django.shortcuts import get_object_or_404
6from django_modern_schemas import MethodSource, ModelSchema
7from typing_extensions import TypedDict
8
9from dmr import Body, Controller, Path
10from dmr.plugins.pydantic import PydanticFastSerializer
11from dmr.routing import Router, path
12
13
14class UserSchema(ModelSchema[User]):
15 # Extra fields can be computed by the model itself:
16 full_name: Annotated[str, MethodSource('get_full_name')]
17
18 class Config:
19 model = User
20 fields = ('id', 'username', 'email')
21
22
23class UserCreateSchema(ModelSchema[User]):
24 class Config:
25 model = User
26 fields = ('username', 'email', 'first_name', 'last_name')
27
28
29class _UserPath(TypedDict):
30 user_id: int
31
32
33_UserList: Final = pydantic.TypeAdapter(list[UserSchema])
34
35
36@final
37class UsersController(Controller[PydanticFastSerializer]):
38 def get(self) -> list[UserSchema]:
39 return _UserList.validate_python(User.objects.all())
40
41 def post(self, parsed_body: Body[UserCreateSchema]) -> UserSchema:
42 # Schemas know how to persist themselves:
43 return UserSchema.model_validate(parsed_body.save())
44
45
46@final
47class UserDetailController(Controller[PydanticFastSerializer]):
48 def get(self, parsed_path: Path[_UserPath]) -> UserSchema:
49 return UserSchema.model_validate(
50 get_object_or_404(User, pk=parsed_path['user_id']),
51 )
52
53
54router = Router(
55 'api/',
56 [
57 path('users/', UsersController.as_view(), name='users'),
58 path(
59 'users/<int:user_id>/',
60 UserDetailController.as_view(),
61 name='user',
62 ),
63 ],
64)
65
66urlpatterns = [router.to_urlpatterns(namespace='api')]
Run result
$ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"username": "ada", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace"}' -H 'Content-Type: application/json'
{"id":2,"username":"ada","email":"ada@example.com","full_name":"Ada Lovelace"}
$ curl http://127.0.0.1:8000/api/users/ -X GET
[{"id":1,"username":"test_user","email":"test@example.com","full_name":""},{"id":2,"username":"ada","email":"ada@example.com","full_name":"Ada Lovelace"}]
$ curl http://127.0.0.1:8000/api/users/1/ -X GET
{"id":1,"username":"test_user","email":"test@example.com","full_name":""}
Note
Schemas generated this way follow your database tables by default.
Keep fields and exclude explicit
to decide what your API really exposes,
as described in the sections above.
See also
A complete application built with django-modern-rest and
django-modern-schemas, with nested schemas, reverse relations
and partial updates, can be found here: