Back to Blog
Engineering

How to Add Structured Logging to a FastAPI App: JSON Logs, Request IDs, and Correlation

print() is a fine logger right up until the moment you have two of anything — two workers, two requests in flight, two servers behind a load balancer. Then the console becomes a braid of interleaved lines from unrelated requests, with no way to tell which line belongs to which, and the first real production incident turns into archaeology. Structured logging is the fix, and it's less work than the mess it replaces: emit each log as a JSON object instead of a sentence, stamp every line with a request ID, and make that ID propagate automatically so a single query pulls back the entire story of one request. This is a practical, vendor-neutral guide to doing exactly that in FastAPI — standard-library Python, one small middleware, and one idea (contextvars) that ties it all together.

Why JSON Logs, Not Sentences

A human-readable log line — User 4821 updated order 9910 in 240ms — reads fine to you and is nearly useless to a machine. The moment you want to answer “show me every request slower than 500ms for user 4821 yesterday,” you're writing regexes against prose. A structured log emits the same event as data:

{"timestamp": "2026-07-06T14:03:11Z", "level": "INFO", "logger": "app.orders",
 "message": "order updated", "user_id": 4821, "order_id": 9910, "duration_ms": 240,
 "request_id": "6f1c…"}

Now every field is queryable. Any log aggregator — or a local jq pipe — can filter on user_id, sort by duration_ms, or group by level without parsing English. The shift is small and total: you stop formatting facts into a string and start attaching them as fields. Everything below is in service of that one move.

Step 1: Emit JSON From the Standard Library

You don't need a framework for this. Python's built-in logging already separates what you log from how it's rendered — the rendering lives in a Formatter, so a JSON formatter is the entire lift. Configure it once at startup:

import json, logging, sys

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "request_id": getattr(record, "request_id", "-"),
        }
        # anything passed via logger.info("...", extra={...}) rides along
        for key, value in getattr(record, "context", {}).items():
            payload[key] = value
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, default=str)

def configure_logging(level: str = "INFO") -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers.clear()          # drop Uvicorn's default text handler
    root.addHandler(handler)
    root.setLevel(level)

Call configure_logging() before you create the app, log to stdout, and stop there. In a container, stdout is the log — the orchestrator collects it, and rotation, shipping, and retention are the platform's job, not your app's. Writing to files from inside the process is a habit worth dropping the day you containerize. Reach for a library like structlog if you want its ergonomics, but understand you don't need it: the standard library does structured output in the twenty lines above.

Step 2: Give Every Request an ID

The single most useful field in the whole scheme is the one that ties a request's log lines together. Generate a unique ID at the edge of every request, and — critically — honor one that's already there: if a proxy, gateway, or calling service sent an X-Request-ID, reuse it so the trail stitches across service boundaries instead of restarting at yours.

import uuid
from starlette.middleware.base import BaseHTTPMiddleware

class RequestIdMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex
        token = request_id_var.set(rid)          # see Step 3
        try:
            response = await call_next(request)
        finally:
            request_id_var.reset(token)          # don't leak the ID to the next request
        response.headers["X-Request-ID"] = rid   # echo it so the caller can correlate too
        return response

app.add_middleware(RequestIdMiddleware)

Echoing the ID back in the response header is a small courtesy that pays off constantly: when a user reports “it failed at 2:04,” the ID is right there in their browser's network tab, and you paste it into one query instead of guessing at timestamps.

Step 3: Make the ID Propagate by Itself

Here's the part that separates logging that helps from logging you have to babysit. You do not want to thread a request_id argument through every function call down to the query layer. Instead, store it in a contextvars.ContextVar — a variable that's naturally scoped to the current async task — and let a logging Filter stamp it onto every record automatically:

import contextvars, logging

request_id_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_var.get()   # injected into JsonFormatter above
        return True

# attach the filter once, wherever you configured the handler
handler.addFilter(RequestIdFilter())

That's the whole trick. The middleware sets the contextvar at the start of the request; the filter reads it for every log line emitted anywhere in that request's call stack; the formatter writes it out. A logger.info("order updated") buried three layers deep in a service function now carries the right request_id without knowing the ID exists. Because a ContextVar is isolated per async task, two requests handled concurrently never bleed IDs into each other.

Why a Filter and Not a Global

A module-level CURRENT_ID = "…" would be shared across every concurrent request and corrupt instantly under load. A ContextVar gives each async task its own copy, and the Filter is what bridges it into the logging machinery — it runs on every record, so you attach the ID in exactly one place instead of at every call site. Set it once in middleware, read it everywhere for free. That asymmetry is the entire reason this scales.

Step 4: Log the Request Itself — Once

With context wired up, add one deliberate log line per request: a single completion event that captures method, path, status, and duration. It's the backbone of your observability — the record you group, count, and alert on — and it belongs in the same middleware:

import time, logging

log = logging.getLogger("app.access")

class RequestIdMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex
        token = request_id_var.set(rid)
        start = time.perf_counter()
        try:
            response = await call_next(request)
        finally:
            elapsed_ms = round((time.perf_counter() - start) * 1000, 1)
            log.info("request completed", extra={"context": {
                "method": request.method,
                "path": request.url.path,
                "status": locals().get("response").status_code if "response" in locals() else 500,
                "duration_ms": elapsed_ms,
            }})
            request_id_var.reset(token)
        response.headers["X-Request-ID"] = rid
        return response

One structured line per request, and your dashboards write themselves: request rate is a count, latency is a percentile over duration_ms, error rate is a filter on status >= 500. Resist the urge to also log the start of every request — it doubles your volume to tell you something the completion line already implies. Log the outcome, richly, once.

Never Log Secrets or Raw Bodies

Structured logs get shipped somewhere — a managed aggregator, cold storage, a teammate's laptop during triage. A logged Authorization header, password field, or full request body is now a credential or a pile of PII living in all of those places, often with far looser access controls than your database. Log identifiers (user_id), never contents (the session token). Maintain an explicit allowlist of headers you'll record, redact the rest, and treat “just dump the payload to debug it” as the trap it is.

Step 5: Use Levels Like They Mean Something

Log levels are a routing decision, not decoration — they're how you and your alerting tell “someone typed a bad password” apart from “the database is down.” A working convention:

The most common mistake is logging expected conditions at ERROR. A user submitting an invalid form is a 400 and an INFO at most — it is not an error in your system, and if it pages you at 3 a.m., you'll soon be ignoring the channel that also carries the real fire. Reserve ERROR for things that are genuinely your bug or a genuine outage, and your alerts stay trustworthy.

The One Gotcha: Background Work

Context propagation has a sharp edge worth knowing before it bites you. A ContextVar follows the current async task, so it flows naturally into anything you await and into FastAPI's BackgroundTasks. But the moment you hand work to a separate executor — a thread pool, a process, a Celery or RQ worker on another machine — that new context starts empty, and your logs there show request_id: "-". The fix is to pass the ID across the boundary explicitly and re-seat it on the other side:

rid = request_id_var.get()
enqueue_job(process_export, export_id=42, request_id=rid)   # carry it across the wire

def process_export(export_id: int, request_id: str) -> None:
    request_id_var.set(request_id)     # re-seat context in the worker
    log.info("export started", extra={"context": {"export_id": export_id}})

Do that, and a request that kicks off async work stays traceable end to end — the web log and the worker log share one ID, and “where did this export go?” is one query across both.

The Bottom Line

Structured logging isn't a library you install or a vendor you sign up for — it's a handful of decisions you make once and then benefit from on every incident afterward. Emit JSON so your logs are data, not prose. Mint a request ID at the edge and honor one that arrives. Park it in a ContextVar and let a filter stamp it on every line, so correlation costs you nothing at the call site. Log each request's outcome once, richly, and use levels that mean what they say. The payoff is the difference between grepping a wall of interleaved text and running one query that returns the complete, ordered story of a single request — which is the only thing you ever actually wanted from a log.

A Production FastAPI Foundation

Cross-cutting plumbing like a request-ID middleware is far easier to add to an app that's already well-organized. ShipKit, our production-ready FastAPI boilerplate, gives you that structured foundation — a clean app layout, SQLAlchemy models, auth, and middleware wiring in place — so adding your own observability layer is a drop-in, not a refactor. See inside ShipKit's architecture for how it fits together.

Explore ShipKit
BW

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: How to Build a Design System All Articles