pydantic-settingsの使い方—.envと環境変数はどちらが勝つのか

pydantic-settingsの使い方—.envと環境変数はどちらが勝つのか | mohablog

.envAPP_DB_PORT=6543 と書き、シェルから APP_DB_PORT=9999 を渡す。pydantic-settings が返すのは 9999 です。環境変数が .env より強い、という順序が最初から決まっているから。

検証環境は pydantic-settings 2.14.2(2026年6月19日リリース)、pydantic 2.13.4、Python 3.14.6。以下に貼る出力はすべて実行結果です。

目次

os.environ.getを置き換えるBaseSettings

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
    db_host: str = "localhost"
    db_port: int = 5432
    debug: bool = False
    timeout: float = 3.0

s = Settings()
print(s)
print("port type:", type(s.db_port).__name__, "| debug type:", type(s.debug).__name__)

.env の中身はこれだけ。

APP_DB_HOST=db.internal
APP_DB_PORT=6543
APP_DEBUG=true

実行結果。

db_host='db.internal' db_port=6543 debug=True timeout=3.0
port type: int | debug type: bool

型変換がフィールド定義に従う

環境変数は文字列としてしか渡ってきません。6543int に、trueboolTrue になっている。変換規則を書いた覚えはなく、根拠はフィールドの型注釈だけです。

os.environ.get で同じことをやると、こうなります。

db_port = int(os.environ.get("APP_DB_PORT", "5432"))
debug = os.environ.get("APP_DEBUG", "false").lower() in ("1", "true", "yes")
timeout = float(os.environ.get("APP_TIMEOUT", "3.0"))

デフォルト値が文字列リテラルで書かれ、その横に変換関数が並ぶ。"false"bool() に渡すと True になる罠を避けるため、in ("1", "true", "yes") のような判定を手で書くことになります。

壊れた値は起動時に止まる

数値でない値を渡すとどうなるか。

APP_DB_PORT=notanumber python basic.py
pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
db_port
  Input should be a valid integer, unable to parse string as an integer
  [type=int_parsing, input_value='notanumber', input_type=str]

設定ミスが Settings() の行で止まる。DBへ接続しにいく段階まで持ち越されません。どのフィールドが、どんな入力で落ちたかがエラーに出るので、原因を探す手間もありません。

.envと環境変数はどちらが勝つのか

同じキーが複数の場所にあるとき、どれが採用されるか。公式ドキュメントの “Field value priority” セクションが順序を明示しています。

In the case where a value is specified for the same Settings field in multiple ways, the selected value is determined as follows (in descending order of priority)

公式が定める6段階

順位ソース備考
1CLI引数cli_parse_args 有効時のみ
2Settings(...) の引数コードで直接渡した値
3環境変数
4.env ファイル
5シークレットディレクトリsecrets_dir
6フィールドのデフォルト値最弱

実務で効くのは3位と4位の関係です。.env はローカルの既定値を置く場所で、本番のコンテナが渡す環境変数が常にそれを上書きする。公式も .env の項で「environment variables will always take priority over values loaded from a dotenv file」と断言しています。

1本のコードで順位を確かめる

4フィールドそれぞれに違う数のソースを与えて、一度に観察します。

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env.prio")
    a: str = "default-a"   # どこにも無い
    b: str = "default-b"   # .env のみ
    c: str = "default-c"   # .env と 環境変数
    d: str = "default-d"   # .env と 環境変数 と init

s = Settings(d="from-init")
for k, v in s.model_dump().items():
    print(f"{k} = {v}")

.env.prio には b / c / d の3つを from-dotenv で書いた上で、実行します。

c=from-env d=from-env python prio.py
a = default-a
b = from-dotenv
c = from-env
d = from-init

表の順位がそのまま出ました。d は3つのソースに値があるが、init が勝つ。

SettingsConfigDictの主要オプション

挙動を変えるものだけ挙げます。

オプションデフォルト効果
env_fileNone読み込む dotenv ファイルのパス
env_prefix''全環境変数名に付く接頭辞
env_nested_delimiterNoneネストを表す区切り(例 __)
case_sensitiveFalse環境変数名の大文字小文字を区別するか
extra'forbid'モデル未定義キーを拒否するか
secrets_dirNone1ファイル1値のシークレット置き場
nested_model_default_partial_updateFalseネストのデフォルトを部分更新するか

extranested_model_default_partial_update は、既定値のまま使うと想定と違う結果になります。

.envが効かない時に疑う3箇所

.env に書いたのに反映されない。原因はだいたいこの3つに入ります。

env_prefixを付け忘れたエントリは黙って捨てられる

env_prefix="APP_" を設定したクラスに、prefix なしの .env を食わせます。

# .env.prefix の中身は  db_host=forgot-the-prefix  の1行だけ
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env.prefix", env_prefix="APP_")
    db_host: str = "localhost"

print(Settings())
db_host='localhost'

フィールド名と .env のキーは db_host で一致しているのに、値はデフォルトの localhost。警告もエラーも出ません。公式の “Dotenv (.env) support” セクションに、この挙動が仕様として書かれています。

an unprefixed dotenv entry whose name matches a field name is silently skipped

未知のキーを拒否する extra="forbid" なら拾えるのでは、と考えて試した結果。

model_config = SettingsConfigDict(
    env_file=".env.prefix", env_prefix="APP_", extra="forbid"
)
# 出力: db_host='localhost'   ← forbid でもエラーにならない

変わりません。prefix に一致しないエントリは extra の判定に届く前に捨てられる。env_prefix を使うなら .env 側のキーにも APP_ を付ける、が唯一の対処です。.env とクラス定義を別の人が触る構成だと、この不一致は長く残ります。

モデルに無いキーはValidationErrorになる

逆方向の罠。env_prefix なしで、モデルに存在しないキーが .env にあるケース。

# .env.extra
b=from-dotenv
legacy_key=old-value
extra=forbid   -> ValidationError: Extra inputs are not permitted
                  [type=extra_forbidden, input_value='old-value', input_type=str]
extra=ignore   -> {'b': 'from-dotenv'}

BaseSettingsextra は既定で forbid。dotenv ソースはファイル全体を読んでモデルに丸ごと渡すため、使わなくなった古いキーが1行残っているだけでアプリが起動しなくなります。チームで .env を共有し、各自が自分用の変数を書き足す運用なら extra="ignore" が要ります。

env_fileはカレントディレクトリ基準で解決される

env_file=".env" の相対パスは、スクリプトの場所ではなくプロセスのカレントディレクトリから解決されます。同じスクリプトを2箇所から実行して比べます。

# /tmp/ps_check/.env.cwd     -> b=from-cwd-dotenv
# /tmp/ps_check/sub/.env.cwd -> b=from-subdir-dotenv

$ cd /tmp/ps_check && python cwd.py
  b = from-cwd-dotenv

$ cd /tmp/ps_check/sub && python ../cwd.py
  b = from-subdir-dotenv

スクリプトのパスは ../cwd.py で同一。読まれた .env.cwd だけが入れ替わっています。手元で通ったコードが cron に載せた途端デフォルト値で動き出したことがあり、原因はこれでした。crontab はカレントディレクトリがホームになるので、リポジトリ直下の .env は視界に入りません。

回避策は、相対パスをやめて起点をファイル自身に固定すること。

from pathlib import Path

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=Path(__file__).parent.parent / ".env"
    )

ネストした設定でデフォルト値が消える

DB接続やRedisの設定をサブモデルへ切り出すと、env_nested_delimiter の出番です。ここに nested_model_default_partial_update が絡みます。

env_nested_delimiterで階層を埋める

class Redis(BaseModel):
    host: str = "localhost"
    port: int = 6379

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_nested_delimiter="__")
    redis: Redis = Redis()

REDIS__PORT=6380 を渡すと、区切り文字の左右がそのまま階層になります。

{'redis': {'host': 'localhost', 'port': 6380}}

デフォルトに値を入れると挙動が変わる

サブモデルのデフォルトを Redis() ではなく Redis(host="redis.internal") にして、REDIS__PORT だけを環境変数で渡します。

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_nested_delimiter="__",
        nested_model_default_partial_update=partial,   # False / True を切り替え
    )
    redis: Redis = Redis(host="redis.internal")
partial_update=False: {'redis': {'host': 'localhost', 'port': 6380}}
partial_update=True:  {'redis': {'host': 'redis.internal', 'port': 6380}}

既定の False では hostredis.internal ではなく localhost環境変数で触っていない host の指定が消えていますREDIS__PORT が来た時点でサブモデル全体が環境変数側から組み直され、Redis(host="redis.internal") というデフォルトが丸ごと捨てられる。代わりに Redis クラス自身のデフォルトが入ります。

公式の “Nested model default partial updates” セクションにも明記があります。

By default, Pydantic settings prevents partial updates to nested model defaults.

ポート番号だけ差し替えたつもりが接続先ホストごと変わる。サブモデルのデフォルトに値を持たせるなら nested_model_default_partial_update=True を付けます。

値がどのソースから来たかを特定する

ソースが4つ有効なら、1つのフィールドに対して値の候補が4つあります。最終結果だけを見ても、そのうちどれが採用されたかは分かりません。

PYDANTIC_SETTINGS_DEBUGは2.14.2にはまだ無い

公式ドキュメントには “Debugging settings sources” というセクションがあり、環境変数ひとつで全ソースの中身を優先順位つきで吐かせられる、と書かれています。

PYDANTIC_SETTINGS_DEBUG=1 python your_app.py

ただし現時点の最新リリースである 2.14.2 では動きません。インストール済みのパッケージを検索すると、実装が1行も入っていないことが分かります。

$ pip show pydantic-settings | head -2
Name: pydantic-settings
Version: 2.14.2

$ grep -rn "PYDANTIC_SETTINGS_DEBUG\|getLogger" .venv/lib/python3.14/site-packages/pydantic_settings/
(出力なし)

この機能は GitHub の main ブランチにマージ済みで、ドキュメントの latest がそちらを反映している状態。main 側には _DEBUG_ENV_VAR = 'PYDANTIC_SETTINGS_DEBUG'logger = logging.getLogger('pydantic_settings') が入っています。2.14.2 は 2026年6月19日リリースのセキュリティパッチで、デバッグ機能はその後の追加。次のリリースを待つ必要があります。

ソースを直接呼んで中身を並べる

現行バージョンで同じ情報を得る方法。各ソースクラスは呼び出し可能なので、Settings を組み立てる前に単体で実行できます。

from pydantic_settings.sources import EnvSettingsSource, DotEnvSettingsSource

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env.prio", extra="ignore")
    b: str = "default-b"
    c: str = "default-c"

for src in (EnvSettingsSource(Settings), DotEnvSettingsSource(Settings)):
    print(f"{type(src).__name__:22} -> {src()}")
print(f"{'最終結果':22} -> {Settings().model_dump()}")
EnvSettingsSource      -> {'c': 'from-env'}
DotEnvSettingsSource   -> {'b': 'from-dotenv', 'c': 'from-dotenv', 'd': 'from-dotenv'}
最終結果               -> {'b': 'from-dotenv', 'c': 'from-env'}

各ソースが何を拾ったかが分離して見えます。c は両方に居て環境変数が採用され、d は dotenv にあるがモデルに無いので最終結果から消えた。PYDANTIC_SETTINGS_DEBUG が入るまではこれで足ります。

ソースクラスは settings_cls を受け取るだけで単体生成でき、呼ぶと dict を返す。Settings()これらの dict を優先順位順にマージしているだけで、特別な仕組みは挟まっていません。優先順位を疑ったら、疑っているソースを1つずつ呼ぶ。値が想定と違うのか、そもそも拾えていないのかが1行で切り分けられます。

settings_customise_sourcesで順序を入れ替える

優先順位そのものを変えたい場合。settings_customise_sources をオーバーライドし、戻り値のタプルの順番で優先度が決まります(先頭が最強)。

class Settings(BaseSettings):
    api_key: str = "default"

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return env_settings, init_settings, file_secret_settings

env_settings を先頭に置いたので、環境変数が Settings(api_key=...) の引数より強くなります。

$ API_KEY=from-env python custom.py
customise後: from-env

標準の順位なら from-init が出るところ。タプルから外したソースは無効になるので、dotenv_settings を返り値に含めなければ .env は読まれません。YAML や TOML を足す場合も同じ場所で、YamlConfigSettingsSourceTomlConfigSettingsSource をタプルへ差し込みます。

BaseSettingsはデフォルト値も検証する

BaseModel と同じ感覚で書くとズレる箇所。型と合わないデフォルト値を、両方のクラスに書いて比べます。

class M(BaseModel):
    foo: int = "test"   # 型と不一致

class S(BaseSettings):
    foo: int = "test"   # 同じ定義
BaseModel  : foo='test'
BaseSettings: ValidationError -> Input should be a valid integer,
              unable to parse string as an integer [type=int_parsing, ...]

BaseModel はデフォルト値を検証せず素通しするのに対し、BaseSettings“Validation of default values” セクションの通り既定で検証します。設定クラスは起動時に一度作るだけなので、型の食い違いをその場で落とす方が安全という判断。緩めるなら validate_default=False です。バリデータの書き方そのものは Pydantic V2のバリデーション設計—よくあるミスと正しい書き方BaseModel 側の話としてまとめています。

まとめ

  • 優先順位は CLI引数 > init引数 > 環境変数 > .env > シークレットディレクトリ > デフォルト値。本番の環境変数が .env を上書きする前提で設計する
  • env_prefix を設定したら .env のキーにも接頭辞が要る。付け忘れは extra="forbid" でも検出できず黙って捨てられる
  • extra の既定は forbid.env に残った未定義キー1行で起動が止まる
  • env_file の相対パスはカレントディレクトリ基準。cron や systemd から実行するなら Path(__file__) を起点にする
  • サブモデルのデフォルトに値を持たせるなら nested_model_default_partial_update=True。既定では環境変数で触っていないフィールドの指定が消える
  • PYDANTIC_SETTINGS_DEBUG は 2.14.2 未収録。ソースクラスを直接呼べば同じ情報が取れる

ソースが増えるほど値の出どころは追いにくくなります。優先順位の6段階を頭に入れておけば、.env が反映されない原因は EnvSettingsSourceDotEnvSettingsSource を1回ずつ実行するだけで絞れます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次