Skip to content

Chapter 21 · Configuration Management and Application Lifecycle ​

Chapter goals: Manage environment variables with pydantic-settings, and gracefully manage app-level resource startup and teardown with lifespan.

21.1 Why Configuration Management Is Needed ​

Values like database URLs, secrets, and third-party credentials: they vary by environment (dev/test/prod differ), and are often sensitive — cannot be written into the code repository. The industry standard is to put them in environment variables, read by the app at runtime.

But environment variables have a天然 defect — they are always strings:

python
import os
items_per_user = os.getenv("ITEMS_PER_USER")   # "50" not 50
# Type conversion and validation must be hand-written; miss one place and it is a production incident

21.2 pydantic-settings: Validated Configuration ​

pydantic-settings brings Pydantic's type coercion and validation to environment variables:

bash
uv add pydantic-settings python-dotenv
python
# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str = "Awesome API"       # Can omit if a default exists
    admin_email: str
    items_per_user: int = 50            # Auto-converts from string to int and validates
    database_url: str = "sqlite:///./app.db"

    # Read from .env file in project root (requires python-dotenv)
    model_config = SettingsConfigDict(env_file=".env")


settings = Settings()  # Reads env vars on instantiation; case-insensitive matching
bash
# .env (remember to add to .gitignore!)
ADMIN_EMAIL="admin@example.com"
APP_NAME="ChimichangApp"

Resolution priority: environment variables > .env file > defaults; env var names match field names case-insensitively (APP_NAME → app_name). Override temporarily at startup:

bash
ADMIN_EMAIL="deadpool@example.com" APP_NAME="ChimichangApp" fastapi run main.py

Put config in a separate module

Place Settings in config.py (paired with Chapter 19's package structure);各处 from .config import settings. Never commit .env to Git.

21.3 Settings as a Dependency + @lru_cache Singleton ​

The problem with a bare global settings object: it is hard to replace in tests. The more elegant approach is to make config a dependency:

python
# config.py —— Note: no global instance created here
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50

    model_config = SettingsConfigDict(env_file=".env")
python
# main.py
from functools import lru_cache
from fastapi import Depends, FastAPI
from .config import Settings

app = FastAPI()


@lru_cache                # Critical: create Settings exactly once per process
def get_settings() -> Settings:
    return Settings()


@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
    return {"app_name": settings.app_name}

Why @lru_cache? Without it, def get_settings(): return Settings() would re-instantiate Settings and re-read the .env file from disk on every request — file I/O is a slow operation, completely unnecessary. With @lru_cache, the first call executes and caches the result; all subsequent requests reuse the same object.

The biggest benefit of using it as a dependency is test-friendliness:

python
def test_info_with_mocked_settings(client, monkeypatch):
    mock_settings = Settings(app_name="TestApp")
    app.dependency_overrides[get_settings] = lambda: mock_settings
    resp = client.get("/info")
    assert resp.json()["app_name"] == "TestApp"

21.4 Lifespan: Graceful Startup and Shutdown ​

The lifespan context manager replaces the old on_event("startup") / on_event("shutdown") pattern:

python
from contextlib import asynccontextmanager
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: initialize resources
    print("Starting up...")
    db_engine = create_engine("postgresql://...")
    app.state.db = db_engine
    yield
    # Shutdown: clean up resources
    print("Shutting down...")
    db_engine.dispose()


app = FastAPI(lifespan=lifespan)

Access the initialized resource in dependencies:

python
def get_db(request: Request):
    return request.app.state.db

21.5 Chapter Summary ​

  • pydantic-settings + BaseSettings for typed, validated environment variable config;
  • @lru_cache on the settings dependency prevents re-reading .env on every request;
  • dependency_overrides makes settings replaceable in tests;
  • lifespan context manager replaces on_event for graceful resource lifecycle management.

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. Why use pydantic-settings instead of os.getenv()?

2. Why decorate get_settings with @lru_cache?

3. What is the advantage of lifespan over on_event("startup")?

4. How do you replace Settings in a test?

🛠️ Hands-On Exercises ​

  1. Create a config.py with Settings(BaseSettings) reading from .env; access it via a get_settings dependency in a route.
  2. Add @lru_cache to get_settings; add a print statement to verify Settings is instantiated only once.
  3. Write a test that overrides get_settings with a mock Settings and verifies the route returns the mocked values.

Proceed to Chapter 22: Production Deployment.