Pydanticモデルの落とし穴:インスタンス変数と`default`/`default_factory`の正しい使い分け
はじめに
FastAPI などで Pydantic を使ってデータモデルを定義していると、時折「あれ?リクエスト間でデータが混ざってる?」といった不思議な現象に遭遇することがあります。多くの場合、その原因は Python のクラス変数とインスタンス変数の挙動、特にミュータブルなデフォルト値の扱いや、Pydantic の Field
における default
と default_factory
の違いを正確に理解していないことにあります。
この記事では、実際の開発で遭遇した問題を元に、これらの概念を整理し、Pydantic モデルを安全かつ正しく定義するためのポイントを解説します。
Python におけるクラス変数とインスタンス変数
まず、基本的な Python のクラス変数とインスタンス変数の違いをおさらいしましょう。
- クラス変数: クラス定義の直下で定義され、そのクラスから生成される全てのインスタンス間で共有されます。
-
インスタンス変数: 通常、
__init__
メソッド内でself.変数名 = 値
のように定義され、インスタンスごとに独立した値を持ちます。
class MyClass:
class_var = "共有" # クラス変数
def __init__(self, value):
self.instance_var = value # インスタンス変数
obj1 = MyClass("A")
obj2 = MyClass("B")
print(f"obj1: クラス変数={obj1.class_var}, インスタンス変数={obj1.instance_var}")
# -> obj1: クラス変数=共有, インスタンス変数=A
print(f"obj2: クラス変数={obj2.class_var}, インスタンス変数={obj2.instance_var}")
# -> obj2: クラス変数=共有, インスタンス変数=B
# クラス変数を変更すると…
MyClass.class_var = "変更された共有"
print(f"obj1: クラス変数={obj1.class_var}") # -> obj1: クラス変数=変更された共有
print(f"obj2: クラス変数={obj2.class_var}") # -> obj2: クラス変数=変更された共有
# インスタンス変数を変更しても他のインスタンスには影響しない
obj1.instance_var = "AA"
print(f"obj1: インスタンス変数={obj1.instance_var}") # -> obj1: インスタンス変数=AA
print(f"obj2: インスタンス変数={obj2.instance_var}") # -> obj2: インスタンス変数=B
「ミュータブルなデフォルト値」の罠
Python でよく知られた注意点として、「ミュータブルなデフォルト値」があります。これは、リスト (list
) や辞書 (dict
) のような変更可能なオブジェクトをクラス変数や関数のデフォルト引数として直接初期化した場合に発生します。
class BadCache:
# この cache は全インスタンスで共有されてしまう!
cache: dict = {}
def add(self, key, value):
self.cache[key] = value
c1 = BadCache()
c2 = BadCache()
c1.add("a", 1)
print(c2.cache) # -> {'a': 1} (c2で操作していないのに値が入っている!)
クラス変数 cache = {}
はクラス定義時に一度だけ評価され、生成された辞書オブジェクトが全てのインスタンスで共有されるため、このような挙動になります。これを避けるためには、__init__
でインスタンスごとに初期化するのが定石です。
class GoodCache:
def __init__(self):
# インスタンスごとに独立した辞書を持つ
self.cache: dict = {}
def add(self, key, value):
self.cache[key] = value
c1 = GoodCache()
c2 = GoodCache()
c1.add("a", 1)
print(c2.cache) # -> {} (影響を受けない)
Field
Pydantic モデルと Pydantic モデルでは、フィールド(属性)をクラス定義の直下に型アノテーション付きで定義します。
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
tags: list[str] = [] # <-- これが問題!
一見問題なさそうに見えますが、tags: list[str] = []
は先ほどの「ミュータブルなデフォルト値」の罠と同じ問題を引き起こします。[]
はクラス定義時に一度だけ評価され、全ての Item
インスタンスでデフォルトの tags
として共有されてしまいます。
ここで登場するのが Pydantic の Field
です。Field
を使うと、フィールドに対してデフォルト値、バリデーションルール、メタデータなどをより詳細に設定できます。
from pydantic import BaseModel, Field
from datetime import datetime
import uuid
from typing import List, Dict, Any
class UserProfile(BaseModel):
# 静的なイミュータブル値には default
user_id: int = Field(default=0)
username: str = Field(default="guest")
is_active: bool = Field(default=True)
# None もイミュータブルなので default でOK
email: str | None = Field(default=None)
# ミュータブルな型には default_factory
tags: List[str] = Field(default_factory=list)
preferences: Dict[str, Any] = Field(default_factory=dict)
# 動的に生成する値には default_factory
# datetime.now は呼び出し可能なオブジェクトなので直接渡せる
created_at: datetime = Field(default_factory=datetime.now)
# UUID生成は lambda を使う例
session_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# __init__ は Pydantic が処理するため通常不要
# class Config:
# # 必要に応じて設定を追加
# pass
Field
の default
vs default_factory
Field
を使う際に最も重要なのが default
と default_factory
の違いを理解することです。
Field(default=値)
- 静的なデフォルト値を設定する場合に使用します。
- 指定された「値」は、クラス定義時に一度だけ評価されます。
-
イミュータブルな型 (int, float, str, bool, None, tuple など) のデフォルト値に適しています。
- 例:
Field(default=0)
,Field(default="")
,Field(default=None)
- 例:
-
ミュータブルな型 (
list
,dict
,Queue
など) には絶対に使わないでください!Field(default=[])
と書くと、全インスタンスで空リストが共有されてしまいます。 -
動的に生成する値にも使わないでください!
Field(default=str(uuid.uuid4()))
と書くと、クラス定義時に生成された1つのUUIDが全インスタンスのデフォルト値になってしまいます。
Field(default_factory=関数)
- インスタンスごとに新しいデフォルト値を生成する必要がある場合に使用します。
-
default_factory
に指定された「関数」(または呼び出し可能なオブジェクト)は、インスタンスが生成されるたび(かつ、そのフィールドに値が指定されなかった場合)に呼び出され、その戻り値がデフォルト値となります。 -
ミュータブルな型のデフォルト値に必須です。
- 例:
Field(default_factory=list)
,Field(default_factory=dict)
,Field(default_factory=asyncio.Queue)
- 例:
-
動的に生成する値のデフォルト値にも必須です。
- 例:
Field(default_factory=lambda: str(uuid.uuid4()))
,Field(default_factory=datetime.now)
- 例:
まとめ:正しい使い分け
ケース | 使用すべき引数 | 例 | なぜか? |
---|---|---|---|
静的なイミュータブル値 (int, str, None...) | default |
Field(default=0) , Field(default="")
|
共有されても問題なく、シンプル。 |
ミュータブルな型 (list, dict, Queue...) | default_factory |
Field(default_factory=list) , Field(default_factory=asyncio.Queue)
|
インスタンスごとに独立したオブジェクトを生成する必要があるため。 |
動的に生成する値 (UUID, datetime...) | default_factory |
Field(default_factory=lambda: str(uuid.uuid4())) |
インスタンスごとに異なる値を生成する必要があるため。 |
結論
Pydantic モデルでフィールドを定義する際には、そのデフォルト値の性質(静的か動的か、イミュータブルかミュータブルか)を意識し、default
と default_factory
を適切に使い分けることが非常に重要です。特に、安易に list
や dict
を default=[]
や default={}
で初期化すると、予期せぬバグの原因となります。
Field(default_factory=...)
を活用することで、インスタンスごとに独立した状態を持つ、安全で堅牢なデータモデルを構築できます。この記事が、Pydantic を使う上での一助となれば幸いです。
Discussion