Most FastAPI logging tutorials start with import logging and end with a print statement. That is a fine place to start, and a terrible place to ship from. The default logging module in Python is genuinely good — it is also genuinely underused, because the defaults that come with most FastAPI scaffolds are tuned for development, not for the moment a request starts returning 500s and you have to figure out why.
The good news: getting FastAPI logging to a level that holds up in production is not complicated. It is mostly about turning off the things that hide signal and turning on the things that produce it. JSON output, a request id per call, the right log level at each layer, and one library decision you only have to make once.
The direct answer
To configure FastAPI logging, instantiate Python’s standard logging module at process start with a JSON formatter, add a middleware that attaches a request id to the logging context, and replace the default uvicorn access log with a structured line that includes the request id, the route, the status, and the latency. That is the entire production setup in one paragraph. The rest of this post is the details, the opinions, and the small things that go wrong when you skip steps.
# app.py
import logging
import sys
import json
import time
import uuid
from fastapi import FastAPI, Request
app = FastAPI()
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"ts": time.time(),
"level": record.levelname,
"msg": record.getMessage(),
"logger": record.name,
}
for k in ("request_id", "route", "status", "latency_ms"):
v = getattr(record, k, None)
if v is not None:
payload[k] = v
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
That is the floor. Everything below is on top of this.
The library question, settled early
The two libraries that come up in any “FastAPI logging” discussion are loguru and structlog. The two camps disagree loudly. My take, after running both in production:
- stdlib
loggingis enough for the majority of FastAPI services. It is in the standard library, it integrates with every observability backend, and it does not require a dependency to maintain. - Add
structlogif you want first-class structured logging. It plays nicely with stdlib (it can be configured to useloggingunder the hood), it makes context binding trivial, and it produces JSON that looks the same in development and production. - Skip
loguru. It is a fine library, but it patches stdlib, owns its own handler, and creates friction with the rest of the Python ecosystem (uvicorn, gunicorn, celery, structlog, opentelemetry). The convenience is not worth the long-term tax.
The rest of this post assumes stdlib + a thin JSON formatter. Adding structlog on top is straightforward and the structlog docs cover the integration in detail.
The four log levels worth configuring
Most production services log at the wrong levels because the level names are used as “severity” instead of “audience.” The useful mapping is closer to:
| Level | What belongs here | Who reads it |
|---|---|---|
DEBUG | Per-step internals, request bodies, query params | You, while developing |
INFO | One line per request, deploys, config loads, jobs started/finished | Operators, audit |
WARNING | Recoverable anomalies — retries, fallbacks, deprecated paths, slow queries above a threshold | Operators, on-call |
ERROR | Unhandled exceptions, failed external calls, data integrity issues | On-call, pages |
A common bug is logging every successful request at INFO and every exception at ERROR, which makes the two indistinguishable in volume and drowns the signal. The fix is to log every request at INFO with one line, and exceptions at ERROR with the stack trace and a request id.
The request id is the whole game
A production log line that does not carry a request id is almost useless. The request id is what lets you grep one user’s journey through the system, correlate a slow database call to the request that triggered it, and find the one log line out of a million that explains a customer’s bug report.
The implementation is one middleware:
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
latency_ms = (time.perf_counter() - start) * 1000
logger.info(
"request",
extra={
"request_id": request_id,
"route": request.url.path,
"status": response.status_code,
"latency_ms": round(latency_ms, 2),
},
)
response.headers["x-request-id"] = request_id
return response
Three things to notice:
- The middleware accepts an inbound
x-request-idheader if the load balancer or upstream service already set one. That is the difference between a request id that works across services and one that resets at the edge. - The same request id is returned in the response header. That lets a customer support workflow quote a request id back, and the on-call engineer can find the exact logs.
- The structured fields (
request_id,route,status,latency_ms) go through theextra=argument, which theJsonFormatterthen reads out of theLogRecordand serializes.
Uvicorn and the duplicate-access-log problem
Out of the box, uvicorn logs every request on its own access log, and your FastAPI middleware logs the same request on your own logger. The result is two log lines per request, neither with the request id the other one has. There are two ways to fix it.
Disable uvicorn’s access log and own every line yourself:
uvicorn app:app --no-access-log
Or, if you want to keep uvicorn’s access log, route it through the same JSON formatter:
import logging
logging.getLogger("uvicorn.access").handlers = logger.handlers
logging.getLogger("uvicorn.access").propagate = False
The first option is cleaner and what most production deployments end up using. The second is useful when you want uvicorn’s per-request bytes-served metric, which is information the FastAPI middleware does not have.
What to log in a request handler
A common pattern is to log too much. The right amount, in a request handler, is a single structured line at the boundary and a couple of structured lines for meaningful events inside. Not a log line per database query. Not a log line per dependency call. Not a log line at the top of every function.
Inside a request handler, the right pattern is:
@app.post("/orders")
async def create_order(order: Order, request: Request):
logger.info("creating order", extra={"request_id": request.state.request_id,
"customer": order.customer_id})
try:
result = await orders.create(order)
except InsufficientStock as e:
logger.warning("stock unavailable",
extra={"request_id": request.state.request_id,
"sku": e.sku, "requested": e.requested})
raise HTTPException(409, "out of stock")
logger.info("order created", extra={"request_id": request.state.request_id,
"order_id": result.id})
return result
Notice the pattern: a single line at the start, a single line at the end, one line per meaningful exception. No logger.info("entering create_order") line at the top of every function. No logger.info("calling orders.create") line before every call. The request id is enough to find any of those moments in a debugger or a trace.
What to log from background tasks and workers
A FastAPI service that uses background tasks or a separate worker process needs the same setup, applied independently. Each process gets its own logging configuration, its own JSON formatter, and its own stdout handler. The trick to making the logs correlate is to generate a job or task id at the boundary and stamp it on every log line the worker emits.
# worker.py
import logging, uuid
logger = logging.getLogger("worker")
job_id = str(uuid.uuid4())
logger.info("job started", extra={"job_id": job_id})
If the worker is called from a request handler, the job id is generated there and passed in. If the worker is called from a queue, the message id from the queue is the natural job id. The principle is the same: one stable identifier for the unit of work, attached to every log line it produces.
The five things that go wrong first
These are the failures that show up in the first week of a new FastAPI service in production. Knowing the failure mode saves an hour of digging.
1. The logs are plain text in production. The fix is a single JsonFormatter class and a stream handler pointed at stdout. Every observability backend ingests JSON; almost none parses Python’s default text format cleanly.
2. The logs go to stdout but the platform drops them. Some platforms aggregate stdout, some aggregate stderr, and some expect a file. On a container host, stdout is almost always right. On a VPS, you may need a process manager that writes to a file the log shipper can read. The Heroku/RunxBuild/Render model of “log to stdout and let the platform handle it” is the one to copy.
3. The request id is not in the error log. This happens when an exception is logged in a deeper layer that does not know about the request. The fix is to either pass the request id down as an argument, or to use contextvars to make it implicitly available. structlog makes this easier than stdlib; with stdlib, a small contextvars.ContextVar plus a custom logging.Filter is enough.
4. The uvicorn error log is going somewhere different. By default uvicorn logs to its own logger, which has its own handlers. Set them all to use the same JSON handler as your app, and the traceback ends up in the same place as the request line.
5. PII in the logs. Email addresses, IP addresses, user agents, and request bodies all leak by default. The fix is a logging filter that redacts known-sensitive fields, and a code review habit of asking “what does this log line contain” before merging. The cheapest version of the filter is a list of field names that get replaced with [REDACTED].
A short checklist before you ship
Before you call FastAPI logging “done,” run this:
- Every log line is JSON, on stdout.
- Every request has a request id, in the log line, in the response header, and (if the upstream provides it) carried through from the inbound header.
- Every exception is logged at
ERRORwith a stack trace, the request id, and the route. - The uvicorn access log is either disabled or routed through the same formatter.
- Background workers and cron jobs have the same setup with a job id.
- PII is filtered.
- There is one log line per successful request, with route, status, and latency.
- There is one log line per failed request, with the same plus an error reason and a stack trace.
That is eight checks. The first time you set this up, it takes an afternoon. Every time after that, it is a copy-paste of the same five files. The cost of doing it well once is much smaller than the cost of doing it badly in production for a year.
If you are building the FastAPI service from scratch and want to see what the rest of the deployment looks like, the Python service docs walk through how a FastAPI app is built, deployed, and observed on RunxBuild. The hosting calculator is the fastest way to get a real monthly number before you commit to a tier.
FAQ
What is the default FastAPI logger?
FastAPI does not configure a logger. It uses uvicorn’s logger, which uses Python’s stdlib logging module. The result is that requests are logged at INFO on uvicorn.access, exceptions are logged at ERROR on uvicorn.error, and your own logging.getLogger("app") calls are routed through the default configuration. The default is fine for development and wrong for production.
Should I use print for logging?
No. print writes to stdout but does not carry level, timestamp, logger name, or traceback, and it cannot be filtered or routed. The only acceptable use of print in a backend service is inside a script that is meant to be run by a human. Anywhere a request handler or background task is running, use logging.
How do I log the request body?
Read await request.body() in middleware, store it on request.state, and add a filter that redacts known-sensitive fields. Logging full request bodies is rarely worth the storage and PII cost, and the cases where it is (a debugging session for one specific bug) are better served by enabling body logging temporarily, not by default.
Where should I configure the logger — in app.py or a separate module?
A separate module. app/logging_config.py imported once at the top of app.py, before anything else, so the configuration is set up before uvicorn starts logging. This pattern also makes it easy to swap configurations between development (text formatter, DEBUG level) and production (JSON formatter, INFO level).
How do I send FastAPI logs to Datadog, Sentry, or ELK?
For ELK and Datadog, the JSON-to-stdout setup is enough — the log shipper parses each line as a structured event. For Sentry, install the Sentry SDK with traces_sample_rate and send_default_pii=False, and the SDK will pick up exceptions and correlate them with the request via the Sentry transaction.
Can I use structlog instead of stdlib?
Yes. structlog is a thin layer over stdlib, and the migration is mostly mechanical — replace logger = logging.getLogger("app") with logger = structlog.get_logger(), and replace logger.info("msg", extra={...}) with logger.info("msg", key=value, ...). The structured output is the same; the call sites read better.
What is the difference between logging and tracing?
A log line is a discrete event with a timestamp and a message. A trace is a connected set of spans that share a trace id and describe a request’s path through a system. OpenTelemetry emits both. For most FastAPI services, structured logs with a request id are enough to debug production issues; add tracing when the request path crosses services or when latency debugging becomes the dominant operational cost.
Why are my uvicorn logs and FastAPI logs in different formats?
Because they are configured separately. Uvicorn configures its own loggers (uvicorn, uvicorn.access, uvicorn.error) with its own handlers, and your app configures its own. Either set them to share handlers (point uvicorn’s loggers at the same handler as your app), or disable uvicorn’s access log (--no-access-log) and log requests in your own middleware.
Closing thought
The default Python logger is the most underused tool in the FastAPI ecosystem. It is already in the standard library, it is already what uvicorn uses, and it is already what every observability backend speaks. The work of getting FastAPI logging to a production level is not “picking the right library” — it is turning the defaults into a structured format, threading a request id through the call, and making sure the exception logs end up next to the request logs. Do that once, and the on-call rotation gets a lot quieter.