šÆ 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
dictConfigsetup ā and taming uvicorn's own loggers - Correlating every log line with a request ID via
contextvars - Logging in your endpoints with structured
extrafields - 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 logThe 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.
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
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:
| Piece | What it does |
|---|---|
| Logger | Where you call logger.info(...). Name it per module: logging.getLogger("app.orders"). |
| Handler | Where logs go ā stdout, a file, a network socket. |
| Formatter | How each record is rendered ā plain text or, for us, JSON. |
| Filter | Hook to inspect/enrich each record ā we'll use one to inject the request id. |
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.
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.
import contextvars
# default "-" so logs emitted outside a request still format cleanly
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
"request_id", default="-"
)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 Trueimport 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 responsereset() 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.
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},
},
}
)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.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.
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"}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.
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"})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)
fastapi==0.116.1
uvicorn[standard]==0.30.6python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload$ 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"}$ 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"}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)
| Mistake | Symptom | Fix |
|---|---|---|
Using print() for logs | No levels, no fields, no aggregation | Named loggers + logging |
| Interpolating values into the message | Can't filter/aggregate by that value | Pass them via extra={...} |
| Leaving uvicorn on its own formatter | Mixed JSON + plain text logs | Route uvicorn loggers through your handler |
propagate=True on a configured child logger | Every line logged twice | Set propagate=False |
Forgetting to reset() the contextvar | Request IDs bleed across requests | Reset in a finally block |
except Exception: pass | Errors vanish with no trace | Use 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.
INFOin prod,DEBUGlocally ā 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.
What's Next
fastapi-middlewareā the request lifecycle where our request-id context is setfastapi-error-handlingā shape the error responses you're now loggingfastapi-dockerā ship these stdout JSON logs from a containerfastapi-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