Configuration Management in FastAPI

Configuration management is the practice of collecting every environment-specific input a service needs — endpoints, credentials, limits, feature switches — into one typed object that is validated in a single pass before the application accepts traffic, and then delivered to the code that needs it rather than fetched by it.

Configuration is the input to the application factory: the factory takes a validated settings object and builds an application around it, which is what makes the same code deployable to several environments without branching. It reaches handlers through dependency injection like everything else, which is what makes it substitutable under test. This page sits inside Core Architecture and Routing Patterns and covers the model that makes those properties hold; the specific mechanics have their own guides.

Prerequisites

You need pydantic-settings installed and a service you can restart. The transcripts below were produced on Pydantic 2.13.4 with FastAPI 0.139.2 on Python 3.12, and set their environment explicitly in-process so the recorded behaviour is reproducible rather than dependent on a developer's shell.

Configuration as a gate in front of the applicationFour sources in increasing precedence order feed one validation pass. A valid result becomes the settings object the factory builds around; an invalid result stops the process before it serves traffic.sources, low to high precedencefield defaults.env fileprocess environmentinit argumentsone validation passall errors reported togethervalidfactory builds appinvalidprocess exitsthe gate runs once, before the first requesta bad value stops a deploy instead of a request
Sources are merged in precedence order and validated together, so a deployment either starts with a complete, well-typed configuration or does not start.

Core mechanics: a model, not a lookup

The important thing pydantic-settings does is not reading environment variables — os.environ does that. It is that configuration becomes a model, and a model has properties a collection of reads does not.

Sources are consulted in precedence order and merged into a single dictionary, and that dictionary is validated in one pass. Two consequences follow. Every problem is reported together rather than one restart at a time, and by the time validation runs there is no distinction between a value that came from a file and one that came from the process environment — precedence is resolved before typing begins.

Validation is over the whole object, not per field. A model can therefore express constraints that span fields, such as requiring a real credential when the environment is production, which no per-read cast can represent. And because the object either constructs completely or raises, there is no state in which some values are validated and others are not.

The result is that configuration failure moves from request time to boot time. That relocation is the entire argument, and it is what the alternatives are measured against in pydantic-settings vs Dynaconf vs python-decouple.

Production implementation: the boot gate

Here is a settings model with real constraints, constructed from a deliberately broken environment and then from a good one. Nothing is caught and reformatted; this is what the process itself reports.

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="SECARCH_", env_file=None, extra="ignore")

    environment: str = Field(pattern="^(development|staging|production)$")
    database_url: PostgresDsn
    signing_key: SecretStr = Field(min_length=32)
    worker_count: int = Field(default=4, ge=1, le=64)


BROKEN = {
    "SECARCH_ENVIRONMENT": "prod",              # not one of the three accepted spellings
    "SECARCH_DATABASE_URL": "localhost:5432",   # no scheme, so not a PostgresDsn
    "SECARCH_SIGNING_KEY": "too-short",         # under the minimum length
    "SECARCH_WORKER_COUNT": "0",                # below the floor
}

Executed:

$ GET /broken-environment
200 OK
{
  "booted": false,
  "error_count": 4,
  "errors": [
    {
      "field": "environment",
      "type": "string_pattern_mismatch",
      "msg": "String should match pattern '^(development|staging|production)$'"
    },
    {
      "field": "database_url",
      "type": "url_scheme",
      "msg": "URL scheme should be 'postgres', 'postgresql', 'postgresql+asyncpg', 'postgresql+pg8000', 'postgresql+psycopg', 'postgresql+psycopg2', 'postgresql+psycopg2cffi', 'postgresql+py-postgresql' or 'postgresql+pygresql'"
    },
    {
      "field": "signing_key",
      "type": "too_short",
      "msg": "Value should have at least 32 items after validation, not 9"
    },
    {
      "field": "worker_count",
      "type": "greater_than_equal",
      "msg": "Input should be greater than or equal to 1"
    }
  ]
}

Every fault is named in one report, with a machine-readable type and the field it belongs to. An operator fixing a deployment manifest gets the complete list on the first attempt instead of discovering the next problem after each redeploy. Note also what the pattern constraint bought: prod is a perfectly ordinary string, and without the constraint the service would have started and then behaved as neither production nor development wherever it branched on that value.

The successful construction is where a subtler lesson lives:

$ GET /good-environment
200 OK
{
  "booted": true,
  "repr": "Settings(environment='production', database_url=PostgresDsn('postgresql://api:pw@db.internal:5432/orders'), signing_key=SecretStr('**********'), worker_count=8)",
  "signing_key_is_readable_when_asked": true
}

signing_key is masked because it was typed SecretStr. The database password is not, because PostgresDsn is a URL type with no notion of sensitivity — and a connection URL is one of the most common places a credential actually lives. Any code that logs the settings object at startup, or includes it in an error report, publishes that password. The masking you get is exactly the masking you asked for, field by field, and the practical handling of that — including which values belong in a file at all — is covered in secrets and .env files per environment.

Production implementation: slicing settings per concern

A single flat settings object becomes a shared global that every module reaches into. Composing it from nested models, and injecting the slice a handler needs, keeps the blast radius small.

class DatabaseConfig(BaseModel):
    host: str = "localhost"
    port: int = Field(default=5432, ge=1, le=65535)
    pool_size: int = Field(default=10, ge=2, le=50)


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="SLICE_", env_nested_delimiter="__", env_file=None, extra="ignore"
    )
    service_name: str = "orders"
    database: DatabaseConfig = DatabaseConfig()
    security: SecurityConfig


def get_database_config(
    settings: Annotated[Settings, Depends(get_settings)],
) -> DatabaseConfig:
    # The narrowing dependency. Handlers below it cannot reach security config at all.
    return settings.database

The run:

$ GET /pool-status
200 OK
{
  "received_type": "DatabaseConfig",
  "host": "db.internal",
  "pool_size": 24,
  "port_kept_its_default": true,
  "fields_visible_to_this_handler": [
    "host",
    "pool_size",
    "port"
  ]
}

$ GET /nested-failure
200 OK
{
  "booted": false,
  "errors": [
    {
      "loc": "database.pool_size",
      "msg": "Input should be less than or equal to 50"
    },
    {
      "loc": "security.signing_key",
      "msg": "Value should have at least 32 items after validation, not 5"
    }
  ]
}

Three things are worth taking from that. The handler receives a DatabaseConfig and nothing else, so the signing key is not merely discouraged but genuinely unreachable from that code path. Supplying only some nested values leaves the rest on their defaults, so a partial override is safe. And failures report a dotted location such as database.pool_size, which maps directly back to the environment variable that produced it once you know the nesting delimiter — the naming rules that connect the two are set out in managing environment variables with Pydantic settings.

What belongs in configuration at all

Before deciding how to load configuration, it is worth being strict about what qualifies. Three kinds of value get filed together and want different treatment.

Genuine configuration is a value that differs between deployments of the same code and is fixed for the life of a process: a database endpoint, a queue name, a pool size, an external service's base URL, a credential. These belong in the settings model, and the test for membership is simple — if staging and production would set it differently, it is configuration.

Constants are values that are the same everywhere. A retry ceiling, a page size limit, a currency rounding rule. Putting these in the settings model looks harmless and quietly costs you: every one becomes something an operator can set, so every one becomes something you must validate, document and defend, and the set of environment variables grows until nobody knows which ones matter. Leave a constant in code where it is reviewable, and promote it only when a deployment genuinely needs to differ.

Runtime data is anything expected to change while the process is running — feature switches, per-tenant limits, kill switches. These are frequently mistaken for configuration because they look like small scalar values, but a settings model is built once and cannot represent them. Trying to make one reload turns a well-defined boot gate into a source of inconsistency, where different workers hold different values with no way to tell which. Read them from a store at request time with a short cache instead, and accept that they need their own validation.

The environment-specific half of the split has a further wrinkle worth naming. A value can differ between environments and still not be secret — a hostname, a log level, a bucket name — and those are exactly what a checked-in-by-example, deployed-as-a-file .env is good for. Credentials are different data with different handling requirements, and mixing the two in one file means the file inherits the stricter requirement while getting the looser treatment. Keeping the description of an environment separate from the credentials for it is the practical division set out in secrets and .env files per environment.

Async and performance notes

The settings object should be built once per process. Wrapping the accessor in functools.lru_cache achieves that and makes every subsequent resolution a dictionary lookup, which matters because a dependency that reconstructs settings would re-read files and re-run validation on every request — filesystem work on the hot path in exchange for nothing.

The cache has a testing consequence worth knowing before it surprises you: once the accessor has been called, changing an environment variable has no effect until the cache is cleared. Any fixture that manipulates the environment must call cache_clear(), and any test that would rather not think about it should override the dependency instead.

Freezing the model is worth the keystrokes. Configuration that can be mutated at runtime makes questions about what a process was actually running unanswerable after the fact, and a frozen model turns an accidental write into an immediate error at the line that attempted it rather than a puzzle days later.

Testing strategy

Because settings arrive through the injection graph, tests substitute them the same way they substitute anything else — no environment manipulation, no subprocess, no monkeypatching of module globals:

def test_signup_disabled_when_flag_is_off(app, client):
    app.dependency_overrides[get_settings] = lambda: Settings(
        environment="test",
        database_url="postgresql://u:p@localhost/test",
        signing_key="k" * 40,
        enable_signup=False,
    )
    assert client.post("/signup", json={"email": "a@b.c"}).status_code == 403
    app.dependency_overrides.clear()

Two further tests earn their place. Assert that the model rejects a known-bad environment, so the constraints themselves are covered rather than merely present — a pattern or ge that was never exercised is a comment. And assert that your example environment file constructs the model successfully, which keeps the documented template honest as fields are added; a stale example is the reason a new engineer's first day goes badly.

One more test is worth writing once and never thinking about again: assert that the factory refuses to build an application when a required value is absent. It is the only test that covers the boot gate as a mechanism rather than covering individual constraints, and it fails loudly if somebody later adds a default to a field that should not have one — which is the single change most likely to convert a loud deployment failure into a quiet production one.

A note on what these tests are protecting. The value of a boot gate is entirely in the constraints you put on fields, and constraints decay: a field added under time pressure gets no ge, no pattern, no minimum length, and the gate silently stops covering it. Reviewing a settings model periodically for fields that accept anything is more productive than adding more tests around the ones that already have constraints.

Failure modes and diagnosis

A misspelt variable is silently ignored. extra="forbid" rejects unknown keys found in a .env file but does not see unknown process environment variables, so it will not catch a typo in a deployment manifest — the case that matters most. Add an explicit startup check that scans the environment for your prefix and compares against the model's field names.

A secret appears in the logs. Something printed the settings object, and the value was not typed SecretStr — most often a credential inside a URL. Audit which fields are genuinely masked, and prefer separate host, user and password fields where a credential would otherwise be embedded in a string.

Settings differ between the app and a worker. Two processes read different sources, usually because one loads a file the other does not. Make the environment the authority in deployed environments and treat files as a local convenience.

Configuration changes have no effect in tests. The cached accessor is holding the first constructed instance. Clear the cache in the fixture, or override the dependency.

A list or dictionary field will not parse. Complex types are read as JSON, so a comma-separated value is rejected. Either supply JSON or add a validator that accepts the operator-friendly form.

The service starts but behaves as the wrong environment. An unconstrained free-text environment field accepted a value nothing branches on. Constrain it to the exact set of accepted spellings.

What kind of configuration is this?

Settings modelRuntime store
Read atProcess startRequest time, with a cache
Changes without a restartNoYes
ValidatedWholly, in one passPer read, if at all
Wrong value surfacesBefore trafficDuring a request
Right forEndpoints, credentials, limits, wiringFeature flags, per-tenant limits
Delivered throughA cached dependencyA repository or client dependency

Most confusion about configuration libraries comes from trying to make one of these do the other's job. A settings model that reloads is not a settings model, and a feature flag that needs a deploy is not a feature flag.

FAQ

Why validate configuration at startup rather than where it is used? Because a value read where it is used fails during a request, in production, for one caller, at an unpredictable time. Constructing a settings model at boot converts that into a process that refuses to start, which a deployment system can detect and roll back before any traffic is affected.

Does SecretStr protect every sensitive value in my settings? No. It masks only the fields you typed as SecretStr. A password embedded in a database URL typed as PostgresDsn appears in full in the object's repr, so it will reach any log line that prints the settings object.

Should handlers receive the whole settings object? Prefer a narrowing dependency that returns only the slice a handler needs. A route that receives just the database configuration cannot accidentally read or log the signing key, and the narrower type documents what that part of the code actually depends on.

How do I stop a typo in an environment variable from being ignored?extra="forbid" rejects unknown keys read from a .env file but does not see unknown process environment variables, so it cannot catch a misspelling in your deployment manifest. Add an explicit startup check that scans the environment for variables matching your prefix and are not fields on the model.

Can I reload configuration without restarting the process? Not with a settings model, which is deliberately built once and frozen. Anything that must change while the process runs — feature flags, per-tenant limits — is different data and belongs behind a store you can read at request time and cache with an expiry.

Where should the settings object be created? Once, in the application factory or a cached accessor it calls, so the whole process shares one validated instance. Constructing settings inside a handler re-reads and re-validates every source on every request.