Pydantic v1 to v2 settings migration
Upgrading pydantic to v2 moves BaseSettings to a new package, replaces the inner Config class, and renames the validator decorators. Miss one and the app raises at import. This page is the field-by-field migration, extending Pydantic Settings Fundamentals.
The good news is that the pydantic v2 settings migration is mechanical: a bounded list of renames and one package install, with almost no behaviour to reason about because v2 deliberately preserved the lenient environment-string coercion that settings code depends on. The work is finding every v1 idiom and swapping it for its v2 equivalent, then proving the result produces the same validated object the old code did. Because the failures are loud — an ImportError at import, a TypeError from a validator with the wrong signature — you tend to find the remaining v1 code by running the app rather than by auditing it line by line, and the migration map below is the lookup table for each thing you hit as you go. Treat it as a checklist to work through, not a rewrite to design. And because the same renames recur across pydantic v2 migrations everywhere, the muscle memory you build on your settings model transfers directly to migrating the rest of your BaseModel code — the settings layer is simply the highest-stakes place to get it right, since every process depends on it booting.
Problem 1: the import moved
# ANTI-PATTERN (v1): BaseSettings no longer lives in pydantic
from pydantic import BaseSettings # ImportError under pydantic v2
In v2, BaseSettings moved to the separate pydantic-settings package. This was a deliberate split: the core pydantic package now contains only the validation engine, while the settings-specific machinery — reading from the environment, .env files, and secret directories — lives in its own package that depends on pydantic. The practical consequence is that a v1 project which only ever pip install pydantic and imported BaseSettings from it now needs an explicit pip install pydantic-settings and a changed import. The error is unambiguous: ImportError: cannot import name 'BaseSettings' from 'pydantic', raised at import time, so the app will not even start until you fix it — which is exactly the kind of loud, early failure you want from a migration.
Pin the new package while you are here. Add pydantic-settings to your requirements with a version constraint alongside pydantic itself, because the two version together and an unpinned pair can drift into an incompatible combination on a future install. Pinning both, and testing the pair explicitly, means a later dependency bump cannot silently change how your settings coerce or validate — the one behaviour you least want to move underneath a running service. A pinned, tested pair also makes the migration reproducible for teammates and CI: everyone installs the exact same versions, so “works on my machine” cannot creep in through a floating dependency that resolved differently on someone else’s checkout.
Problem 2: v1 Config and validators
# ANTI-PATTERN (v1): inner Config + @validator
class Settings(BaseSettings):
class Config: # -> SettingsConfigDict
env_file = ".env"
@validator("port") # -> @field_validator + @classmethod
def check(cls, v): ...
The inner Config class and @validator are gone in v2. The inner class Config — where v1 put env_file, case_sensitive, and the rest — becomes a model_config = SettingsConfigDict(...) attribute. The option names are largely the same, so the change is structural rather than semantic: the same keys move from class-body attributes into a SettingsConfigDict call. v2 still tolerates an inner Config in some cases but emits a deprecation warning and will eventually stop, so treat any remaining class Config as unfinished migration.
The validator change has a subtlety beyond the rename. @validator becomes @field_validator, but v2 also requires the method to be an explicit @classmethod — the decorator no longer makes it one implicitly — so a v1 validator copied across without adding @classmethod fails with a confusing signature error that does not obviously point at the missing decorator. Similarly, @root_validator, which validated the whole model in v1, becomes @model_validator(mode="after") (or mode="before" for pre-coercion work), and the method now receives and returns the model instance rather than a dict of values. These are the two validator idioms most likely to bite, because the code looks almost right — the decorator name is close and the body is unchanged — but the required @classmethod and the changed signature make the difference between a validator that runs and one that raises. When you migrate a validator, add @classmethod immediately below the @field_validator line as a reflex, and for anything that was a @root_validator decide explicitly whether it needs mode="before" (to normalise raw input before coercion) or mode="after" (to check the assembled, typed model), because v1’s single root validator did not force that choice and v2 does.
Secure implementation
# config/settings.py — v2
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict # new package
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="forbid") # replaces Config
port: int = 8080
@field_validator("port") # replaces @validator
@classmethod
def check(cls, v: int) -> int:
if not 1 <= v <= 65535:
raise ValueError("port out of range")
return v
Migration map
| pydantic v1 | pydantic v2 |
|---|---|
from pydantic import BaseSettings |
from pydantic_settings import BaseSettings |
inner class Config |
model_config = SettingsConfigDict(...) |
@validator |
@field_validator + @classmethod |
@root_validator |
@model_validator |
.dict() / .json() |
.model_dump() / .model_dump_json() |
.parse_obj() |
.model_validate() |
Work down that table as a find-and-replace list, but read two rows carefully. Field(env="X") — the v1 way to point a field at a differently-named environment variable — is gone; in v2 you express the same thing with validation_alias (or AliasChoices when a field should accept more than one name, which is also the mechanism for a zero-downtime rename during a migration window). And the .dict()/.json() methods became .model_dump()/.model_dump_json(), which matters for settings because any code that serialized the config — to snapshot it, to diff it across environments, to log a redacted view — calls those methods and will AttributeError until updated. The parse_obj/model_validate rename matters less for settings specifically, since you usually construct via Settings() rather than from a dict, but update it anywhere your tests build the model from a literal dictionary. A quick grep across the codebase for .dict(, .json(, .parse_obj(, and class Config before you start gives you the exact list of sites to change and turns the migration from an open-ended hunt into a finite worklist.
Gotchas & version-specific behaviour
- Install
pydantic-settingsexplicitly; v2’spydanticno longer bundlesBaseSettings. Field(env="X")is replaced byvalidation_alias/AliasChoicesin v2.- v2 keeps lenient env-string coercion, so
"false"still parses toFalsefor bool fields. - Run with
-W errorto surface deprecation warnings the upgrade introduces.
The coercion-stays-lenient point is the reassuring one and deserves emphasis, because it is where people expect a v2 upgrade to break their settings and it usually does not. For BaseSettings, v2 keeps the environment-friendly behaviour: "8080" still coerces to 8080 for an int field, and "false", "0", and "no" still parse to False for a bool. That is intentional — environment variables are always strings, so a settings model that refused to coerce them would be useless. The stricter behaviour some blog posts warn about applies to BaseModel validating structured data, not to BaseSettings reading the environment, so your existing variables keep parsing as they did. If you want a stricter boundary for a particular field, you opt into it with strict=True, but nothing forces it on you during the migration, so an upgrade never silently tightens a field you did not ask it to.
One genuine behaviour change to check is Optional. In v1, Optional[str] implied a default of None; v2 no longer treats an Optional annotation as automatically defaulting, so x: Optional[str] with no assignment becomes a required field that happens to accept None — which is probably not what the v1 code meant. Add the explicit = None (x: Optional[str] = None) wherever a v1 optional field relied on the implicit default, or the migration will turn previously-optional settings into required ones and fail at startup for anyone who did not set them. Running with -W error during the upgrade turns the deprecation warnings that flag much of this into hard failures, so you find every site while you are actively migrating rather than in production weeks later.
Production parity checklist
pydantic-settingsis a pinned dependency.- All
Configclasses replaced withSettingsConfigDict. - Validators renamed to
field_validator/model_validator. .dict()/.parse_obj()replaced withmodel_dump/model_validate.- A parity test compares v2 output against the v1 baseline before cutover.
Staging the rollout
For a small service the whole migration is a single pull request: change the imports, swap the config block and validators, update the dump calls, run the tests, ship. For a larger codebase — or one where BaseModel and BaseSettings are entangled with other libraries that themselves pin pydantic — stage it. First, get the dependency install and the import move in place so the app starts under v2 at all; that alone surfaces the ImportError and forces the package pin. Next, sweep the class Config blocks and the validators, running with -W error so every deprecated idiom becomes a hard stop you fix as you find it. Then update the serialization calls (model_dump, model_validate) that your tests and any config-snapshotting code use. Only once the app boots clean and the tests pass do you add the parity assertion and delete the v1 compatibility.
The dependency-graph question is the one that most often complicates the plan. If another library in your tree requires pydantic v1, you cannot simply bump — you either wait for that library’s v2 support or use a compatibility shim during a transition. Check your lockfile for pydantic pins before starting, because discovering a hard v1 dependency halfway through a migration is the difference between a clean afternoon and a stalled branch. Where the graph is clean, the migration is genuinely quick; where it is not, the blocker is almost always a third-party pin rather than your own code, and knowing that up front lets you sequence the work realistically.
The parity test earns its keep precisely because the migration is mechanical. Construct both the v1 and v2 models against the same environment in a throwaway script or a temporary test, dump each with the version-appropriate method, and diff the results field by field. Anything that differs is a rename you missed or a behaviour you did not expect — an Optional that flipped to required, an alias that redirected a field, a validator that silently stopped running because it lacked @classmethod. Green parity is your signal that the v2 model is a faithful replacement, at which point removing the v1 path is safe rather than hopeful. Until it is green, keep both paths alive; the small cost of a temporary compatibility shim is far cheaper than a cutover that breaks a service which cannot start without valid configuration.
Key takeaways
Move the import, swap Config for SettingsConfigDict, rename the validators, and update the dump/parse calls — then verify parity before deleting v1 code. The parity step is the one that makes the cutover safe: before you rely on the v2 model, construct it against a realistic environment and assert its model_dump() matches the value the v1 model produced for the same inputs. Because the migration is a set of renames rather than a redesign, a mismatch means you missed a rename — an un-migrated validator that no longer runs, an Optional that flipped from optional to required, an alias that changed a field’s source — and the diff points straight at it. Keep the v1 code path available behind a flag until that parity test is green, then delete it in a separate change.
The reason to do this deliberately rather than in a rush is that a settings model is load-bearing: every process depends on it constructing correctly, so a botched migration is not a subtle bug but a service that will not boot. Fortunately the loudness that makes v2 migration annoying — import errors, signature errors, deprecation warnings — is also what makes it safe, because none of it fails silently. Work the map, run with -W error, prove parity, and the upgrade is boring in the best sense. For migrating off a non-pydantic parser entirely, see Migrate to pydantic-settings v2.