Python Pydantic 資料驗證與設定管理

在 Python 開發中(特別是 Web API 開發,如 FastAPI),資料驗證 (Data Validation) 和解析 (Parsing) 是非常重要的一環。傳統的做法可能需要手動寫很多 if-else 檢查型別和內容,既繁瑣又容易出錯。

Pydantic 是一個基於 Python Type Hints 的資料驗證庫。它讓你用定義類別 (Class) 的方式來宣告資料結構,並自動處理型別轉換和驗證。

本文基於 Pydantic V2 版本撰寫,這是目前最新且推薦的版本 (效能比 V1 快很多,底層由 Rust 重寫)。

為什麼選擇 Pydantic?

  1. 基於標準 Type Hints:使用你熟悉的 str, int, List, Optional 等語法,不需要學習新的 Schema 定義語言。
  2. 自動型別轉換:如果你定義欄位是 int,但傳入 "123" (字串),Pydantic 會試著幫你轉成整數 123
  3. IDE 支援度高:因為基於 Class 和 Type Hints,自動補全 (Autocomplete) 和靜態檢查 (MyPy/Pyright) 都能完美運作。
  4. 效能優異:核心部分使用 Rust 編寫,速度極快。
  5. 設定管理:內建 BaseSettings,非常適合用來讀取環境變數 (Environment Variables) 管理專案設定。

基本用法

使用 Pydantic 非常簡單,只需要建立一個繼承自 pydantic.BaseModel 的類別。

from pydantic import BaseModel, ValidationError
from typing import List, Optional

class User(BaseModel):
    id: int
    name: str
    signup_ts: Optional[str] = None
    friends: List[int] = []

# 1. 正常資料 (自動轉換型別)
# 注意 id 傳入字串 "123",會被自動轉成整數 123
user_data = {
    "id": "123",
    "name": "John Doe",
    "signup_ts": "2024-01-01 12:00",
    "friends": [1, 2, "3"]
}

user = User(**user_data)
print(user)
print(user.id) # 123 (int)

# 2. 錯誤資料 (驗證失敗)
try:
    User(id="not-a-number", name="Jane")
except ValidationError as e:
    print(e.json())
    # 會輸出詳細的錯誤訊息,指出哪個欄位錯誤、原因為何

與 Python Dataclasses 的差異

雖然 Pydantic 看起來很像標準庫的 dataclasses,但主要區別在於:

  • Dataclasses:主要用於儲存資料,不會主動驗證或轉換數據。
  • Pydantic:主要用於解析與驗證資料,確保資料符合定義的規範。
  • Pydantic:主要用於解析與驗證資料,確保資料符合定義的規範。

巢狀模型 (Nested Models)

Pydantic 支援將一個 Model 作為另一個 Model 的欄位型別,這在處理複雜的 JSON 結構時非常有用。

class Address(BaseModel):
    city: str
    street: str
    zip_code: str

class UserWithAddress(BaseModel):
    name: str
    # 引用 Address Model
    address: Address 

data = {
    "name": "Bob",
    "address": {
        "city": "Taipei",
        "street": "Xinyi Rd.",
        "zip_code": "110"
    }
}

user = UserWithAddress(**data)
print(user.address.city) # Taipei

除了基本的型別檢查,我們常常需要更細的限制,例如「年齡必須大於 0」、「字串長度不能超過 50」。這可以使用 Field 來達成。

from pydantic import BaseModel, Field

class Item(BaseModel):
    name: str = Field(..., max_length=50, description="商品名稱")
    price: float = Field(..., gt=0, description="價格必須大於 0")
    # gt: greater than
    # ge: greater than or equal
    # lt: less than
    # le: less than or equal
    
    quantity: int = Field(1, ge=1) # 預設值為 1,且必須 >= 1

try:
    Item(name="Apple", price=-5)
except ValidationError as e:
    print(e)
    # price
    #   Input should be greater than 0 [type=greater_than, input_value=-5, input_type=int]

使用 Annotated 定義限制 (推薦)

在 Pydantic V2 中,更推薦使用 Python 標準庫的 typing.Annotated 來定義欄位限制。這樣可以把「型別定義」和「預設值」分開,程式碼會更乾淨,也更 conform Python 的 Type Hint 標準。

from typing import Annotated
from pydantic import BaseModel, Field

class Item(BaseModel):
    # 把 Field 限制放在 Annotated 裡
    name: Annotated[str, Field(max_length=50)]
    
    # 預設值可以單獨寫在等號後面,不用擠在 Field 裡
    price: Annotated[float, Field(gt=0)] = 0.0
    
    quantity: Annotated[int, Field(ge=1)] = 1

print(Item(name="Banana", price=10.0))

欄位別名 (Field Aliases) 與駝峰式命名

在 Python 中我們慣用 snake_case (如 first_name),但 API 的 JSON 資料常使用 camelCase (如 firstName)。Pydantic 提供了 Alias 功能來解決這個問題。

from pydantic import ConfigDict

class CamelCaseModel(BaseModel):
    # 允許使用 name 或 serial_no 初始化
    # validation_alias: 讀取輸入資料時使用的別名
    # serialization_alias: 輸出資料 (dump) 時使用的別名
    first_name: str = Field(validation_alias="firstName")
    
    # 也可以直接在 Config 中設定全域規則 (V2 推薦寫法)
    model_config = ConfigDict(populate_by_name=True) 

data = {"firstName": "John"}
m = CamelCaseModel(**data)
print(m.first_name) # John

如果 Field 的內建限制不夠用,可以使用 @field_validator 裝飾器來寫自定義邏輯。

After Validator (預設)

這是在 Pydantic 完成型別轉換 之後 執行的驗證。例如欄位是 int,Pydantic 會先嘗試把字串轉成整數,成功後再丟給你的 validator 檢查。

from pydantic import BaseModel, field_validator

class RegisterUser(BaseModel):
    username: str
    
    @field_validator('username')
    @classmethod
    def username_must_be_alphanumeric(cls, v: str) -> str:
        # 這裡收到的 v 已經確保是 str 型別
        if not v.isalnum():
            raise ValueError('使用者名稱只能包含英數字')
        return v.title() # 也可以修改值

try:
    RegisterUser(username="user_name!!")
except ValidationError as e:
    print(e) 
    # Value error, 使用者名稱只能包含英數字 [type=value_error, input_value='user_name!!', input_type=str]

Before Validator (資料預處理)

有時候你需要在 Pydantic 進行型別轉換 之前 就介入處理。例如:前端傳來的日期格式很奇怪,或者你想允許傳入 "Yes"/"No" 字串來代表 True/False

這時可以使用 mode='before'

from typing import Any
from pydantic import BaseModel, field_validator

class CheckBox(BaseModel):
    is_checked: bool

    @field_validator('is_checked', mode='before')
    @classmethod
    def check_legacy_boolean(cls, v: Any) -> Any:
        # 因為是 before validator,v 可能是任何型別 (raw input)
        if isinstance(v, str):
            if v.lower() == 'yes':
                return True
            if v.lower() == 'no':
                return False
        return v # 回傳處理後的值,接著交給 Pydantic 做標準型別驗證

c1 = CheckBox(is_checked="Yes") 
print(c1.is_checked) # True (bool)

c2 = CheckBox(is_checked="no")
print(c2.is_checked) # False (bool)

模型驗證器 (Model Validators)

Field Validator 只能檢查「單一欄位」,但有時候我們需要驗證「多個欄位之間的關係」,例如:「確認密碼」必須等於「密碼」、「如果選擇信用卡付款,卡號欄位不能為空」。

這時就要用 @model_validator

from typing import Self
from pydantic import BaseModel, model_validator, ValidationError

class ChangePassword(BaseModel):
    password: str
    confirm_password: str

    @model_validator(mode='after')
    def check_passwords_match(self) -> Self:
        # 這裡的 self 已經是填好資料的 Model instance
        if self.password != self.confirm_password:
            raise ValueError('兩次輸入的密碼不一致')
        return self

try:
    ChangePassword(password="secret", confirm_password="wrong")
except ValidationError as e:
    print(e)
    # Value error, 兩次輸入的密碼不一致 [type=value_error, input_value=..., input_type=ChangePassword]

mode='before' 的 Model Validator 則是用來處理「進入 Model 之前」的整個 Dictionary 資料,適合用來做資料清洗或結構攤平 (Flattening)。

計算欄位 (Computed Fields)

有時候某個欄位的值是依賴其他欄位計算出來的,例如 full_name 是由 first_name + last_name 組成。Pydantic V2 提供了 @computed_field 裝飾器。

from pydantic import computed_field

class Person(BaseModel):
    first_name: str
    last_name: str

    @computed_field
    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

p = Person(first_name="Tom", last_name="Cruise")
print(p.full_name) # Tom Cruise

# dump 時也會包含 computed fields
print(p.model_dump())
# {'first_name': 'Tom', 'last_name': 'Cruise', 'full_name': 'Tom Cruise'}

嚴格模式 (Strict Mode)

預設情況下,Pydantic 會嘗試進行型別轉換(例如字串 "123" 轉整數 123)。如果你希望禁止這種行為,確保輸入型別必須完全符合,可以使用 Strict Mode

from pydantic import ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(strict=True)
    
    id: int
    name: str

try:
    # 這裡會報錯,因為 id 必須是 int,不能是 str
    StrictUser(id="123", name="Alice") 
except ValidationError as e:
    print(e)
    # Input should be a valid integer [type=int_type, input_value='123', input_type=str]

當你需要將 Pydantic 物件轉回 Dictionary 或 JSON 字串(例如 API 回傳資料時),可以使用 model_dump()model_dump_json()

user = User(id=1, name="Alice")

# 轉成 Dictionary
user_dict = user.model_dump()
print(user_dict) 
# {'id': 1, 'name': 'Alice', 'signup_ts': None, 'friends': []}

# 轉成 JSON String
user_json = user.model_dump_json()
print(user_json)
# {"id":1,"name":"Alice","signup_ts":null,"friends":[]}
在 Pydantic V1 中,這兩個方法分別叫 dict()json(),但在 V2 中已被棄用 (Deprecated),建議改用新的名稱。

更多建立模型的方式

除了使用 User(**data) 這種像是 keyword arguments 的方式建立物件,Pydantic 還有提供專門的方法來讀取資料:

  • Model.model_validate(dict_data): 從 Dictionary 建立
  • Model.model_validate_json(json_str): 從 JSON 字串直接建立 (效率比先 json.loads 再丟進去更好)
# 從 JSON 字串建立
json_input = '{"id": 100, "name": "Bob"}'
user = User.model_validate_json(json_input)
print(user.name) # Bob

設定管理 (Generic Settings)

Pydantic 另一個強大的功能是 BaseSettings (需安裝 pydantic-settings 套件),它可以用來管理整個應用程式的設定,並支援從環境變數 (Environment Variables) 自動讀取。

安裝:

pip install pydantic-settings

使用範例:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "My App"
    admin_email: str
    items_per_user: int = 50
    
    # 會自動讀取 .env 檔案
    class Config:
        env_file = ".env"

# 如果環境變數中有 ADMIN_EMAIL,會自動覆蓋
# export ADMIN_EMAIL="admin@example.com"
settings = Settings()

print(settings.app_name)
print(settings.admin_email) # 如果沒讀到環境變數會報錯

這在開發雲端應用程式(符合 12-factor app 原則)時非常實用,你不需要自己寫程式碼去解析 os.environ

總結

Pydantic 已經成為現代 Python 開發的標準配備之一,特別是在數據密集型的應用中。它讓資料驗證變得宣告式、可讀性高且強型別,大幅提升了程式碼的健壯性。