Skip to content

Debug mode and tracebacks

Severity: highApplies to: FastAPI 0.100+Applies to: Starlette 1.xFacts last verified 2026-08-13 against FastAPI 0.141.1 · Starlette 1.6.0 · uvicorn 0.52.3

FastAPI(debug=...) defaults to False, and that default is correct. This page is therefore a verify page: the question is not whether to turn it off but whether something in your deployment turns it on.

The same unhandled RuntimeError, raised from the same endpoint:

debug Status Response body
False 500 Internal Server Error — 21 bytes
True 500 Full traceback as HTML — 5,169 bytes

The debug body is Starlette’s ServerErrorMiddleware rendering the exception: the traceback, the source file paths, the surrounding source lines of each frame, and the exception message. It is a development tool and it is genuinely useful — it is just addressed to whoever made the request.

Absolute filesystem paths, which name your deployment layout and often the OS user. Installed package versions, from the frames that pass through site-packages. Your module and function names, which map the application. And the surrounding source lines of each frame, which is the part people underestimate — a frame inside a function that builds a query publishes that query’s construction.

It does not print local variable values, so it discloses less than some frameworks’ debug pages. That is why this is rated high rather than critical: it is a strong map of the application rather than a direct credential leak.

settings on this page
debugoption

Returns the full traceback page on an unhandled error instead of a bare 500. The default is correct, so this is a page about something you switched on rather than something you forgot — and the usual route in is an environment variable feeding the constructor, not a literal True in the source.

accepts
True | False
default
False
set in
FastAPI(...)
Read in: fastapi 0.141.1 fastapi/applications.py — FastAPI.__init__ signature; reproduced FastAPI().debug is False
the fix
import os
from fastapi import FastAPI
app = FastAPI(
debug=False, # explicit; never read this from an env var
docs_url=None if os.getenv("ENV") == "production" else "/docs",
)

The value being a literal is the point. debug=os.getenv("DEBUG") is the failure mode this control exists for: any non-empty string, including "0" and "false", is truthy in Python.

debug=os.getenv("DEBUG") # "false" -> True. wrong.
debug=os.getenv("DEBUG") == "1" # explicit comparison. correct.
verify it workedRun this in: http response
Terminal window
# force an unhandled error on a route that does not exist in a safe form,
# or hit a known-bad path, and read the body size and shape
curl -s -o /tmp/err.html -w '%{http_code} %{size_download}\n' \
https://api.example.com/__does_not_exist__/
grep -ciE 'Traceback|site-packages|File "/' /tmp/err.html

A production application returns a short body and zero matches. Any hit for Traceback or site-packages means the debug renderer is live.

Check the setting directly if you can reach the process:

Terminal window
python3 -c "from app.main import app; print('debug =', app.debug)"

uvicorn --reload is a development flag, and it tends to travel in the same command: line as everything else in a container image. It does not itself set app.debug, so the two are worth checking separately — but a deployment running with --reload is a deployment running the development invocation, and the debug flag is usually nearby.

Terminal window
# in the running container
ps -o args= -C uvicorn 2>/dev/null || ps aux | grep '[u]vicorn'

--reload in that output means the image is running its development command.

A custom handler for Exception intercepts errors your application raises, which is worth having. It does not change what ServerErrorMiddleware does with an exception raised before your handler is reached — in middleware, in dependency resolution, or during request parsing.

So a handler is a good addition and a bad substitute. debug=False is what actually governs the rendered traceback.

before you ship this

Turning debug off removes the diagnostic you were relying on, and the replacement has to exist before you take it away — otherwise the next 500 is a blank Internal Server Error with nothing behind it.

Make sure the traceback goes somewhere first:

import logging
logger = logging.getLogger("app")
@app.exception_handler(Exception)
async def unhandled(request, exc):
logger.exception("unhandled error on %s %s", request.method, request.url.path)
return JSONResponse({"detail": "Internal Server Error"}, status_code=500)

logger.exception records the traceback server-side. The client gets nothing useful, which is the entire point.

Confirm those records are actually reaching your log destination before you deploy the change — a handler that logs to a stream nobody collects is the same blackout with extra steps.