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
だけを使用したコードとpydantic
とorjson
を使用したコードを比較していました。
ある程度階層が深い方がシリアライズの差異がわかりやすいかと思い、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
に直接マッピングさせても処理速度は遅くなりました。
ソースコード
参考情報
- https://fastapi.tiangolo.com/advanced/custom-response
- https://github.com/pydantic/pydantic/discussions/6388
終わりに
正直、この検証方法が正しいかどうかの自信はないです。Pydantic V1のころだったらorjson
を使用することによって、優位な結果が出たかもしれませんが、V2では遅くなる結果が出ました。
意図的にfastapi[all]
を使用しているメリットが正直自分には見当たらないので、依存関係をfastapi
だけにして依存ライブラリを絞ったほうがメリットが多そうです。
この検証方法が誤っている、またはfastapi[all]
での有用なライブラリを知っている方はコメントくれると嬉しいです。
Discussion