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.

The migration this page describes is not a version bump — it is replacing a whole style of configuration. A legacy config layer is usually some mix of an .ini file read by configparser, a scattering of os.environ.get calls made across many modules, and a hand-rolled helper or two that coerce strings into the types the rest of the code needs. None of it validates, none of it agrees on defaults, and the string-to-type coercion is duplicated and subtly inconsistent everywhere it appears. The target is a single typed Settings model that does all of that once, at startup, and refuses to run when the configuration is wrong. The safe way to get there is the strangler pattern: stand the new model up beside the old parser, prove they agree, and remove the legacy reads one module at a time — never a big-bang rewrite of a system every process depends on. (If your starting point is specifically pydantic v1 rather than a non-pydantic parser, the sibling page on the v1-to-v2 API changes covers the exact decorator and method renames in depth; this page is about escaping configparser and raw os.environ.)

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. Two classic bugs live in that snippet. os.environ.get("DEBUG") returns the string "False" when someone sets DEBUG=False, and a non-empty string is truthy in Python, so if DEBUG: runs the debug path in production — the exact inversion the operator intended. And int(os.environ.get("PORT", "8080")) raises a ValueError not at startup but wherever that line first executes, which might be deep inside a request handler, so a malformed PORT surfaces as a 500 mid-traffic instead of a refusal to boot — and only for the request unlucky enough to be the first to reach that code path. configparser has its own version of these: every value it returns is a string, so the same manual int(...) and truthiness mistakes reappear, just sourced from an .ini file instead of the environment.

The deeper problem is duplication. Because there is no single place that owns “what type is PORT?”, every module that reads it re-answers the question, and they drift: one does int(...), another forgets and compares a string, a third adds a default the others do not have. The configuration’s shape is implicit, scattered, and inconsistent — which is precisely the state a single validated model eliminates by making the field list the one authoritative schema that every module reads and none of them re-derives. Once that schema exists, “what configuration does this service take?” has exactly one answer — the class definition — instead of being an archaeology exercise across a dozen files.

Configuration scattered across modules with no schema Three modules each call os.environ.get independently and re-parse the same values untyped, with no single schema, so the same mistakes repeat everywhere. auth.pydb.pyweb.py os.environ.get — untypedos.environ.get — untypedos.environ.get — untyped no single schema — the same mistakes repeat everywhere
Without one schema, 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. If your legacy layer already used pydantic v1 BaseSettings in places, you will hit these renames during the migration and the target Settings class must use the v2 forms. This page keeps the summary short because the sibling page walks the full rename table — including the .dict()/model_dump() and @root_validator/@model_validator changes and the Optional default subtlety — in depth. The point to carry here is only that your migration target is idiomatic v2: pydantic_settings for the import, SettingsConfigDict for the config block, and @field_validator with an explicit @classmethod for any field rule you bring across from the old code.

Three pydantic v1 idioms and where they moved in v2 In v2, BaseSettings moves from pydantic to pydantic_settings, the inner Config class becomes SettingsConfigDict, and the validator decorator becomes field_validator. pydantic v1 pydantic v2 from pydantic import BaseSettings from pydantic_settings inner class Config SettingsConfigDict @validator @field_validator
Each v1 idiom has a direct v2 replacement; the migration is mechanical.

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. This parallel-run is the heart of a safe legacy migration. On the first pass you do not remove a single os.environ.get — you add the Settings model, construct it at startup, and write a check that compares each new field against the legacy read it replaces (assert settings.port == int(os.environ.get("PORT", "8080"))). While that check passes, the new model is proven to reproduce the old behaviour exactly, so you can start routing individual modules to settings.port with confidence, deleting the corresponding legacy read as you go. When the last legacy read is gone, so is the parity check, and the model is the sole source of configuration.

The reason to migrate module by module rather than all at once is blast radius. A configuration layer is touched by every part of the service, so a big-bang rewrite puts the whole system’s ability to boot on one large, hard-to-review change. Converting one module at a time keeps each change small and independently verifiable — auth reads from the model, ships, is confirmed healthy; then the database module; then the web layer — so a mistake is caught in a small pull request rather than discovered when nothing starts. The strangler pattern trades a little more elapsed time for a lot less risk, which is the right trade for something as load-bearing as configuration. It also keeps the branch shippable at every step: because each module’s cutover is a small, self-contained change that leaves the parity check green, you can merge and deploy continuously rather than maintaining a long-lived migration branch that drifts from main and becomes its own source of conflicts.

Run the v2 model beside the legacy parser, then remove the old code The legacy parser and the new Settings model run in parallel; a parity test asserts they agree; once they match, the legacy reads are removed module by module. legacy parser configparser / os.environ Settings (v2) typed + validated parity test values must agree remove legacy module by module
Prove parity before deleting a single line of the old parser.

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[...]).

Migrating specifically off configparser has a few wrinkles the environment case does not. configparser organises values into sections ([database], [cache]), which map naturally onto nested models — a [database] section with host and port keys becomes a DatabaseConfig sub-model — so the migration is also an opportunity to give the flat .ini structure real types. Watch for two configparser features that have no direct model equivalent: its DEFAULT section, whose values fall through to every other section, and its interpolation syntax (%(base)s), which computes one value from another at read time. Neither maps cleanly, so resolve them during the migration — fold DEFAULT values into explicit field defaults, and replace interpolation with a validator or a computed property — rather than trying to preserve the parser’s behaviour inside the new model where it does not belong. And remember that configparser returns everything as a string, including booleans and numbers, so the model’s coercion is doing exactly the work your old getboolean/getint calls did, now in one place and validated once at startup rather than re-implemented at every call site.

The subtle difference between os.environ.get("X") and a field on the model is the swallowed default. The legacy call returns None (or a supplied fallback) for a missing variable, so the absence is silent; the model raises a ValidationError naming the field for a missing required value, turning a silent gap into a loud one. During migration this can surface variables that were “optional” only because nobody noticed they were unset — which is a feature, not a regression: model them as required if the service genuinely needs them, or give them an explicit default if it does not, but decide, rather than letting None propagate.

A v1-to-v2 rename cheat sheet Reference pairs: BaseSettings comes from pydantic_settings; class Config becomes SettingsConfigDict; validator becomes field_validator; .dict becomes .model_dump; .parse_obj becomes .model_validate. v1 v2 pydantic.BaseSettingsclass Config:@validator.dict() / .parse_obj() pydantic_settings.BaseSettingsSettingsConfigDict(...)@field_validator + @classmethod.model_dump() / .model_validate()
Keep this mapping handy; every rename is one-for-one.

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.

The secrets item is the one migration teams most often defer and most benefit from doing now. A legacy config layer almost always reads credentials as plain strings — DB_PASSWORD = os.environ["DB_PASSWORD"] — which means that password is one careless log line or one print(config) away from a leak, and in the scattered style there are many such lines. Retyping those fields as SecretStr while you migrate closes every one of those channels at once: the value is masked in repr(), model_dump(), and tracebacks, and reading it now requires an explicit .get_secret_value(). Because you are touching each configuration read anyway, the migration is the cheapest possible moment to make this change, and doing it later means revisiting all the same files a second time.

Choosing what to migrate first

Not all configuration is equally risky to move, so sequence the migration by blast radius. Start with the values that are read in exactly one place and have no security weight — a timeout, a log level — because getting them wrong is cheap and they build your confidence in the parallel-run harness. Move the widely-read values next, one module at a time, keeping the parity check green throughout. Save the credentials for a focused pass where you both route the read through the model and retype the field as SecretStr, so the highest-stakes values in the whole system get the most careful attention. Leaving credentials for last also means the parity harness is well-exercised by the time you touch them, so the one migration where a mistake matters most runs on the machinery you trust most.

A useful discovery step before any of this is to grep the codebase for os.environ, os.getenv, and configparser to enumerate every configuration read. That list becomes your worklist and your definition of done: the migration is complete when the grep returns nothing but the one module that constructs the Settings object, and any new occurrence added later is a review flag that someone is drifting back toward the scattered style you just retired. Turning an open-ended “find all the config reads” into a finite, checkable list is what keeps a strangler migration from dragging on indefinitely or quietly missing a read that then breaks in production.

Migration production-parity checklist One Settings object replaces scattered reads; extra=forbid is set; a transition test asserts parity; CI pins v2 with warnings as errors; secrets move to SecretStr fields. One Settings object replaces all scattered reads extra="forbid" set so typos fail loudly Transition test asserts parity before old code is removed CI pins pydantic v2 and runs with warnings as errors Secrets moved to SecretStr fields during the migration
Five checks that make the cutover safe and irreversible only when parity holds.

Key takeaways

Stand up the v2 Settings object beside the legacy parser, verify parity, then remove the old reads incrementally. The migration is worth doing precisely because of what the legacy layer cannot give you: a single schema, validation at startup, typed values, and secrets that cannot leak. Each of those is a property the scattered os.environ.get and configparser reads structurally lack, and each becomes true the moment every read routes through one model. The extra="forbid" setting turns the migration into an ongoing guard — once the model owns configuration, a stray variable or a typo fails the build rather than sitting unnoticed as it did in the untyped world.

Treat the parity test as the safety rail that lets you move fast without fear. As long as it is green, the new model provably reproduces the old behaviour, so deleting legacy reads is mechanical and reversible; the day it goes red, it points at the exact value that diverged. Migrate a module, keep the test green, delete the old read, repeat — and when the last legacy call is gone you have replaced an implicit, inconsistent configuration style with an explicit, validated one, without a single risky flag day. For the v1-to-v2 API specifics in depth, see pydantic v1 to v2 settings migration.