FastAPI in the Real World: Settings, Lifespan Startup, and Request-Aware Logging (with Working Code)
FastAPI makes it easy to ship a working API fast. The tricky part comes next: running it in different environments, wiring dependencies cleanly, and debugging production issues when logs don’t tell you “which request caused this?”. This hands-on guide shows a practical baseline you can reuse across projects:
- Typed configuration with environment variables (
pydantic-settings) - Predictable app startup/shutdown with
lifespan - Request ID middleware and request-aware logging
- Clean dependencies for auth and service layers
All examples are copy/pasteable and work with Python 3.10+.
1) Install dependencies
We’ll use FastAPI, Uvicorn, and Pydantic Settings (recommended for config management).
pip install fastapi uvicorn pydantic-settings
(Optional for the small test at the end)
pip install pytest httpx
2) Recommended project layout
Keep app concerns separated early—it’s easier than refactoring later:
app/ __init__.py main.py settings.py logging_config.py middleware.py deps.py services/ __init__.py users.py
3) Typed settings with environment variables
Stop passing secrets and URLs around as strings. Centralize them as typed settings. Create app/settings.py:
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import Field class Settings(BaseSettings): # App app_name: str = "fastapi-practical" environment: str = Field(default="dev", description="dev|staging|prod") debug: bool = False # HTTP host: str = "127.0.0.1" port: int = 8000 # Observability log_level: str = "INFO" # Example: external services database_url: str = "sqlite:///./local.db" api_key_header_name: str = "X-API-Key" api_key_value: str = "dev-secret" # set in env in real deployments model_config = SettingsConfigDict( env_file=".env", # local convenience env_prefix="APP_", # e.g., APP_LOG_LEVEL=DEBUG extra="ignore", ) settings = Settings()
Create a local .env file (don’t commit it):
APP_ENVIRONMENT=dev APP_DEBUG=true APP_LOG_LEVEL=INFO APP_API_KEY_VALUE=dev-secret
Now you can import settings anywhere without guessing variable names or types.
4) Logging that includes a Request ID (so you can trace issues)
When a bug happens, you want to follow one request across multiple log lines. A common pattern is:
- Generate a request ID for each incoming request (or reuse one if the client sends it)
- Attach it to responses (so clients can report it)
- Include it in logs
Create app/middleware.py to manage request IDs:
import uuid from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.types import ASGIApp class RequestIdMiddleware(BaseHTTPMiddleware): def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"): super().__init__(app) self.header_name = header_name async def dispatch(self, request: Request, call_next): request_id = request.headers.get(self.header_name) or str(uuid.uuid4()) request.state.request_id = request_id response = await call_next(request) response.headers[self.header_name] = request_id return response
Now configure logging. We’ll use Python’s logging and a filter that injects a request ID when available. Create app/logging_config.py:
import logging import contextvars # Context variable that can store request_id per request/task request_id_ctx_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-") class RequestIdFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: record.request_id = request_id_ctx_var.get() return True def configure_logging(level: str = "INFO") -> None: logging.basicConfig( level=getattr(logging, level.upper(), logging.INFO), format="%(asctime)s %(levelname)s request_id=%(request_id)s %(name)s: %(message)s", ) root = logging.getLogger() root.addFilter(RequestIdFilter())
We also need to set the context variable inside request handling. The middleware can do that. Update app/middleware.py to set and reset the context:
import uuid from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.types import ASGIApp from .logging_config import request_id_ctx_var class RequestIdMiddleware(BaseHTTPMiddleware): def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"): super().__init__(app) self.header_name = header_name async def dispatch(self, request: Request, call_next): request_id = request.headers.get(self.header_name) or str(uuid.uuid4()) # Set request id for the current context token = request_id_ctx_var.set(request_id) request.state.request_id = request_id try: response = await call_next(request) finally: # Reset context variable to previous state request_id_ctx_var.reset(token) response.headers[self.header_name] = request_id return response
With this, any log line during request handling includes the correct request_id.
5) Lifespan: predictable startup/shutdown
Instead of global side effects at import time, use FastAPI’s lifespan to initialize resources (DB pools, clients, caches) and close them cleanly. Create app/main.py:
import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, HTTPException from starlette.requests import Request from .settings import settings from .middleware import RequestIdMiddleware from .logging_config import configure_logging logger = logging.getLogger("app") @asynccontextmanager async def lifespan(app: FastAPI): configure_logging(settings.log_level) logger.info("Starting app: %s env=%s", settings.app_name, settings.environment) # Example: initialize resources app.state.some_cache = {} # replace with real cache/client/pool yield # Example: close resources logger.info("Shutting down app: %s", settings.app_name) app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan) app.add_middleware(RequestIdMiddleware) def get_request_id(request: Request) -> str: # Useful as a dependency if you want it explicitly return getattr(request.state, "request_id", "-") def require_api_key(request: Request) -> None: header = settings.api_key_header_name provided = request.headers.get(header) if provided != settings.api_key_value: raise HTTPException(status_code=401, detail="Invalid API key") @app.get("/health") async def health(): return {"status": "ok"} @app.get("/whoami") async def whoami( request_id: str = Depends(get_request_id), _auth: None = Depends(require_api_key), ): logger.info("Handling whoami") return {"request_id": request_id, "message": "You are authenticated"}
Run it:
uvicorn app.main:app --reload
Try requests:
# No API key curl -i http://127.0.0.1:8000/whoami # With API key and custom request id curl -i -H "X-API-Key: dev-secret" -H "X-Request-ID: abc-123" http://127.0.0.1:8000/whoami
You should see:
- Response contains
X-Request-ID - Logs include
request_id=abc-123for that request
6) Clean “service layer” dependency (without overengineering)
As your API grows, endpoints shouldn’t do everything. Move business logic into a service and inject it. Create app/services/users.py:
from dataclasses import dataclass @dataclass class UserService: # In real life: pass repositories/clients here def get_display_name(self, user_id: int) -> str: # Pretend lookup return f"user-{user_id}"
Create app/deps.py:
from .services.users import UserService def get_user_service() -> UserService: return UserService()
Add an endpoint that uses it (append to app/main.py):
from .deps import get_user_service from .services.users import UserService @app.get("/users/{user_id}") async def get_user(user_id: int, svc: UserService = Depends(get_user_service)): name = svc.get_display_name(user_id) logger.info("Fetched user %s", name) return {"id": user_id, "display_name": name}
This pattern scales: swap the implementation later (database, external API) without rewriting your routes.
7) Optional: a tiny test to validate the Request ID behavior
Create test_app.py:
from fastapi.testclient import TestClient from app.main import app def test_request_id_header_is_returned(): client = TestClient(app) r = client.get("/health", headers={"X-Request-ID": "test-123"}) assert r.status_code == 200 assert r.headers["X-Request-ID"] == "test-123"
Run:
pytest -q
Practical checklist for your next FastAPI project
Settingsobject with typed fields,APP_prefix, and local.envsupportlifespanthat configures logging and initializes resources- Request ID middleware that sets
X-Request-IDand makes logs traceable - Dependencies for cross-cutting concerns (auth, request metadata)
- A simple service layer for business logic to keep endpoints thin
If you want, I can adapt this template to your stack (PostgreSQL + SQLAlchemy, Redis caching, background tasks, or structured JSON logs for ELK/Datadog) while keeping it junior-friendly.
Leave a Reply