Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Configuration with pydantic-settings

The same code runs on a laptop, in CI, in staging and in production. Only configuration differs: database URLs, secrets, feature flags, allowed origins.

In this post

What this post covers
  1. 1. A settings class
  2. 2. Fail at startup, not on the first request
  3. 3. Settings as a dependency
  4. 4. Configuration rules from production
  5. Recap

The same code runs on a laptop, in CI, in staging and in production. Only configuration differs: database URLs, secrets, feature flags, allowed origins. Scattered os.environ.get(...) calls are how services end up with a typo’d variable name that silently falls back to a default in production. pydantic-settings turns configuration into a validated, typed object that fails loudly at startup, not at 3 a.m. on the first request that needs the missing value.

🧾

A pre-flight checklist

Pilots do not discover a missing fuel reading after take-off. Every item is checked on the ground, in a fixed order, and the plane does not leave the gate if one fails. A settings class is the checklist: every value is read, type-checked and validated before the service accepts traffic.

tests 1. Settings(database_url=...) arguments production 2. environment variables ORDERS_JWT_SECRET=… local development 3. the .env file containers 4. secrets directory /run/secrets fallback 5. defaults written in the class higher wins then validated once, at startup: a typo fails the deploy, not a request

1. A settings class

typed settings from environment variables and a .env file

import os
from pathlib import Path
from typing import Literal

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

class RedisSettings(BaseModel):
    url: str = "redis://localhost:6379/0"
    pool_size: int = Field(10, ge=1, le=200)

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", env_nested_delimiter="__",
                                      extra="ignore")
    env: Literal["dev", "staging", "prod"] = "dev"
    database_url: str
    jwt_secret: SecretStr                           # never printed or logged by accident
    debug: bool = False
    allowed_origins: list[str] = []                 # lists and dicts come from JSON strings
    request_timeout_s: float = Field(10.0, gt=0, le=120)
    redis: RedisSettings = RedisSettings()

Path(".env").write_text(                             # a developer's local .env (never committed)
    "APP_DATABASE_URL=sqlite+aiosqlite:///./dev.db\n"
    "APP_JWT_SECRET=dev-only-secret\n"
    "APP_DEBUG=true\n", encoding="utf-8")
os.environ["APP_ALLOWED_ORIGINS"] = '["https://shop.example.com", "https://admin.example.com"]'
os.environ["APP_REDIS__POOL_SIZE"] = "25"            # nested field: redis.pool_size

settings = Settings()
print(settings.env, settings.debug, settings.database_url)
print(settings.allowed_origins)
print(settings.redis)
print("secret shows as:", settings.jwt_secret, "| real value length:", len(settings.jwt_secret.get_secret_value()))
print(repr(settings)[:90], "…")

Output

dev True sqlite+aiosqlite:///./dev.db
['https://shop.example.com', 'https://admin.example.com']
url='redis://localhost:6379/0' pool_size=25
secret shows as: ********** | real value length: 15
Settings(env='dev', database_url='sqlite+aiosqlite:///./dev.db', jwt_secret=SecretStr('*** …
Priority (highest first)Source
1arguments passed to Settings(...) (tests use this)
2environment variables (APP_DEBUG=false)
3the .env file
4secrets directory files (secrets_dir="/run/secrets": Docker and Kubernetes secrets)
5defaults in the class

2. Fail at startup, not on the first request

a missing secret and a bad value, reported together

import os

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

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    database_url: str
    jwt_secret: SecretStr
    request_timeout_s: float = Field(10.0, gt=0, le=120)

os.environ["APP_DATABASE_URL"] = "postgresql+asyncpg://app@db/orders"
os.environ["APP_REQUEST_TIMEOUT_S"] = "300"            # a typo'd extra zero
try:
    Settings()                                           # in real code: at import of the app factory / lifespan
except ValidationError as e:
    print(f"refusing to start, {e.error_count()} configuration errors:")
    for err in e.errors():
        print(f"  APP_{err['loc'][0].upper()}: {err['msg']}")

Output

refusing to start, 2 configuration errors:
  APP_JWT_SECRET: Field required
  APP_REQUEST_TIMEOUT_S: Input should be less than or equal to 120

3. Settings as a dependency

one cached instance, overridable in tests

from functools import lru_cache
from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    env: str = "dev"
    feature_new_checkout: bool = False

@lru_cache                                    # read the environment once per process
def get_settings() -> Settings:
    return Settings()

SettingsDep = Annotated[Settings, Depends(get_settings)]

app = FastAPI()

@app.get("/checkout")
def checkout(settings: SettingsDep):
    return {"flow": "new" if settings.feature_new_checkout else "classic", "env": settings.env}

client = TestClient(app)
print(client.get("/checkout").json())

app.dependency_overrides[get_settings] = lambda: Settings(env="test", feature_new_checkout=True)
print(client.get("/checkout").json())         # a test flips a feature flag without touching the environment
app.dependency_overrides.clear()

Output

{'flow': 'classic', 'env': 'dev'}
{'flow': 'new', 'env': 'test'}
lru_cache and tests

Because get_settings is cached, changing environment variables inside a test has no effect after the first call. Either override the dependency (as above) or call get_settings.cache_clear() in a fixture. Code that reads settings at import time (a module-level settings = Settings() used everywhere) cannot be overridden at all, which is one more reason for the app factory (part 07).

4. Configuration rules from production

Secrets never in code or Git

.env in .gitignore; production secrets from the platform (Kubernetes secrets, a vault, a cloud secret manager) via env vars or secrets_dir. Use SecretStr so they never appear in logs or tracebacks.

Required means no default

A production database URL with a localhost default will one day connect production to nothing, or to the wrong thing. Make it required.

Few environment switches

Prefer explicit flags (debug, docs_enabled, log_json) over if env == "prod": scattered through the code.

Log the shape, not the values

At startup, log which settings were loaded and from where. Never log secret values, and SecretStr makes that the default.

Recap

  • BaseSettings reads env vars, .env and secret files into a typed, validated object.
  • Required fields have no default; invalid config stops the service at startup with every error listed.
  • SecretStr for secrets; nested models with env_nested_delimiter; lists as JSON strings.
  • @lru_cache get_settings as a dependency, overridden in tests.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries