FastAPI in the Real World: Settings, Lifespan Startup, and Request-Aware Logging (with Working Code)

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-123 for 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

  • Settings object with typed fields, APP_ prefix, and local .env support
  • lifespan that configures logging and initializes resources
  • Request ID middleware that sets X-Request-ID and 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

Your email address will not be published. Required fields are marked *