Skip to main content

Pydantic serialization for tortoise-orm

Project description

Tortoise Serializer

Codacy Badge Codacy Badge

Motivation

This project was created to address some of the limitations of pydantic_model_creator, including:

  • The ability to use a context in serialization at the field level.
  • Access to the actual Tortoise Model instance 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 use ModelSerializer instead.

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:

  1. Resolvers (computed fields)
  2. ForeignKeys
  3. 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_instance inside a transaction context 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=True and select_only=True are 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_create will not have their database-generated fields (e.g. id) populated after creation. Re-query the database if you need them.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tortoise_serializer-1.8.0.tar.gz (99.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tortoise_serializer-1.8.0-py3-none-any.whl (20.6 kB view details)

Uploaded Python 3

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

Hashes for tortoise_serializer-1.8.0.tar.gz
Algorithm Hash digest
SHA256 0399f91bd639419ab0ec3beb9e6a3121774ed7cddece010fd2a82b1c69b6d144
MD5 8379d21cf6340694c3a88172df6ae2f5
BLAKE2b-256 32171ae61543e5d4366c9e19c9b2e852aa30985acd92b1b7ee4be177abb7432f

See more details on using hashes here.

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

Hashes for tortoise_serializer-1.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e980dd40e9736ee4c9abefddc50ef8a7f9e212047058ee35a1f18367fdf79041
MD5 99b43ddd7eff85d8c4a97a7ce373b750
BLAKE2b-256 e6ea2d135adf9f0c96dab6762a8057652af79704aa4886a5f8782752fea4aff2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page