Fastapi 0.100.0-beta1: even faster

Beta version released last week FastAPI 0.100-beta1, which means what? That’s right, it’s time for performance tests!

Changes

The main change in the new version of FastAPI is the transition to the new version of the Pydantic v2.0b3 library – all validation logic has been rewritten in Rust. Pydantic is promised a 5-50x performance boost! Well, let’s see how this will affect the speed of FastAPI in general. Other changes in version 0.100-beta1 release notes not indicated.

For Pydantic

Test bench preparation

We are web developers Notwe love CRUD, so let’s test it on it. To at least try to get closer to a real application, for each client request, it will work with the SQLAlchemy model, accessing the database.

All core code is available at githubHere are the main points:

The model will have three date-time fields, two text fields, one field booland of course id:

Model code
# commons/models.py
from datetime import datetime

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import Text
from sqlalchemy.sql import func


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
    published_at: Mapped[datetime] = mapped_column(nullable=False)
    title: Mapped[str] = mapped_column(Text)
    content: Mapped[str] = mapped_column(Text)
    is_deleted: Mapped[bool] = mapped_column(nullable=False, default=False)

What? Haven’t seen the new-stylish-young style of SQLAlchemy called mapping_styles ? Then rather to documentation. In general, changes were made again so that our favorite IDEs would not swear when we try to write some data not Column, but for example int, str and so on, into an attribute of an object of type Column.

The Pydantic v1 schema is the standard Pydantic model:

laugh code
# commons/schemas.py
from datetime import datetime
from enum import StrEnum

from pydantic import BaseModel, validator


class PostOut(BaseModel):
    id: int
    published_at: datetime
    updated_at: datetime
    title: str
    content: str
    is_published: bool | None = None

    @validator("is_published", always=True)
    def compute_is_published(cls, v, values, **kwargs):
        return datetime.utcnow() >= values["published_at"]

    class Config:
        orm_mode = True


class PostsOut(BaseModel):
    posts: list[PostOut]


class PostIn(BaseModel):
    title: str
    content: str
    published_at: datetime


class Order(StrEnum):
    ASC = "asc"
    DESC = "desc"

Of the interesting here – only field calculations is_published “on the fly”, that is – when returning to the client.

For tests, we will make three endpoints, one of them is for writing posts to the database, the other is for reading posts from the database, the third is a purely synthetic speed test:

Routing code
# api/posts/router.py
from fastapi import APIRouter, Depends, Response, status, HTTPException
from sqlalchemy.orm import Session

from commons.database import get_db
from commons import schemas
from crud import posts


router = APIRouter(tags=["posts"])


@router.get("/posts")
def get_posts(
    per_page: int = 10,
    page: int = 0,
    order: schemas.Order = schemas.Order.DESC,
    session: Session = Depends(get_db),
) -> schemas.PostsOut:
    return schemas.PostsOut(posts=posts.get(per_page, per_page*page, order, session))


@router.get("/posts_synthetic")
def posts_synthetic(
    per_page: int = 10,
) -> schemas.PostsOut:
    return schemas.PostsOut(
        posts=[
            schemas.PostOut(
                id=i,
                published_at=datetime(2023, 6, 30, 12, 0, 0),
                updated_at=datetime(2023, 6, 30, 12, 0, 0),
                title="Статья",
                content="Съешь ещё этих мягких французских булок, да выпей же чаю.",
            )
            for i in range(per_page)
        ]
    )


@router.post("/posts")
def create_post(
    post_in: schemas.PostIn, session: Session = Depends(get_db)
) -> schemas.PostOut:
    post = posts.create(post_in, session)
    return post

In full accordance with the documentation, I give the work of converting models from SQLAlchemy into the final response to the client to FastAPI.

I reduced the operations for working with the database to a minimum, without updating and deleting:

Code for working with the database
# crud/posts.py
from datetime import datetime
from typing import Sequence
from sqlalchemy import insert, select, update, desc, asc
from sqlalchemy.orm import Session
from sqlalchemy import exc
from commons.schemas import PostIn, Order

from commons.models import Post


def get(limit: int, offset: int, order: Order, session: Session) -> Sequence[Post]:
    q = (
        select(Post)
        .where(Post.is_deleted == False)
        .order_by(
            desc(Post.published_at) if order is Order.DESC else asc(Post.published_at)
        )
        .limit(limit)
        .offset(offset)
    )
    return session.execute(q).scalars().all()


def create(post_in: PostIn, session: Session) -> Post:
    q = (
        insert(Post)
        .values(
            updated_at=datetime.utcnow(),
            published_at=post_in.published_at,
            title=post_in.title,
            content=post_in.content,
            is_deleted=False,
        )
        .returning(Post)
    )
    post = session.execute(q).scalar_one()
    session.commit()
    return post

Changes when migrating to Pydantic 2

Changes to the major version bring changes to the interfaces, so our version of the application running on FastAPI 0.100.0-beta1 + Pydantic 2 will also need changes. Scrolling fast Migration Guidefor my test application I had to make the following changes:

  • Update dependencies. Here a surprise awaited me – it turns out that in the version of Pydantic 2 they decided to take out familiar to many BaseSettings to a separate library pydantic-settings! And she demands in dependencies typing-extensions<4.0.0when the new version of alchemy 2.0.17 requires typing-extensions>=4.2.0 … It’s good that there is only one variable in my little CRUD, so we put os.getenv and forgotten – but in large applications it can steal a lot of nerves.

  • In Pydantic model configuration orm_mode works but warns that the title has changed to from_attributes. We change.

  • always=True in the Pydantic model now does not work, but the long-awaited decorator has appeared computed_field – now the computed property looks much more decent:

class PostOut(BaseModel):
    ...

    @computed_field
    @property
    def is_published(self) -> bool:
        return datetime.utcnow() >= self.published_at

In general, the transition on a small application looks painless.

Performance Testing

And now let’s move on to the cherry on the cake – the tests themselves. For this I wrote a script test.shwhich:

  • starts the database, starts the application, tests the client using the utility ab (Apache benchmark) for application on FastAPI version 0.98.0

  • demolishes everything with docker compose down -v

  • repeats the first point for the application on FastAPI version 0.100-beta1

The requests themselves are 1000 POST entries /posts and 1000 reads of the first 100 posts GET /posts?per_page=100, the number of simultaneously executed requests (parameter c) = 10

Since I am not a respected bash master, my output from the script is somewhat clumsy, but I will give you the already processed data (everywhere I took the average of the three runs performed by the test):

fastapi 0.98.0

fastapi 0.100.0-beta1

READ r/s

126.90

371.19

READ r/s synthetic

172.57

1203.18

WRITE r/s

342.11

352.65

MEM USAGE BEFORE

72.44MiB

85.95MiB

MEM USAGE AFTER

85.95MiB

98.91MiB

Conclusions:

  • The main thing is that the speed of return of the first 100 posts has increased by x2.92 times! It just helps that the speed of accessing the database does not play a role so much with a large number of repetitive requests. But the speed of the framework has a strong influence.

  • With a purely synthetic test without accessing the database, the speed increased by ~7 times!

  • The write speed, which mainly depends on the speed of the database, has increased, but not significantly.

  • But you have to pay for everything – the consumption of RAM has increased by about 15%.

Conclusion

I was pleased with the large increase in FastAPI speed. Pydantic V2 is currently on github open 7 issue4 of which are about static code analyzers, in general – not much.
If you want to run additional tests – it’s very easy to fork the repository and make changes, so welcome!

Application source code:
github.com

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *