一. 前言:設定檔不是字典,設定檔是合約 #
很多 Python 專案一開始都很單純:一個 API key、一個資料庫網址,再加上一個 DEBUG=true。於是很自然地寫出這種程式:
import os
debug = os.getenv("DEBUG") == "true"
database_url = os.getenv("DATABASE_URL", "sqlite:///local.db")
openai_api_key = os.getenv("OPENAI_API_KEY")
這樣不是不能用。問題是專案一長大,設定會散落在 API server、背景 worker、CLI、測試與部署腳本裡。最後你會遇到幾種很煩的小 bug:
- 字串
"false"被當成 truthy value - port 明明應該是整數,卻被當成字串傳下去
- 本機有預設值,正式環境忘記設,結果靜悄悄用錯
- 同一個設定在不同檔案有不同名字
- 新同事不知道到底需要哪些環境變數
這就是今天拍拍君要介紹 pydantic-settings 的原因。它不是單純幫你讀 .env,而是把應用程式設定變成一個可驗證、可型別檢查、可重用的 Settings 物件。
如果你之前看過 Pydantic 基礎教學,那篇重點是資料模型與驗證。如果你看過 python-dotenv,那篇重點是把 .env 讀進環境變數。這篇則往前走一步:把設定管理整理成一個清楚的系統。
二. 安裝:Pydantic v2 要另外裝 settings 套件 #
在 Pydantic v1 時,Settings 功能放在 Pydantic 本體裡。到了 Pydantic v2,設定管理被拆成官方獨立套件:
uv add pydantic-settings
# 或是
pip install pydantic-settings
建立一個小專案:
mkdir settings-playground
cd settings-playground
uv init
uv add pydantic-settings
touch settings.py
今天的範例都會先放在 settings.py 裡。重點不是架構多華麗,而是把「程式需要哪些設定」集中描述清楚。
三. 第一個 Settings:把環境變數變成物件 #
先看最小版本:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Daily Pypy Lab"
debug: bool = False
database_url: str
settings = Settings()
print(settings.model_dump())
這裡有三個欄位。app_name 有預設值,debug 有預設值且型別是 bool,database_url 沒有預設值,所以一定要從環境變數提供。
直接執行:
uv run python settings.py
你會看到錯誤。這是好事,因為 database_url 是必要設定,沒有提供就應該立刻失敗。
設定環境變數再跑一次:
DATABASE_URL="sqlite:///local.db" uv run python settings.py
這次就會正常印出設定。拍拍君很喜歡這個特性:設定缺了就早點爆,不要等服務跑到一半才爆。
四. .env 檔案:本機開發的舒服入口 #
正式部署通常會用平台提供的環境變數。但本機開發時,把所有變數都手動打在 shell 裡很煩,這時候可以使用 .env。
APP_NAME="拍拍設定實驗室"
DEBUG=true
DATABASE_URL="sqlite:///dev.db"
API_TIMEOUT_SECONDS=10
接著改寫 Settings:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
app_name: str = "Daily Pypy Lab"
debug: bool = False
database_url: str
api_timeout_seconds: int = 5
執行 uv run python settings.py 之後,.env 裡的值會被讀進來。而且 DEBUG=true 在檔案裡是文字,進到 Settings 之後會變成真正的布林值;API_TIMEOUT_SECONDS=10 也會變成整數。
這就是比單純 os.getenv() 舒服的地方。型別轉換不是靠你到處手寫,它集中在 Settings schema 裡。
五. 命名規則:Python 欄位與環境變數怎麼對應? #
預設情況下,欄位名稱會對應到同名環境變數。例如 database_url 會讀取 DATABASE_URL 或 database_url。實務上,環境變數通常用全大寫,Python 欄位則通常用 snake_case。
如果你想讓所有環境變數都加上前綴,可以這樣:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="PYPY_",
)
app_name: str = "Daily Pypy Lab"
debug: bool = False
database_url: str
這樣它會期待:
PYPY_APP_NAME
PYPY_DEBUG
PYPY_DATABASE_URL
拍拍君建議中大型專案都加前綴。原因很實際:部署環境裡可能不只一個服務。如果大家都叫 DEBUG、PORT、DATABASE_URL,久了會很難追。前綴不是裝飾,前綴是避免設定撞名。
六. 巢狀設定:把一坨變數整理成群組 #
設定一多,全部攤平會很難讀。例如資料庫常常有 host、port、name、user、password。你可以把它整理成一個 model。
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 5432
name: str
user: str
password: str
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
)
database: DatabaseSettings
debug: bool = False
對應的 .env 可以這樣寫:
DATABASE__HOST=localhost
DATABASE__PORT=5432
DATABASE__NAME=dailypypy
DATABASE__USER=pypy
DATABASE__PASSWORD=local-secret
DEBUG=true
雙底線 __ 會把環境變數拆成巢狀結構。於是你可以在程式裡使用 settings.database.host 和 settings.database.port,比到處傳一堆 DATABASE_* 字串舒服很多。
七. 驗證規則:設定值不是能轉型就好 #
型別轉換只能解決一半問題。另一半是「這個值合不合理」。例如 timeout 應該大於 0,worker 數量不應該是 999,production 環境不應該打開 debug。 這些規則可以直接寫在 model 裡。
from typing import Literal
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
environment: Literal["local", "staging", "production"] = "local"
debug: bool = False
api_timeout_seconds: int = Field(default=5, gt=0, le=60)
worker_count: int = Field(default=2, ge=1, le=16)
@model_validator(mode="after")
def production_must_be_strict(self) -> "Settings":
if self.environment == "production" and self.debug:
raise ValueError("production cannot run with debug=true")
return self
這段做了幾件事:
environment只能是三個指定字串api_timeout_seconds必須介於 1 到 60worker_count必須介於 1 到 16- 正式環境不能開 debug
設定管理最怕「看起來有值,但值很荒謬」。把規則寫在 Settings 裡,錯誤會集中、清楚、提早發生。
八. SecretStr:不要不小心把密鑰印出來 #
設定通常會包含 secret,例如 API key、資料庫密碼、Webhook token。這些值最怕在 log 裡被印出來,Pydantic 提供 SecretStr。
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str
openai_api_key: SecretStr
假設 .env 裡有:
DATABASE_URL=sqlite:///local.db
OPENAI_API_KEY=sk-demo-secret
執行:
settings = Settings()
print(settings.model_dump())
print(settings.openai_api_key)
輸出會遮住 secret。真正需要拿原始字串時,再明確呼叫:
api_key = settings.openai_api_key.get_secret_value()
這個設計很好。它不是讓 secret 永遠拿不到,而是逼你在需要明文的地方寫得更明確。
九. FastAPI:設定只建立一次,入口保持乾淨 #
FastAPI 專案常常需要 settings,但不要在每個 request 裡重新建立。比較好的做法是用 lru_cache 做一次性載入,再透過 dependency 注入。
from functools import lru_cache
from fastapi import Depends, FastAPI
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
app_name: str = "Daily Pypy API"
debug: bool = False
database_url: str
@lru_cache
def get_settings() -> Settings:
return Settings()
app = FastAPI()
@app.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]:
return {"app": settings.app_name, "debug": settings.debug, "status": "ok"}
這樣 Settings 只建立一次,測試時也可以 override dependency。endpoint 不需要自己知道設定從哪裡來,設定物件也可以被 IDE 與型別檢查器理解。 #
十. 一個完整範例:可部署的小型 API 設定 #
最後整理成一個比較完整的版本。
from functools import lru_cache
from typing import Literal
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="PYPY_",
env_nested_delimiter="__",
extra="ignore",
)
app_name: str = "Daily Pypy API"
environment: Literal["local", "staging", "production"] = "local"
debug: bool = False
database_url: str
api_token: SecretStr
request_timeout_seconds: int = Field(default=10, gt=0, le=60)
worker_count: int = Field(default=2, ge=1, le=16)
@property
def is_production(self) -> bool:
return self.environment == "production"
@model_validator(mode="after")
def validate_production_rules(self) -> "Settings":
if self.is_production and self.debug:
raise ValueError("production cannot run with debug=true")
return self
@lru_cache
def get_settings() -> Settings:
return Settings()
本機 .env:
PYPY_ENVIRONMENT=local
PYPY_DEBUG=true
PYPY_DATABASE_URL=sqlite:///local.db
PYPY_API_TOKEN=local-dev-token
PYPY_REQUEST_TIMEOUT_SECONDS=10
PYPY_WORKER_COUNT=2
正式環境就不要靠這份檔案。讓部署平台提供:
PYPY_ENVIRONMENT=production
PYPY_DEBUG=false
PYPY_DATABASE_URL=postgresql://...
PYPY_API_TOKEN=...
這樣本機、CI、正式環境都走同一個 Settings schema,差別只在資料來源。這才是設定管理最值得追求的狀態:程式碼知道需要什麼,環境負責提供什麼。
結語 #
pydantic-settings 最迷人的地方,不是它可以讀 .env。這件事很多工具都做得到。它真正解決的是:你的專案到底需要哪些設定、每個設定是什麼型別、哪些設定必填、哪些值不合理、本機與正式環境的覆蓋規則是什麼,以及 secret 會不會不小心被印出來。
如果專案只有十幾行 script,os.getenv() 當然夠用。但只要你開始有 API server、worker、CLI、測試、部署環境,Settings schema 就很值得早點建立。
拍拍君的建議很簡單:不要等設定散落滿地才整理。在專案開始長出第二個執行入口時,就把 Settings 抽出來。未來的你會少罵現在的你很多次。