Pydantic Settings Fundamentals

A BaseSettings subclass is the single object that should own every configuration read in a Python service. It pulls from the environment, applies types, runs validators, and either constructs cleanly or raises a ValidationError that stops the process before it serves traffic. This page builds that object correctly with pydantic-settings v2, and explains the reasoning behind every configuration choice so you can adapt it rather than copy it blindly.

The word “single” is the load-bearing part. In most codebases configuration accretes: one module reads os.environ["DATABASE_URL"], another calls os.getenv("REDIS_URL", "redis://localhost"), a third parses a YAML file, and a fourth hard-codes a timeout it means to make configurable “later”. Each read has its own idea of what the value’s type is, whether it is required, and what the default should be, and none of them agree. The BaseSettings model collapses all of that into one class where every setting is declared once — its name, its type, its default, and its validation rule — so there is exactly one answer to “what configuration does this service take?” and it is the class definition itself. Everything else on this page is detail in service of that one idea.

pydantic-settings is a separate package from pydantic, though they version together. pydantic gives you the validation engine — BaseModel, field types, Field() constraints, validators. pydantic-settings adds the one class those tools do not cover: BaseSettings, which knows how to go and find values in the environment, in a .env file, and in secret files before handing them to the pydantic engine to validate. If you have used BaseModel to validate an API request body, BaseSettings is the same machinery pointed at your process’s configuration sources instead of a JSON payload.

That framing also tells you when not to reach for BaseSettings. It is for the process-level configuration read once at startup — database URLs, worker counts, feature flags, credential handles. It is not for per-request data, which stays with BaseModel, and it is not a general key-value store you consult throughout a request. The distinction keeps the boundary clean: BaseSettings sources and validates the small, stable set of values that define how this process is configured, and everything that varies per request or per user flows through different types entirely. Conflating the two — stuffing request-scoped data into the settings object, or reading process config from a request model — is how the single-boundary property erodes.

Architectural positioning

This is the foundation of the type-safe validation section — it turns the raw configuration sources into a typed contract the rest of the application can depend on. Everything downstream — strict coercion, custom validators, and schema evolution — extends the model you build here.

Think of the model as a boundary with a very specific job: everything on its left is untrusted and loosely typed — strings that may be missing, misspelled, or malformed — and everything on its right is a typed, validated object the rest of your code can use without a single defensive check. That asymmetry is the entire value proposition. Once a value has passed through the model, no downstream function needs to ask “is this a valid integer?” or “did someone remember to set this?”; the type says it is an int and the fact that the object exists says it was set. Bugs in a well-built configuration layer concentrate at exactly one place — the boundary — and they all surface at the same time, at startup, rather than being scattered through the request path waiting to be triggered. That concentration is itself a debugging superpower: when configuration is wrong, you know precisely where to look and precisely when you will hear about it, instead of chasing a NoneType error six layers deep into a handler that turns out to trace back to an unset variable nobody validated.

Placing that boundary correctly is a design decision, not an accident. The model belongs at the edge of your application, constructed once as the process starts, and passed inward — never reconstructed deep in a call stack and never bypassed by a direct environment read. The pages that build on this one all assume that placement: strict coercion decides how the boundary treats ambiguous inputs, custom validators add domain rules to the boundary, and schema evolution changes the boundary’s shape safely over time. Get the boundary right here and the rest of the section is refinement.

BaseSettings is the boundary between raw strings and typed code Environment variables and a .env file are loose strings until a BaseSettings model validates them; get_settings caches the result and the whole application imports it as a typed contract. loose strings typed contract env vars + .env BaseSettings validate get_settings() whole app
One model is the boundary between loose environment strings and typed application code.

Secure implementation

# config/settings.py
from functools import lru_cache
from pydantic import SecretStr, Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        env_nested_delimiter="__",   # CACHE__HOST -> cache.host
        extra="forbid",              # unknown vars raise instead of being ignored
        case_sensitive=False,
    )

    database_url: str
    api_key: SecretStr               # masked in repr(), model_dump(), and logs
    workers: int = Field(default=4, ge=1, le=64)
    debug: bool = False


@lru_cache
def get_settings() -> Settings:
    return Settings()                # constructed once; raises at startup on error

Every line of that model_config earns its place. env_file=".env" tells the model where to look for a local file of KEY=value lines, used in development and quietly absent in production where real environment variables take over. env_file_encoding="utf-8" avoids the platform-dependent decoding surprises that bite when a .env contains a non-ASCII character on a machine with a different default encoding. env_nested_delimiter="__" is what lets a flat variable like CACHE__HOST populate a nested cache.host field, so a structured configuration tree can be hydrated from the flat key-value space that environments actually provide. case_sensitive=False matches the convention that environment variables are upper-case while your Python fields are lower-case, so DATABASE_URL fills database_url without a manual alias. And extra="forbid" is the one that turns silence into noise.

extra="forbid" makes a misspelled variable fatal. This is worth dwelling on because the default — "ignore" — is the source of a whole category of production incidents. With ignore, setting DATABSE_URL (a transposed typo) does nothing: the real database_url field falls back to its default or raises a “missing” error that sends you hunting for a variable you did set, while the misspelled one sits in the environment doing nothing. With forbid, that stray variable is itself the error, named explicitly, so the fix is obvious. The cost of forbid is that you must declare every variable your process legitimately reads — but that is a feature, because the model’s field list becoming the exhaustive, enforced inventory of your configuration is exactly the property that makes it trustworthy.

SecretStr keeps the API key out of any serialized output — repr(), model_dump(), log lines, and tracebacks all show ********** instead of the value, and reading it requires an explicit .get_secret_value() call that is greppable in review. lru_cache means the model is built exactly once and shared: the first call to get_settings() constructs and validates the object, every later call returns the cached instance, and because construction is where validation happens, a broken configuration fails on that first call rather than being re-validated (and re-failing) on every access. The cache also gives tests a clean seam — get_settings.cache_clear() forces a rebuild against a patched environment — which is why the cached-accessor shape is preferred over a bare module-level global in any codebase with a test suite.

Required versus optional fields

The presence or absence of a default is how you declare whether a setting is mandatory. database_url: str with no default is required: if no source provides it, construction raises a ValidationError naming the field, so the process refuses to start without a database. workers: int = Field(default=4, ge=1, le=64) is optional with a sane fallback and a validated range, so an operator who sets nothing gets four workers and an operator who sets WORKERS=200 gets a clear rejection rather than a machine that thrashes. This is a more expressive vocabulary than environment reads give you: os.getenv("WORKERS", "4") cannot tell the difference between “the operator chose 4” and “nobody set it”, and it certainly cannot reject 200 before that value reaches the code that would try to spawn two hundred workers. Model the requiredness in the type, and the field list doubles as documentation of which variables an operator must supply versus which they may leave to a default.

Where and when to construct the model

Two shapes expose the single instance, and the choice has consequences beyond style. A module-level settings = Settings() constructs at import time: the first import triggers validation, and a broken configuration makes the import itself fail — the strongest fail-fast, ideal for scripts and workers where you want the process to die before it does anything. The cached-accessor shape, get_settings() behind @lru_cache, defers construction to the first call, which is what web frameworks want: the object is built lazily, injected into request handlers as a dependency, and swappable in tests by clearing the cache. Pick module-level for batch jobs and CLIs; pick the accessor for anything with a request lifecycle or a test suite. A useful tie-breaker when you are unsure: if the code has tests that need to vary configuration, the accessor’s cache_clear() seam makes those tests clean, so default to the accessor and reserve the module-level global for the simplest scripts where import-time death is exactly the behaviour you want and there is no test suite to accommodate. What you must not do is construct the model repeatedly — building it per request re-reads and re-validates the environment on every call, turning a startup cost into a per-request one for no benefit.

Constructing the settings object step by step The model reads .env and environment values, coerces and validates each field, forbids unknown keys so a typo fails, and caches the validated result with lru_cache. read sources .env + env vars coerce + validate types + Field() forbid extras a typo fails here cache result lru_cache a missing or misspelled variable never reaches the cache — it stops here, at startup
A misspelled or missing variable stops construction at the forbid step, before the cache.

Configuration reference

The table below is the short list of SettingsConfigDict options that change the security posture of the model, not the exhaustive one. The pattern to notice is that the defaults are tuned for convenience — silent acceptance of extras, no nested delimiter, a .env in the working directory — and a production-grade model overrides several of them deliberately. Read each row as a decision you are making on purpose rather than a knob you can leave at its factory setting.

Two of these rows interact in a way worth spelling out. case_sensitive=False and env_nested_delimiter="__" together define how a flat, upper-case environment maps onto your typically lower-case, possibly nested model. With case-insensitivity on, DATABASE_URL matches database_url and CACHE__HOST matches cache.host without a single manual alias — which is what you want, because environment-variable convention is upper-snake and Python attribute convention is lower-snake, and fighting that mismatch by hand is pure toil. The one place case-sensitivity matters is if two of your fields differ only by case (rare and best avoided) or if you deploy to a platform with case-sensitive environment semantics that you must match exactly. For almost every service, case_sensitive=False is correct and the delimiter is set to whatever your nesting needs.

Setting Type Default Security implication
extra "forbid"/"ignore"/"allow" "ignore" "forbid" catches typos and injected junk
env_nested_delimiter str None Enables nested models from flat env vars
case_sensitive bool False Match your platform’s env-var casing
SecretStr field masked Prevents secret leakage in logs
env_file str None Local convenience; below real env vars
The four settings that matter most extra=forbid raises on unknown variables; env_nested_delimiter builds nested models from flat env vars; SecretStr fields mask credentials; env_file is a local convenience below real env vars. extra = "forbid" env_nested_delimiter SecretStr fields env_file unknown variables raise, not ignored flat env vars build nested models credentials masked in every output local convenience, below real env vars
Set these deliberately; the defaults favour convenience over safety.

Reading the source order with a worked example

pydantic-settings resolves a field from several sources in a fixed order, and the fastest way to internalise it is a single field set in more than one place. Suppose LOG_LEVEL has a field default of "INFO", appears as LOG_LEVEL=WARNING in the committed .env, and is exported as LOG_LEVEL=DEBUG in the shell before launch. The model returns DEBUG: the real environment variable outranks the .env file, which outranks the field default. Now unset the shell variable and the model returns WARNING, from the .env. Delete the .env line too and it returns "INFO", the default. Nothing about which value wins is ambiguous once you know the order — init arguments, then environment variables, then .env, then file secrets, then defaults — and every “I set the variable but the app ignored it” confusion resolves to a higher-priority source having already supplied the value.

This ordering is deliberately designed so that the more effort someone spent to set a value, the more it is respected: a variable exported for one run beats a file committed months ago, and an explicit constructor argument (which tests use) beats everything. It is also why the .env file is a development convenience and nothing more — in production it is absent, and the injected environment, sitting one rank above it, is where the real values live. If you need to change the order, add a source, or read from a parameter store, pydantic-settings exposes a settings_customise_sources hook, but the default order is correct for the overwhelming majority of services and you should have a specific reason before overriding it.

Deployment parity: local to production

  1. Local dev.env supplies values; get_settings() validates them on first call.
  2. CI — instantiate Settings() in a test; a missing or malformed key fails the build.
  3. Staging/Production — the orchestrator injects environment variables that outrank the (absent) .env; the identical model validates them at boot.

The reason this gives you real parity — rather than the hoped-for kind — is that the same class does the validating in all three places. There is no separate “production config loader” that could diverge from the development one; there is one Settings model, and the only thing that changes between environments is which source supplies the values. In development that source is the .env file; in production it is the injected environment, which sits above the .env in precedence so an absent file changes nothing. The CI step is the linchpin that makes the parity trustworthy: by constructing the model against each environment’s real variable set as a test, you prove before deploy that the configuration those variables express is one the model accepts. A variable that is missing in staging, or malformed in production, becomes a failed check on the pull request rather than a failed boot at 2 a.m.

This is also where the discipline of “no direct environment reads” pays off concretely. If one module reaches around the model with os.getenv, that read is invisible to the CI construction test — the test builds the model successfully, the deploy proceeds, and the un-modelled variable is missing in production, breaking a code path the test never exercised. Route every read through the model and the CI construction becomes a complete check: if the model builds, every variable the service needs is present and valid, because the model is the definitive list of what the service needs.

One model validates local, CI, and production Local development reads .env, CI constructs the model as a test, and production validates injected environment variables — all through the same Settings class. Local devCIStaging / Prod .env, validated on first callSettings() as a test one Settings class parity is structural
The same class validates every environment, so parity is structural, not hoped-for.

Security boundaries & guardrails

  • Always set extra="forbid"; silent acceptance of unknown variables hides configuration drift.
  • Wrap every credential field in SecretStr; assert in a test that repr(settings) contains no secret.
  • Keep one settings class per service — no per-environment subclasses with diverging fields.
  • Use SettingsConfigDict, not the deprecated inner class Config.

The subclass guardrail deserves the most emphasis because it is the one teams get wrong as they grow. The tempting anti-pattern is a DevSettings and a ProdSettings, each a subclass with slightly different fields — production adds a sentry_dsn, development drops the TLS requirement, and within a few months the two have quietly diverged into different schemas that are never tested against the same inputs. The correct shape is one Settings class whose values vary by environment while its shape does not: every environment must satisfy the same fields, and a value that is legitimately optional in one place is modelled as an optional field with a default, not as a field that exists in one subclass and not the other. Keeping the schema single is what lets the CI construction test above mean the same thing everywhere.

The SecretStr guardrail is only as strong as the test that enforces it. A field added later as a plain str — because the author forgot, or did not realise the value was sensitive — reintroduces the leak silently. A regression test that renders repr(settings) and model_dump() and asserts that no known credential substring appears turns that silent regression into a red build. It costs three lines and it is the difference between “we wrap secrets in SecretStr” being a convention people remember and a property the build enforces.

A typo'd variable under forbid and ignore The stray variable DATABSE_URL raises a ValidationError at startup under extra=forbid, but is silently dropped and hides configuration drift under extra=ignore. DATABSE_URL a typo'd variable extra=forbid raises at startup extra=ignore silently dropped drift stays hidden
Forbid turns a silent misconfiguration into a loud, early failure.

Troubleshooting

  • ValidationError at startup — a required field is missing or malformed; the message names the exact field. This is the intended fail-fast behaviour.
  • Nested model not populatedenv_nested_delimiter is unset or the variable uses the wrong delimiter (CACHE__HOST, not CACHE_HOST). See Nested Settings Models in Pydantic.
  • Secret printed in logs — the field is a plain str; change it to SecretStr.
  • Migrating from v1 — inner class Config and BaseSettings from pydantic moved; see Migrate to pydantic-settings v2.

The common thread in these symptoms is that pydantic-settings fails loudly and specifically, which is a feature you learn to read rather than a problem to suppress. A ValidationError at startup is not a bug in your model; it is the model doing its job, and its message contains the precise loc — the field name — and the reason, so the fix is almost always a one-line change to a variable or a default. The instinct to wrap Settings() in a try/except and fall back to defaults is exactly wrong: it converts a clear, actionable startup failure into a service that runs with unknown configuration and looks healthy. When you see one of these errors, read the field name in the message and fix the input; do not silence the messenger.

The nested-model symptom trips up almost everyone once. env_nested_delimiter defaults to unset, so a fresh model ignores the __ convention entirely and a CACHE__HOST variable simply becomes an unknown extra (raising under forbid, or silently dropped under ignore). Setting the delimiter is the fix, but the deeper lesson is that nested configuration is opt-in — the model does not guess that you meant a nested structure, so you declare it, both by nesting the sub-model and by enabling the delimiter that maps flat keys onto it.

The secret-in-logs symptom is the one to catch before it happens rather than after. By the time a credential appears in a log line, it has already been written to wherever those logs go — an aggregator, a file, a third-party service — and rotating the exposed secret is the only real remediation, because you cannot un-log it. That is why the fix is preventive: type the field as SecretStr from the start, and back it with the repr-and-model_dump assertion described above so a field added later as a plain str fails the build. Treat “a secret showed up in logs” not as a logging bug to patch at the log call, but as evidence that a field escaped the SecretStr net, and fix it at the model where the net belongs.

Finally, the v1-to-v2 migration symptom — deprecation warnings about the inner class Config, or import errors for BaseSettings — signals code written against the older API. In v2, configuration moved from an inner class Config to the model_config = SettingsConfigDict(...) attribute, and BaseSettings moved out of pydantic into the separate pydantic_settings package. Both changes are mechanical but easy to miss when following an older tutorial; the dedicated migration page walks the full set of renames so a legacy config layer lands cleanly on the v2 idioms this page assumes throughout.

Startup symptoms and their one-line fixes A ValidationError means supply the named field; an empty nested model means set env_nested_delimiter; a secret in logs means change str to SecretStr; a deprecation warning means use SettingsConfigDict. Symptom Fix ValidationError at startup supply the named field nested model empty set env_nested_delimiter secret printed in logs change str to SecretStr deprecation warning on Config use SettingsConfigDict
Each startup symptom maps to a one-line configuration fix.

Frequently asked questions

How do I configure a BaseSettings model in pydantic v2?

Use model_config = SettingsConfigDict(...) on the class. The legacy inner class Config from v1 still partly works but raises deprecation warnings; SettingsConfigDict is the supported v2 API.

In what order does pydantic-settings read sources?

By default, init arguments win, then OS environment variables, then the .env file, then file secrets. Environment variables outrank the .env file, giving you correct production parity automatically.

How do I reject unknown environment variables?

Set extra="forbid" in SettingsConfigDict. A typo’d or stray variable then raises a ValidationError at startup instead of being silently ignored.

Key takeaways

The invariant: one BaseSettings model, extra="forbid", secrets as SecretStr, constructed once and validated at startup. Everything else in this section extends this object — strict coercion tightens its types, custom validators add domain rules, and nested models group related fields.

If you build only this much and nothing else in the section, you have already eliminated the largest class of configuration failures: the misspelled variable that was silently ignored, the missing value that surfaced as a KeyError mid-request, the secret that leaked into a log, and the “works on my machine” divergence between how development and production read their config. Those four are not exotic edge cases — they are the everyday failures that a typed, validated, single settings model makes structurally impossible rather than merely unlikely. The field list is your enforced configuration inventory, forbid catches drift, SecretStr closes the leak channels, and the shared class guarantees parity.

From here, each dedicated page tightens one facet without changing this foundation. When a field must reject a plausible-but-wrong string — a boolean that must not accept "maybe", an integer that must not accept a float — reach for strict mode. When a value must match a domain shape — an ARN, an HTTPS URL, a key prefix — reach for custom validators. When related settings want grouping — a whole cache or database block — reach for nested models. And when the schema itself must change in a running system, reach for schema evolution. Each is a refinement of the boundary you built here, not a replacement for it.