🐍

FastAPIはORJsonResponseではなく、素直にPydanticでよさそう

に公開

始めに

FastAPIはデフォルトではJSONResponseを使用してレスポンスします。FastAPI公式ヘルプにはパフォーマンスを向上させるためにORJSONResponseの使用を提案するページがあります。

fastapi[all]orjsonが含まれていることもあり、ORJSONResponseを使用して動作確認をしてみたのですが、個人的な使用目的としては早くならなかったのでメモとして残しておくことにします。

※ ベンチマークの取り方がおかしい、等があったら教えていただきたいです。

Pydantic v2: 3.2411(1000回のリクエスト)
ORJSONResponse: 3.3306(1000回のリクエスト)       

環境

  • Python
    • 3.13
  • FastAPI
    • 0.115.12
  • Pydantic
    • 2.11.2
  • orjson
    • 3.10.16

実装

基本的にはFastAPIのチュートリアルどおりにpydanticを使用してレスポンスモデルをマッピングします。この時のシリアライズ方法を変更する方法で試していました。検証パターンは載せていますが、どちらで実施しても傾向は変わらなかったので、実際にブログに乗せているのは1つだけです。

パターン1

FastAPI全体にJSONResponseではなく、ORJSONResponseを使用する。

from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

app = FastAPI(default_response_class=ORJSONResponse)

パターン2

継承元にorjson.dumpsを使用してシリアライズさせる設定を追加する。

import orjson
from pydantic import BaseModel, ConfigDict

class BaseModelB(BaseModel):
    model_config = ConfigDict(
        json_serializer=orjson.dumps
    )

動作検証

次のコードでpydanticだけを使用したコードとpydanticorjsonを使用したコードを比較していました。

ある程度階層が深い方がシリアライズの差異がわかりやすいかと思い、JSON構造で第三階層までマッピングするようなコードにしています。

import orjson
from fastapi import APIRouter
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel, ConfigDict

router = APIRouter()

class BaseModelA(BaseModel):
    pass

class BaseModelB(BaseModel):
    model_config = ConfigDict(
        json_serializer=orjson.dumps
    )

# GitHubユーザAPIの深い構造を再現
class Repository(BaseModelA):
    id: int
    name: str
    full_name: str
    private: bool
    html_url: str
    description: str

class Organization(BaseModelA):
    login: str
    id: int
    url: str
    repos_url: str

class UserResponse(BaseModelA):
    login: str
    id: int
    node_id: str
    avatar_url: str
    gravatar_id: str
    url: str
    html_url: str
    followers_url: str
    following_url: str
    gists_url: str
    starred_url: str
    subscriptions_url: str
    organizations_url: str
    repos_url: str
    events_url: str
    received_events_url: str
    type: str
    site_admin: bool
    name: str
    company: str
    blog: str
    location: str
    email: str
    hireable: bool
    bio: str
    twitter_username: str
    public_repos: int
    public_gists: int
    followers: int
    following: int
    created_at: str
    updated_at: str
    repositories: list[Repository]
    organizations: list[Organization]
    metadata: dict[str, str]

class RepositoryB(BaseModelB):
    id: int
    name: str
    full_name: str
    private: bool
    html_url: str
    description: str

class OrganizationB(BaseModelB):
    login: str
    id: int
    url: str
    repos_url: str

class UserResponseB(BaseModelB):
    login: str
    id: int
    node_id: str
    avatar_url: str
    gravatar_id: str
    url: str
    html_url: str
    followers_url: str
    following_url: str
    gists_url: str
    starred_url: str
    subscriptions_url: str
    organizations_url: str
    repos_url: str
    events_url: str
    received_events_url: str
    type: str
    site_admin: bool
    name: str
    company: str
    blog: str
    location: str
    email: str
    hireable: bool
    bio: str
    twitter_username: str
    public_repos: int
    public_gists: int
    followers: int
    following: int
    created_at: str
    updated_at: str
    repositories: list[RepositoryB]
    organizations: list[OrganizationB]
    metadata: dict[str, str]


def generate_deep_response():
    return UserResponse(
        login="octocat",
        id=1,
        node_id="MDQ6VXNlcjE=",
        avatar_url="https://github.com/images/error/octocat_happy.gif",
        gravatar_id="",
        url="https://api.github.com/users/octocat",
        html_url="https://github.com/octocat",
        followers_url="https://api.github.com/users/octocat/followers",
        following_url="https://api.github.com/users/octocat/following{/other_user}",
        gists_url="https://api.github.com/users/octocat/gists{/gist_id}",
        starred_url="https://api.github.com/users/octocat/starred{/owner}{/repo}",
        subscriptions_url="https://api.github.com/users/octocat/subscriptions",
        organizations_url="https://api.github.com/users/octocat/orgs",
        repos_url="https://api.github.com/users/octocat/repos",
        events_url="https://api.github.com/users/octocat/events{/privacy}",
        received_events_url="https://api.github.com/users/octocat/received_events",
        type="User",
        site_admin=False,
        name="monalisa octocat",
        company="GitHub",
        blog="https://github.com/blog",
        location="San Francisco",
        email="octocat@github.com",
        hireable=False,
        bio="There once was...",
        twitter_username="monatheoctocat",
        public_repos=2,
        public_gists=1,
        followers=20,
        following=0,
        created_at="2008-01-14T04:33:35Z",
        updated_at="2008-01-14T04:33:35Z",
        repositories=[
            Repository(
                id=1300192,
                name="Spoon-Knife",
                full_name="octocat/Spoon-Knife",
                private=False,
                html_url="https://github.com/octocat/Spoon-Knife",
                description="Test repository"
            )
        ],
        organizations=[
            Organization(
                login="github",
                id=1,
                url="https://api.github.com/orgs/github",
                repos_url="https://api.github.com/orgs/github/repos"
            )
        ],
        metadata={
            "rate_limit": "1000",
            "remaining": "990",
            "api_version": "2022-11-28"
        }
    ).model_dump()
@router.get("/pydantic_only", response_model=UserResponse)
async def pydantic_only():
    return UserResponse(**generate_deep_response())


@router.get("/pydantic_with_orjson", response_model=UserResponseB)
async def with_orjson():
    return UserResponseB(**generate_deep_response())

テスト自体は1000回実施した結果を見ています。

@pytest.mark.skip
class TestSpeedPydantic:
    def test_pydantic_only_response(self):
        result = timeit.timeit(lambda: client.get("/pydantic/pydantic_only"), number=1000)
        print(f"Pydantic v2: {result:.4f}秒 (1000回のリクエスト)")

    def test_pydantic_with_orjson_response(self):
        result = timeit.timeit(lambda: client.get("/pydantic/pydantic_with_orjson"), number=1000)
        print(f"ORJSONResponse: {result:.4f}秒 (1000回のリクエスト)")

Pydantic v2: 3.2411(1000回のリクエスト)
ORJSONResponse: 3.3306(1000回のリクエスト)       

パターン1, パターン2も含めて何回か施行したのですが、結果的に言えばPydantic V2を使用している場合にはorjsonで処理するほうが余計に遅くなる結果が出ました。

完全にvalidationを行わないでORJSONResponseに直接マッピングさせても処理速度は遅くなりました。

ソースコード

参考情報

終わりに

正直、この検証方法が正しいかどうかの自信はないです。Pydantic V1のころだったらorjsonを使用することによって、優位な結果が出たかもしれませんが、V2では遅くなる結果が出ました。

意図的にfastapi[all]を使用しているメリットが正直自分には見当たらないので、依存関係をfastapiだけにして依存ライブラリを絞ったほうがメリットが多そうです。

この検証方法が誤っている、またはfastapi[all]での有用なライブラリを知っている方はコメントくれると嬉しいです。

Discussion