Configuration Management in FastAPI

Configuration management is the practice of loading every environment-specific input — database URLs, feature flags, secret references — into a single typed object that is validated once and injected everywhere it is needed.

Configuration is the input to the application factory: the factory accepts a settings object and wires the app around it. Treating configuration as a first-class, typed dependency is what makes a service testable across environments and safe to deploy, and it connects directly to Dependency Injection Strategies, since settings are delivered through the same injection mechanism as everything else. This topic sits within Core Architecture and Routing Patterns.

Configuration precedence resolving into a validated settings object Four stacked sources — field defaults, .env file, environment variables, and secrets manager — flow in order of increasing precedence into a single validated Settings object, which fails fast if a required value is missing. Sources (low → high precedence) Field defaults (in code) .env file Environment variables Secrets manager / mounted files Validated Settings parsed · typed · cached Missing/invalid → fail at boot
Higher-precedence sources override lower ones, all collapsing into one validated object — and any missing required value stops the process before it serves traffic.

Core Mechanics: Typed Settings with Precedence

pydantic-settings resolves configuration from multiple sources with a defined precedence: explicit arguments override environment variables, which override .env values, which override field defaults. Because the result is a Pydantic model, every field is parsed and validated — a malformed DATABASE_URL or an out-of-range pool size is rejected immediately rather than failing deep in a request.

# app/config.py
from functools import lru_cache

from pydantic import PostgresDsn, SecretStr, Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_prefix="APP_",
        extra="forbid",  # Reject unknown variables so typos fail loudly.
    )

    project_name: str = "api-service"
    environment: str = Field(pattern="^(development|staging|production|test)$")
    database_url: PostgresDsn            # Required — no default, so it must be supplied.
    secret_key: SecretStr = Field(min_length=32)  # Never printed in logs or repr.
    db_pool_size: int = Field(default=10, ge=2, le=50)
    enable_signup: bool = True


@lru_cache  # Parse once per process; all consumers share the instance.
def get_settings() -> Settings:
    return Settings()

Production Implementation: Inject, Don't Import

The cached get_settings function is the seam between configuration and the rest of the app. Inject it as a dependency so any handler or service receives settings as a parameter, which means tests can override it with a different configuration through app.dependency_overrides — no environment juggling required.

from typing import Annotated

from fastapi import APIRouter, Depends

from app.config import Settings, get_settings

router = APIRouter()


@router.get("/features")
async def features(settings: Annotated[Settings, Depends(get_settings)]) -> dict[str, bool]:
    # Configuration arrives as a typed argument, not a global lookup.
    return {"signup_enabled": settings.enable_signup}

Async and Performance Notes

get_settings is wrapped in lru_cache, so parsing and validation happen exactly once and every subsequent resolution is a dictionary hit. This matters because settings are read on hot paths; an uncached settings construction that re-reads .env on every request would add filesystem latency to every call. The cache also gives you a single, predictable object identity, which is convenient when other dependencies close over it.

Testing Strategy

Override the settings dependency to exercise environment-specific behavior without touching the real environment:

def test_signup_disabled(client_factory):
    app = client_factory()
    app.dependency_overrides[get_settings] = lambda: Settings(
        environment="test", database_url="postgresql://x/y", secret_key="x" * 32,
        enable_signup=False,
    )
    # ... assert the signup route now returns 403.

Failure Modes and Debugging

  • Silent misconfiguration. Without required fields, a missing variable produces a wrong-but-running service. Declare required fields without defaults so boot fails instead.
  • Secrets in logs. Plain str secret fields leak into tracebacks and structured logs. Use SecretStr and access the value explicitly with .get_secret_value() only where needed.
  • Per-request re-parsing. Constructing Settings() inside a handler re-reads sources every call. Always go through the cached accessor.
  • Environment drift. Different variable names across environments cause works-here-fails-there bugs. The detailed approach to taming this is in Managing Environment Variables with Pydantic Settings.