Pydantic serialization for tortoise-orm
Project description
Tortoise Serializer
Motivation
This project was created to address some of the limitations of pydantic_model_creator, including:
- The ability to use a
contextin serialization at the field level. - Access to the actual Tortoise
Modelinstance during serialization. - Improved readability.
- Support for adding extra logic to specific serializers.
- The ability to document fields in a way that is visible in Swagger.
Useful readings
Installation
pip install tortoise-serializer
Core concept
A Serializer does not need to know which model it will serialize. For example:
from tortoise_serializer import Serializer
class ItemByNameSerializer(Serializer):
id: int
name: str
products = await ItemByNameSerializer.from_queryset(Product.all())
users = await ItemByNameSerializer.from_queryset(User.all())
This is entirely valid.
Serializers are pydantic.BaseModel objects, which means you can directly return them from FastAPI endpoints or use any functionality provided by BaseModel.
Usage
Reading
from tortoise_serializer import Serializer
from tortoise import Model, fields
from pydantic import Field
from fastapi.routing import APIRouter
class MyUser(Model):
id = fields.IntegerField(primary_key=True)
name = fields.CharField(max_length=100, unique=True)
class MyUserSerializer(Serializer):
id: int
name: str = Field(max_length=100, description="User unique name")
router = APIRouter(prefix="/users")
@router.get("")
async def get_users() -> list[MyUserSerializer]:
return await MyUserSerializer.from_queryset(MyUser.all(), context={"user": ...})
(Note: You can specify a context to pass additional information to serializers, but it is not mandatory.)
Writing
from fastapi import Body
from pydantic import Field
class MyUserCreationSerializer(Serializer):
name: str = Field(max_length=200)
@router.post("")
async def create_user(user_serializer: MyUserCreationSerializer = Body(...)) -> MyUserSerializer:
user = await user_serializer.create_tortoise_instance(MyUser)
# Here you can also pass a `context=` to this function.
return await MyUserSerializer.from_tortoise_orm(user)
Note: It is currently not possible to handle ForeignKeys directly using the base
Serializer. You need to manage such logic in your views, or useModelSerializerinstead.
Partial updates
Use partial_update_tortoise_instance to apply only the fields that were explicitly set in the serializer (useful for PATCH endpoints). It returns True if any field was changed, False otherwise:
from pydantic import Field
from tortoise_serializer import Serializer
class BookUpdateSerializer(Serializer):
title: str | None = None
price: float | None = None
@router.patch("/{book_id}")
async def update_book(book_id: int, update: BookUpdateSerializer) -> BookSerializer:
book = await Book.get_or_none(id=book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such book")
changed = update.partial_update_tortoise_instance(book)
if changed:
await book.save()
return await BookSerializer.from_tortoise_orm(book)
Use has_been_set to check whether a specific field was included in the payload, even when its value is None or an empty string:
serializer = BookUpdateSerializer(title=None)
serializer.has_been_set("title") # True — field was explicitly sent
serializer.has_been_set("price") # False — field was omitted entirely
Context
The context passed to serializers is immutable (stored as a frozendict). It is forwarded automatically to nested serializers and all resolvers.
Resolvers
Sometimes, you need to compute values or restrict access to sensitive data. This can be achieved with resolvers and context.
Method-based resolvers
Define a classmethod named resolve_<field_name>. It can be sync or async:
from tortoise_serializer import ContextType, Serializer
from tortoise import Model, fields
class BookModel(Model):
id = fields.IntField(primary_key=True)
title = fields.CharField(db_index=True)
shelf = fields.ForeignKeyField(
"models.BookShelf",
on_delete=fields.SET_NULL,
null=True,
related_name="books",
)
class BookSerializer(Serializer):
id: int
title: str
path: str
answer_to_the_question: int
@classmethod
async def resolve_path(cls, instance: BookModel, context: ContextType) -> str:
if not instance.shelf:
return instance.title
await instance.fetch_related("shelf")
return f'{instance.shelf.name}/{instance.title}'
@classmethod
def resolve_answer_to_the_question(cls, instance: BookModel, context: ContextType) -> int:
return 42
serializer = await BookSerializer.from_tortoise_orm(my_book)
assert serializer.path == "main/Serializers 101"
assert serializer.answer_to_the_question == 42
All async resolvers are executed concurrently via asyncio.TaskGroup. Sync resolvers run sequentially.
Decorator-based resolvers
Use the @resolver decorator to bind a method to a field when the method name doesn't follow the resolve_<field> convention. The method must still be a @classmethod:
from tortoise_serializer import resolver, Serializer, ContextType
class UserSerializer(Serializer):
full_name: str
@resolver("full_name")
@classmethod
def compute_full_name(cls, instance, context: ContextType) -> str:
return f"{instance.first_name} {instance.last_name}"
Async resolvers work the same way:
class BookSerializer(Serializer):
id: int
title: str
shelf_name: str | None = None
@resolver("shelf_name")
@classmethod
async def _fetch_shelf_name(cls, instance: Book, context: ContextType) -> str | None:
if not instance.shelf_id:
return None
await instance.fetch_related("shelf")
return instance.shelf.name
@resolver and @require_condition_or_unset can be combined. Put @resolver outermost, then @require_condition_or_unset, then @classmethod:
from tortoise_serializer import resolver, require_condition_or_unset, Serializer, ContextType
def is_admin(instance, context: ContextType) -> bool:
return context.get("user_role") == "admin"
class BookSerializer(Serializer):
id: int
title: str
# capitalised and stripped title
display_title: str
# only visible to admins; silently omitted otherwise
internal_margin: float | None = None
@resolver("display_title")
@classmethod
def _clean_title(cls, instance: Book, context: ContextType) -> str:
return instance.title.title().strip()
@resolver("internal_margin")
@require_condition_or_unset(is_admin)
@classmethod
async def _margin(cls, instance: Book, context: ContextType) -> float:
return instance.cost * 1.3
When is_admin returns False, internal_margin is silently omitted. Use response_model_exclude_unset=True in FastAPI endpoints to keep the JSON clean.
Conditional resolvers
Use require_condition_or_unset to conditionally expose a field. When the condition returns False, the field is omitted (set to Unset) instead of raising a validation error:
from tortoise_serializer import ContextType, Serializer, require_condition_or_unset
from tortoise import Model, fields
class UserModel(Model):
id = fields.IntegerField(primary_key=True)
address = fields.CharField(max_length=1000)
def is_self(instance: UserModel, context: ContextType) -> bool:
current_user = context.get("user")
if not current_user:
return False
return current_user.id == instance.id
class UserSerializer(Serializer):
id: int
# Default is set to None, but the field will be omitted when the condition is False.
address: str | None = None
@classmethod
@require_condition_or_unset(is_self)
async def resolve_address(cls, instance: UserModel, context: ContextType) -> str:
return instance.address
@app.get("/users", response_model_exclude_unset=True)
async def list_users(user: UserModel = Depends(...)) -> list[UserSerializer]:
return await UserSerializer.from_queryset(UserModel.all(), context={"user": user})
This ensures that the address field is not exposed to unauthorized users.
The condition checker can itself be async when used with an async resolver.
Context propagation into nested serializers
The context is forwarded automatically to all nested serializers and their resolvers. This makes it easy to pass request-scoped data (current user, locale, permissions) down the entire serialization tree without manually threading it:
from tortoise_serializer import Serializer, ContextType
class BookSerializer(Serializer):
id: int
title: str
already_borrowed: bool
@classmethod
async def resolve_already_borrowed(
cls, instance: Book, context: ContextType
) -> bool:
person = context.get("current_person")
if not person:
return False
return await person.borrows.filter(id=instance.id).exists()
class PersonSerializer(Serializer):
id: int
name: str
borrows: list[BookSerializer] # context is forwarded here automatically
serializer = await PersonSerializer.from_tortoise_orm(
person, context={"current_person": person}
)
# every BookSerializer in .borrows receives the same context
Relations
ForeignKeys & OneToOne
To serialize relations, declare a field in the serializer as another serializer:
from tortoise import Model, fields
from tortoise_serializer import Serializer
class BookShelf(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(unique=True)
class Book(Model):
id = fields.IntField(primary_key=True)
title = fields.CharField(db_index=True)
shelf = fields.ForeignKeyField(
"models.BookShelf",
on_delete=fields.SET_NULL,
null=True,
related_name="books",
)
class BookSerializer(Serializer):
id: int
title: str
class ShelfSerializer(Serializer):
id: int
name: str
books: list[BookSerializer] = []
# Prefetching related fields is optional but improves performance.
serializers = await ShelfSerializer.from_queryset(
BookShelf.all().prefetch_related("books").order_by("name")
)
For a forward ForeignKey relationship (book → shelf):
class ShelfSerializer(Serializer):
id: int
name: str
class BookSerializer(Serializer):
id: int
title: str
shelf: ShelfSerializer | None
Reverse relations are typed as list[NestedSerializer].
Limitation: A field cannot mix two different serializer types:
# This is NOT supported:
class MyWrongSerializer(Serializer):
my_field: SerializerA | SerializerB
But None is allowed:
class MySerializer(Serializer):
some_relation: SerializerA | None = None
Many2Many
Declare the M2M field as list[NestedSerializer] — the same as a reverse FK:
from tortoise import Model, fields
from tortoise_serializer import Serializer
class Book(Model):
id = fields.IntField(primary_key=True)
title = fields.CharField(max_length=200)
class Person(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=200)
borrows = fields.ManyToManyField("models.Book", related_name="borrowers")
class BookSerializer(Serializer):
id: int
title: str
class PersonSerializer(Serializer):
id: int
name: str
borrows: list[BookSerializer]
alice = await Person.create(name="Alice")
await alice.borrows.add(*await Book.filter(title__in=["LOTR", "Dune"]))
serializer = await PersonSerializer.from_tortoise_orm(alice)
# serializer.borrows → [BookSerializer(id=..., title="LOTR"), BookSerializer(id=..., title="Dune")]
Two patterns are available for more complex cases:
- Use an intermediate model with two ForeignKeys (for extra fields on the join).
- Use a
resolve_<field>method to apply custom filtering or ordering.
Computed fields
Fields are resolved in the following priority order:
- Resolvers (computed fields)
- ForeignKeys
- Model fields
This means a resolver can shadow or replace a model field of the same name.
Prefetching related fields
Use get_prefetch_fields() to generate the list of relations to pass to prefetch_related:
queryset = Book.all().prefetch_related(*BookSerializer.get_prefetch_fields())
serializers = await BookSerializer.from_queryset(queryset)
Model Serializers
ModelSerializer extends Serializer with the ability to create model instances and their nested relations in a single call. It is generic over the Tortoise model it targets.
class MySerializer(ModelSerializer[MyModel]):
...
It supports creating:
- Foreign keys
- Backward foreign keys
- Many2Many relations
- One-to-one relationships
Basic Usage
from tortoise import Model, fields
from tortoise.fields.relational import BackwardFKRelation
from tortoise_serializer import ModelSerializer
class BookShelf(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(unique=True, max_length=200)
books: BackwardFKRelation["Book"]
class Book(Model):
id = fields.IntField(primary_key=True)
title = fields.CharField(db_index=True, max_length=200)
shelf = fields.ForeignKeyField(
"models.BookShelf",
on_delete=fields.SET_NULL,
null=True,
related_name="books",
)
class ShelfCreationSerializer(ModelSerializer[BookShelf]):
name: str
class BookCreationSerializer(ModelSerializer[Book]):
title: str
shelf: ShelfCreationSerializer
serializer = BookCreationSerializer(title="Some Title", shelf={"name": "where examples lie"})
book = await serializer.create_tortoise_instance()
assert await Book.filter(title="Some Title", shelf__name="where examples lie").exists()
It is strongly recommended to call
create_tortoise_instanceinside atransactioncontext to ensure atomicity.
FastAPI
Since Serializers inherit from pydantic.BaseModel, they work with FastAPI out of the box.
from fastapi import status, Body, HTTPException
from fastapi.routing import APIRouter
from pydantic import Field
from tortoise import Model, fields
from tortoise.transaction import in_transaction
from tortoise_serializer import ModelSerializer
class Author(Model):
id = fields.IntegerField(primary_key=True)
name = fields.CharField(max_length=200, unique=True)
class Book(Model):
id = fields.IntegerField(primary_key=True)
title = fields.CharField(max_length=200)
pages_count = fields.IntegerField()
author = fields.ForeignKeyField("models.Author", related_name="books")
class AuthorCreationSerializer(ModelSerializer[Author]):
name: str
class BookCreationSerializer(ModelSerializer[Book]):
title: str = Field(max_length=200)
author: AuthorCreationSerializer
async def _get_or_create_author(self) -> Author:
author = await Author.filter(name=self.author.name).get_or_none()
if not author:
author = await self.author.create_tortoise_instance()
return author
async def create_tortoise_instance(self, *args, **kwargs) -> Book:
kwargs["author"] = await self._get_or_create_author()
return await super().create_tortoise_instance(*args, **kwargs)
class AuthorSerializer(ModelSerializer[Author]):
id: int
name: str
class BookSerializer(ModelSerializer[Book]):
id: int
title: str
author: AuthorSerializer
router = APIRouter(prefix="/books")
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_book(serializer: BookCreationSerializer = Body(...)) -> BookSerializer:
async with in_transaction():
book = await serializer.create_tortoise_instance()
return await BookSerializer.from_tortoise_orm(book)
@router.get("")
async def list_books() -> list[BookSerializer]:
return await BookSerializer.from_queryset(Book.all(), prefetch=True)
@router.get("/{book_id}")
async def get_book(book_id: int) -> BookSerializer:
book = await (
Book.filter(id=book_id)
.prefetch_related(*BookSerializer.get_prefetch_fields())
.get_or_none()
)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such book")
return await BookSerializer.from_tortoise_orm(book)
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(book_id: int) -> None:
await Book.filter(id=book_id).delete()
@router.patch("/{book_id}")
async def update_book(book_id: int, update: BookCreationSerializer) -> BookSerializer:
book = await Book.get_or_none(id=book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such book")
book.author = await update._get_or_create_author()
update.partial_update_tortoise_instance(book)
await book.save()
return await BookSerializer.from_tortoise_orm(book)
Optimizing Database Queries
Prefetching
ModelSerializer.from_queryset accepts a prefetch=True parameter to automatically prefetch all relations declared in the serializer:
books = await BookSerializer.from_queryset(Book.all(), prefetch=True)
This is equivalent to manually calling .prefetch_related(*BookSerializer.get_prefetch_fields()).
Field selection
Starting from tortoise-orm 0.25.0, you can limit fetched columns to only those needed by the serializer using select_only=True:
books = await BookSerializer.from_queryset(Book.all(), select_only=True)
Or manually via get_only_fetch_fields():
books = await BookSerializer.from_queryset(
Book.all().only(*BookSerializer.get_only_fetch_fields())
)
prefetch=Trueandselect_only=Trueare mutually exclusive.
Single instance helpers
ModelSerializer provides two convenience methods for fetching a single instance:
# Raises DoesNotExist if not found
book = await BookSerializer.from_single_queryset(Book.filter(id=book_id).get())
# Returns None if not found
book = await BookSerializer.from_single_queryset_or_none(Book.filter(id=book_id).get_or_none())
Both automatically prefetch related fields by default (prefetch=True).
Mixins
BackwardFKBulkCreateMixin
When creating a parent record with many backward FK children, the default implementation creates them one by one (required by Tortoise ORM to obtain generated PKs). If you don't need the child PKs after creation, BackwardFKBulkCreateMixin uses bulk_create for better performance:
from tortoise_serializer import ModelSerializer
from tortoise_serializer.mixins import BackwardFKBulkCreateMixin
class ShelfCreationSerializer(BackwardFKBulkCreateMixin, ModelSerializer[BookShelf]):
name: str
books: list[BookCreationSerializer] = []
Warning: Instances created via
bulk_createwill not have their database-generated fields (e.g.id) populated after creation. Re-query the database if you need them.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tortoise_serializer-1.8.0.tar.gz.
File metadata
- Download URL: tortoise_serializer-1.8.0.tar.gz
- Upload date:
- Size: 99.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0399f91bd639419ab0ec3beb9e6a3121774ed7cddece010fd2a82b1c69b6d144
|
|
| MD5 |
8379d21cf6340694c3a88172df6ae2f5
|
|
| BLAKE2b-256 |
32171ae61543e5d4366c9e19c9b2e852aa30985acd92b1b7ee4be177abb7432f
|
File details
Details for the file tortoise_serializer-1.8.0-py3-none-any.whl.
File metadata
- Download URL: tortoise_serializer-1.8.0-py3-none-any.whl
- Upload date:
- Size: 20.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e980dd40e9736ee4c9abefddc50ef8a7f9e212047058ee35a1f18367fdf79041
|
|
| MD5 |
99b43ddd7eff85d8c4a97a7ce373b750
|
|
| BLAKE2b-256 |
e6ea2d135adf9f0c96dab6762a8057652af79704aa4886a5f8782752fea4aff2
|