Migrating from legacy config parsers to pydantic-settings v2

Most Python services start with configparser or a pile of os.environ.get calls and only later wish every value were typed and validated. Moving to pydantic-settings v2 is mechanical if you do it incrementally. This page is the migration path, extending Pydantic Settings Fundamentals.

Problem 1: scattered, untyped reads

# ANTI-PATTERN: config spread across modules, all strings, no validation
import os
DEBUG = os.environ.get("DEBUG")            # the string "False" — truthy!
PORT = int(os.environ.get("PORT", "8080")) # ValueError surfaces wherever this runs

There is no single schema and no validation; every module re-parses and re-mistakes the same values.

Problem 2: pydantic v1 idioms that broke in v2

# ANTI-PATTERN: v1 style that warns or fails under pydantic v2
from pydantic import BaseSettings        # moved to pydantic_settings in v2
class Config:                            # inner Config class -> SettingsConfigDict
    env_file = ".env"
@validator("port")                       # -> @field_validator
def v(cls, x): ...

BaseSettings moved packages, the inner Config class became SettingsConfigDict, and @validator became @field_validator.

Secure implementation

# config/settings.py — the v2 target
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(     # replaces inner class Config
        env_file=".env", extra="forbid",
    )
    debug: bool = False                    # "False"/"0"/"no" correctly parsed
    port: int = 8080

    @field_validator("port")               # replaces @validator
    @classmethod
    def valid_port(cls, v: int) -> int:
        if not 1 <= v <= 65535:
            raise ValueError("port out of range")
        return v

settings = Settings()                      # one object; validates at startup

Run it alongside the old code first: build Settings() at startup and assert it matches the legacy values, then delete the old reads module by module.

Gotchas & version-specific behaviour

  • Import BaseSettings from pydantic_settings, not pydantic, in v2.
  • Replace inner class Config with model_config = SettingsConfigDict(...).
  • @validator@field_validator (+ @classmethod); @root_validator@model_validator.
  • .dict().model_dump(); .parse_obj().model_validate().
  • v2 keeps the lenient env-string coercion, so booleans like "false" parse correctly — unlike bool(os.environ[...]).

Production parity checklist

  • One Settings object replaces all scattered reads.
  • extra="forbid" is set so typos fail loudly.
  • A transition test asserts new values equal the legacy ones before old code is removed.
  • CI pins pydantic and pydantic-settings v2 and runs with warnings as errors.
  • Secrets are moved to SecretStr fields during the migration.

Conclusion

Stand up the v2 Settings object beside the legacy parser, verify parity, then remove the old reads incrementally. For the v1-to-v2 API specifics in depth, see pydantic v1 to v2 settings migration.