FastAPI

FastAPI Structured Logging & Observability (JSON Logs + Request IDs)

Thirdy Gayares
12 min read

šŸŽÆ What You Will Learn

print() is fine until you have two users hitting your API at once — then your logs are an unsearchable blur with no way to tell which line belongs to which request. We'll fix that with structured JSON logs you can actually query.

  • Why print() and default logs fall apart under real traffic
  • A stdlib-only JSON log formatter (no extra dependencies)
  • One dictConfig setup — and taming uvicorn's own loggers
  • Correlating every log line with a request ID via contextvars
  • Logging in your endpoints with structured extra fields
  • Logging errors the right way (logger.exception)

Prerequisites: a basic FastAPI app. This post pairs perfectly with fastapi-middleware — we reuse the request-id idea from there and pipe it into every log line.

fastapi-logging/
ā”œā”€ā”€ requirements.txt
└── app/
    ā”œā”€ā”€ __init__.py
    ā”œā”€ā”€ context.py          # contextvar that holds the request id
    ā”œā”€ā”€ logging_config.py   # JsonFormatter + filter + setup_logging()
    ā”œā”€ā”€ middleware.py       # sets the request id per request
    └── main.py             # app + endpoints that log

The Problem: print() Doesn't Scale (See It First)

Here's the logging most tutorials leave you with: a stray print() and uvicorn's default access line. Looks fine with one request.

app/main.py (the naive version)
from fastapi import FastAPI

app = FastAPI()


@app.post("/orders")
def create_order():
    print("order created")          # 😬 goes nowhere useful
    return {"status": "ok"}

Now imagine two users hitting it at the same time. This is your log:

order created
order created
INFO:     127.0.0.1:53124 - "POST /orders HTTP/1.1" 200 OK
INFO:     127.0.0.1:53125 - "POST /orders HTTP/1.1" 200 OK
āš ļø
Why this hurts in production: which "order created" belongs to which request? You can't tell. You can't filter by level, can't search by user_id, and a log aggregator (Loki, CloudWatch, Datadog) sees plain text it can't index. The moment you have traffic, plain logs are noise.

Logging 101 (The Four Pieces)

Python's logging module already does everything we need — most people just never configure it. Four pieces to know:

PieceWhat it does
LoggerWhere you call logger.info(...). Name it per module: logging.getLogger("app.orders").
HandlerWhere logs go — stdout, a file, a network socket.
FormatterHow each record is rendered — plain text or, for us, JSON.
FilterHook to inspect/enrich each record — we'll use one to inject the request id.
šŸ’”
Ang importante dito: never call logging.info() on the root logger directly, and never sprinkle print(). Create a named logger per module (logger = logging.getLogger(__name__)) so you can control levels per subsystem later.

A Structured JSON Formatter (Stdlib Only)

The core upgrade: emit each log as one JSON object per line. No external package needed — a small logging.Formatter subclass does it. Crucially, it also picks up any extra fields you attach, so structured context flows straight into the JSON.

app/logging_config.py
import json
import logging
from datetime import datetime, timezone

# Attributes the stdlib puts on every LogRecord. Anything NOT here
# was added by us via extra={...} and should be logged as a field.
_RESERVED = {
    "name", "msg", "args", "levelname", "levelno", "pathname", "filename",
    "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
    "created", "msecs", "relativeCreated", "thread", "threadName",
    "processName", "process", "taskName", "message", "asctime",
}


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload: dict[str, object] = {
            "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }

        # merge structured extras: logger.info("hi", extra={"order_id": 7})
        for key, value in record.__dict__.items():
            if key not in _RESERVED and not key.startswith("_"):
                payload[key] = value

        if record.exc_info:
            payload["exc_info"] = self.formatException(record.exc_info)

        return json.dumps(payload, default=str)
šŸ’”
default=str keeps json.dumps from crashing on non-serializable values (a datetime, a UUID) — it falls back to their string form instead of raising. A logger should never take down a request.

Correlate Every Log with a Request ID

This is the feature that turns logs into an investigation tool. Store one request ID per request in a contextvar (async-safe, isolated per request), then a logging filter injects it into every record automatically — you never pass it around by hand.

app/context.py
import contextvars

# default "-" so logs emitted outside a request still format cleanly
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "request_id", default="-"
)
app/logging_config.py (add the filter)
from app.context import request_id_var


class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        # every record gets a request_id attribute -> ends up in the JSON
        record.request_id = request_id_var.get()
        return True
app/middleware.py
import uuid

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

from app.context import request_id_var


class RequestContextMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # reuse an incoming id (from a proxy/gateway) or generate one
        request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
        token = request_id_var.set(request_id)
        try:
            response = await call_next(request)
        finally:
            request_id_var.reset(token)   # avoid leaking the id across requests
        response.headers["X-Request-ID"] = request_id
        return response
āš ļø
Always reset() the contextvar in a finally. Under an async server the same worker handles many requests; skip the reset and a later request can inherit an earlier request's ID — the exact confusion we're trying to kill.

One dictConfig to Wire It All (and Tame uvicorn)

Configure logging in one place with dictConfig. This attaches our JSON formatter + request-id filter to a single stdout handler, and — the part everyone forgets — points uvicorn's own loggers at the same handler so every line is JSON, not just yours.

app/logging_config.py (add setup_logging)
from logging.config import dictConfig


def setup_logging(level: str = "INFO") -> None:
    dictConfig(
        {
            "version": 1,
            "disable_existing_loggers": False,
            "filters": {
                "request_id": {"()": "app.logging_config.RequestIdFilter"},
            },
            "formatters": {
                "json": {"()": "app.logging_config.JsonFormatter"},
            },
            "handlers": {
                "console": {
                    "class": "logging.StreamHandler",
                    "formatter": "json",
                    "filters": ["request_id"],
                    "stream": "ext://sys.stdout",
                },
            },
            "root": {"handlers": ["console"], "level": level},
            "loggers": {
                # route uvicorn through OUR handler; propagate=False stops doubles
                "uvicorn": {"handlers": ["console"], "level": level, "propagate": False},
                "uvicorn.error": {"handlers": ["console"], "level": level, "propagate": False},
                "uvicorn.access": {"handlers": ["console"], "level": level, "propagate": False},
            },
        }
    )
āš ļø
The double-log gotcha: if you attach a handler to a child logger and leave propagate=True, the record bubbles up to root and gets logged twice. Set propagate=False on the uvicorn loggers (as above) so each line appears exactly once.
šŸ’”
Prefer a batteries-included option? pip install python-json-logger and swap the formatters.json entry for "()": "pythonjsonlogger.jsonlogger.JsonFormatter". The stdlib version above keeps this tutorial dependency-free and shows exactly what's happening.

Logging in Your Endpoints (Structured Fields)

Now the fun part. Get a named logger per module and pass structured context via extra — never by stuffing values into the message string.

app/main.py
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from pydantic import BaseModel

from app.logging_config import setup_logging
from app.middleware import RequestContextMiddleware

logger = logging.getLogger("app.orders")


@asynccontextmanager
async def lifespan(app: FastAPI):
    setup_logging(level="INFO")   # configure logging before serving
    logger.info("app starting")
    yield
    logger.info("app shutting down")


app = FastAPI(title="FastAPI Logging", version="0.1.0", lifespan=lifespan)
app.add_middleware(RequestContextMiddleware)


class OrderCreate(BaseModel):
    item: str
    amount: float


@app.post("/orders", status_code=201)
def create_order(payload: OrderCreate):
    # structured context -> queryable fields, NOT string interpolation
    logger.info(
        "order created",
        extra={"item": payload.item, "amount": payload.amount},
    )
    return {"status": "ok"}
āœ…
Do this: logger.info("order created", extra={"amount": 49.9}).
Not this: logger.info(f"order created for {amount}"). The first gives you a filterable amount field; the second buries it in text you'll have to regex later.

Logging Errors (Don't Swallow the Traceback)

When something breaks, you want the full traceback in the log — attached to the same request ID. Use logger.exception() inside an except block (or an exception handler); it logs at ERROR and automatically includes exc_info.

app/main.py
from fastapi import Request
from fastapi.responses import JSONResponse


@app.get("/orders/{order_id}")
def get_order(order_id: int):
    if order_id <= 0:
        raise ValueError("order_id must be positive")
    return {"order_id": order_id}


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    # logs the message + full traceback, correlated by request_id
    logger.exception("unhandled error", extra={"path": request.url.path})
    return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
āš ļø
Never do except Exception: pass. A swallowed error is a bug you'll debug blind at 2am. Log it with logger.exception(...) so the traceback and request id are captured, then return a clean 500. See fastapi-error-handling for shaping the response itself.

Try It (Run + curl)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
1Run the app
run.sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Make a request and read the JSON logs
$ curl -s -X POST http://127.0.0.1:8000/orders \
    -H "Content-Type: application/json" \
    -d '{"item":"coffee","amount":49.9}'
{"status":"ok"}

# server stdout — one JSON object per line, request_id on every one:
{"timestamp":"2026-07-18T09:12:04.511+00:00","level":"INFO","logger":"app.orders","message":"order created","request_id":"5f9c8b1a-2e5d-4c11-9a3e-71b0f2a1c6d4","item":"coffee","amount":49.9}
{"timestamp":"2026-07-18T09:12:04.512+00:00","level":"INFO","logger":"uvicorn.access","message":"127.0.0.1:53124 - \"POST /orders HTTP/1.1\" 201","request_id":"5f9c8b1a-2e5d-4c11-9a3e-71b0f2a1c6d4"}
3Trigger an error — traceback, same request_id
$ curl -s http://127.0.0.1:8000/orders/-1 -H "X-Request-ID: trace-123"
{"detail":"Internal Server Error"}

# log line carries the id you passed in + the traceback:
{"timestamp":"2026-07-18T09:13:40.882+00:00","level":"ERROR","logger":"app.orders","message":"unhandled error","request_id":"trace-123","path":"/orders/-1","exc_info":"Traceback (most recent call last):\n  ...\nValueError: order_id must be positive"}
āœ…
You did it: every line is JSON, every line has a request_id, and you can filter by one request across your app logs and uvicorn's. Pipe this into any aggregator and you get real search — request_id="trace-123" pulls the whole story of one request.

Common Mistakes (and Fixes)

MistakeSymptomFix
Using print() for logsNo levels, no fields, no aggregationNamed loggers + logging
Interpolating values into the messageCan't filter/aggregate by that valuePass them via extra={...}
Leaving uvicorn on its own formatterMixed JSON + plain text logsRoute uvicorn loggers through your handler
propagate=True on a configured child loggerEvery line logged twiceSet propagate=False
Forgetting to reset() the contextvarRequest IDs bleed across requestsReset in a finally block
except Exception: passErrors vanish with no traceUse logger.exception(...)

Production Notes

  • Log to stdout, not files. In containers, write JSON to stdout and let the platform (Docker, Kubernetes) collect and ship it. See fastapi-docker. Don't manage log files inside the app.
  • Never log secrets or PII. No passwords, tokens, full card numbers, or raw request bodies. Redact before logging — a log aggregator is not a vault.
  • Set level via config. INFO in prod, DEBUG locally — drive it from an env var (fastapi-pydantic-settings), never hardcode.
  • Mind the volume. Logging on the hot path costs money and I/O. Log decisions and errors, not every loop iteration; sample noisy paths if needed.
šŸ’”
Next step in observability: logs answer "what happened to this request?". Metrics (request rate, latency, error %) and traces (spans across services) answer "how is the system doing?". Structured logs with request IDs are the foundation the other two build on.

What's Next

āœ…
šŸš€ Recommended next reads:
  • fastapi-middleware — the request lifecycle where our request-id context is set
  • fastapi-error-handling — shape the error responses you're now logging
  • fastapi-docker — ship these stdout JSON logs from a container
  • fastapi-pydantic-settings — drive log level and format from typed config

Recap: drop print(), emit one JSON object per line, and inject a request ID via a contextvar + filter so every log — yours and uvicorn's — is correlated. Pass context through extra, log errors with logger.exception, and let your platform collect stdout. That's logging you can actually search when it matters.

fastapi-logging/                    # āœ… finished
ā”œā”€ā”€ requirements.txt
└── app/
    ā”œā”€ā”€ __init__.py
    ā”œā”€ā”€ context.py          # request_id_var (contextvar)
    ā”œā”€ā”€ logging_config.py   # JsonFormatter, RequestIdFilter, setup_logging()
    ā”œā”€ā”€ middleware.py       # RequestContextMiddleware sets/resets the id
    └── main.py             # lifespan calls setup_logging; endpoints log structured

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.