Debugging, Logging & Profiling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-19), Werkzeug 3.1.9 and Python 3.13, checked on 2026-10-07;
the examples were executed with those versions plus Flask-DebugToolbar 0.16.0, sentry-sdk 2.71.0, Flask-SQLAlchemy 3.1.1
and py-spy 0.4.2. Flask 3.2 is not released yet; the changes its changelog announces do not touch the tools of this page,
but the context rework it brings is a reason to re-run request-aware log filters after upgrading. Every application
reaches the point where it does something nobody expected: a book disappears from a list, a page takes four seconds, an
error shows up only in production. Four families of tools answer that. A debugger lets you stop the program and look
inside it, so it belongs on your machine. Logs record what happened and are the main tool in production. An error
tracker such as Sentry collects exceptions from every server in one place. A profiler tells you where the time goes.
This page covers the four, shows which library does the work in each case (the standard-library logging module,
Werkzeug, Sentry’s SDK, cProfile), and keeps one rule throughout: the interactive debugger is for development
only. The running example is the Bookshelf application of the previous pages.
Which Tool for Which Question
| Question | Tool | Where it runs |
|---|---|---|
"Why did this request crash?" while coding |
Debug mode with the Werkzeug debugger in the browser |
Development only |
"What is the value of this variable at this line?" |
|
Development only (a remote debugger can attach temporarily to a staging server) |
"What happened to that request an hour ago?" |
Logging with a request identifier |
Everywhere; the main tool in production |
"Which errors hit users this week, and how often?" |
An error tracker (Sentry), or error e-mails for small systems |
Production and staging |
"How many queries, templates and config values did this page use?" |
Flask-DebugToolbar |
Development only |
"Which function makes this endpoint slow?" |
|
Development, staging, and py-spy on a live process |
"Why is this page doing 200 queries?" |
SQL logging or recorded queries |
Development and tests; logging in production at a higher level |
Debug Mode and the Werkzeug Debugger
Debug mode is one switch with three effects. Since Flask 2.2 the documented way to turn it on is the --debug option of
the flask command (or the FLASK_DEBUG environment variable; app.run(debug=True) is mostly equivalent when you
start the server from Python):
flask --app bookshelf run --debug
The switch sets app.debug, and the development server (Werkzeug’s run_simple) then enables its two helpers unless
you disable them:
-
The reloader. The server restarts when a source file changes.
flask runexposes it as--reload/--no-reload, with--extra-filesand--exclude-patternsto watch more files or fewer. -
The debugger. When a request raises an unhandled exception, the server shows an interactive traceback in the browser instead of the plain "Internal Server Error" page.
flask runexposes it as--debugger/--no-debugger. -
Flask itself. The application logger drops to
DEBUGwhen it has no explicit level, and unhandled exceptions are re-raised so that the debugger can see them (thePROPAGATE_EXCEPTIONSsetting defaults toNone, which means "on whenTESTINGorDEBUGis on").
The next block shows the Flask side of debug mode with the test client: the logger level, and the difference between an
exception that propagates (debug on, the test client re-raises it) and the ordinary 500 response with debug off.
import io
import logging
from flask import Flask
app = Flask("bookshelf_debug_mode")
app.debug = True
@app.get("/boom")
def boom():
raise RuntimeError("the shelf collapsed")
client = app.test_client()
print("debug:", app.debug, "| logger level:", logging.getLevelName(app.logger.level))
assert app.logger.level == logging.DEBUG
try:
client.get("/boom", environ_overrides={"wsgi.errors": io.StringIO()})
except RuntimeError as exc:
print("propagated to the caller:", exc)
else:
raise AssertionError("debug mode should propagate the exception")
app.debug = False
response = client.get("/boom", environ_overrides={"wsgi.errors": io.StringIO()})
print("debug off:", response.status_code)
assert response.status_code == 500
What the Browser Debugger Does
The debugger is Werkzeug’s DebuggedApplication middleware; run_simple(use_debugger=True) wraps the Flask
application in it, and flask run --debug does the same. When the wrapped application raises, the middleware replies
with an HTML page that shows the traceback, the source around each frame and, with evalex on (the default), a console
icon per frame. Clicking it opens a Python prompt that executes code in that frame. A separate console is served at
/console.
That prompt is a remote shell. For that reason the debugger has three protections, and none is a reason to expose it:
-
A PIN. The first time you use a console, the server prints a PIN on the terminal and the page asks for it. The PIN is stable for a project (it is derived from the machine and the application). The
WERKZEUG_DEBUG_PINenvironment variable overrides it with a number of your choice, or turns the check off with the valueoff. After several wrong guesses the server must be restarted.pin_security=Falseis the matchingDebuggedApplicationargument. -
Trusted hosts. Since Werkzeug 3.0.3 the debugger only serves requests whose
Hostislocalhost, a.localhostname or127.0.0.1.run_simpletrusts the host name it was given as well. Binding the server to0.0.0.0and opening it from another machine therefore shows the plain error page, not the debugger. -
evalex=Falseremoves the interactive console and keeps only the traceback view.
The Flask and Werkzeug documentation say the same thing in the strongest terms: the debugger allows executing arbitrary Python code from the browser, a PIN must not be relied on for security, and the development server and the debugger must never run in production.
This block wraps an application the way flask run --debug does, with the console disabled, and shows the page a
developer would see. It is a development-only illustration; nothing in it is reachable from a network.
from flask import Flask
from werkzeug.debug import DebuggedApplication
from werkzeug.test import Client
dev_app = Flask("bookshelf_dev_debugger")
dev_app.debug = True
@dev_app.get("/books/<int:book_id>")
def show_book(book_id: int):
shelf = {1: "Dune"}
return shelf[book_id] # KeyError for unknown ids
# Development only: this is what run_simple(use_debugger=True) does.
debugged = DebuggedApplication(dev_app, evalex=False)
response = Client(debugged).get("/books/99")
print(response.status_code)
assert response.status_code == 500
assert "Traceback" in response.text and "KeyError" in response.text
Source: Werkzeug: Debugging Applications
Keep Debug Mode Out of Production
Debug mode is a deployment mistake that is easy to make: a .env file copied to a server, a Dockerfile that runs
python app.py. Two habits remove it. First, run production with a WSGI server (the page about deployment, later in
this section, covers Gunicorn and Waitress) and never with flask run or app.run(). Second, make the application
refuse to start in a contradictory state. The factory below reads the stage from the environment and raises when
FLASK_DEBUG is on in production:
import os
from flask import Flask
def create_app() -> Flask:
app = Flask("bookshelf_guarded")
app.config.from_prefixed_env() # FLASK_APP_STAGE -> app.config["APP_STAGE"]
app.config.setdefault("APP_STAGE", "development")
if app.debug and app.config["APP_STAGE"] == "production":
raise RuntimeError("debug mode must not be enabled in production")
return app
os.environ["FLASK_APP_STAGE"] = "production"
os.environ["FLASK_DEBUG"] = "1"
try:
create_app()
except RuntimeError as exc:
print("refused:", exc)
else:
raise AssertionError("the guard should have fired")
finally:
del os.environ["FLASK_APP_STAGE"], os.environ["FLASK_DEBUG"]
assert create_app().debug is False
The page Configuration explains from_prefixed_env() and the other loaders, and
Application Factory & Project Layout shows where the
guard fits. FLASK_ENV, the ENV setting and app.env, which many tutorials and the Cookbook-style recipes use to
select "development" or "production", were deprecated in Flask 2.2 and removed in 2.3; they no longer change
anything. Use --debug or FLASK_DEBUG.
IDE Debuggers and the Development Server
An IDE debugger (PyCharm, VS Code, and others) is more comfortable than the browser debugger: you set breakpoints, step
through the code before anything fails, and inspect objects in a panel. It needs the application to stay in debug mode,
otherwise Flask turns unhandled errors into generic 500 pages, and it needs the Werkzeug debugger and reloader off so
that they do not compete with it. The Flask documentation gives both forms:
flask --app bookshelf run --debug --no-debugger --no-reload
# norun
from bookshelf import create_app
app = create_app()
app.run(debug=True, use_debugger=False, use_reloader=False)
The caveats from the documentation, in the order they bite:
-
If the built-in debugger is not disabled, it catches the exception before the external debugger can.
-
If the reloader is not disabled, a code change made while you sit at a breakpoint can restart the process and drop your session. The reloader also starts a child process, which confuses IDEs that attach to the first process only.
-
With the built-in debugger off, the development server still catches unhandled exceptions, because it would crash on every error otherwise. If you want the exception to reach the IDE (usually you do not), pass
passthrough_errors=Truetoapp.run; the server then dies on the first unhandled error.
VS Code uses the same recipe: a launch configuration of type debugpy that runs the module flask, with the arguments
run --no-debugger --no-reload and the environment variables FLASK_APP and FLASK_DEBUG=1. PyCharm Professional has a
Flask run configuration; Community Edition can run flask as a plain module. The page
CLI & Shell lists the editor integrations. For a server on another machine or in
a container, an IDE debugger can attach in its remote mode (debugpy for VS Code and PyCharm Professional): start the
process with the debug adapter listening only on a loopback or tunnelled address, and do it temporarily. The Flask
documentation recommends exactly that for production problems that cannot be reproduced elsewhere.
pdb and breakpoint()
The oldest debugger needs no IDE. The built-in breakpoint() (Python 3.7 and later) calls sys.breakpointhook(),
which by default starts pdb at that line. Put it in a view, run flask run --debug --no-reload, request the URL, and
the terminal that runs the server turns into a prompt: n (next), s (step into), c (continue), l (list source),
p expr (print), w (where) and q (quit). Running with --no-reload avoids the reloader’s child process, which
be awkward with the terminal; request one URL at a time while you sit at the prompt.
The environment variable PYTHONBREAKPOINT changes what breakpoint() does without editing code: PYTHONBREAKPOINT=0
turns every call into a no-op (a safety net for code that slipped into a release), and a dotted name such as
ipdb.set_trace selects another debugger. Two other pdb entry points are worth knowing: python -m pdb -c continue
script.py runs a script and drops into post-mortem debugging when it fails, and pdb.post_mortem() inspects the
exception being handled. Python 3.14 adds python -m pdb -p PID, which attaches to a running process.
The mechanism that makes breakpoint() replaceable is sys.breakpointhook. The block replaces it with a hook that
records where it was called from, which also makes the pattern testable: the request below "stops" at the line and the
test reads the local variables.
import sys
from flask import Flask
app = Flask("bookshelf_pdb")
stops = []
def recording_hook(*args, **kwargs):
frame = sys._getframe(1)
stops.append((frame.f_code.co_name, sorted(frame.f_locals)))
@app.get("/books/<int:book_id>")
def show_book(book_id: int):
title = {1: "Dune"}.get(book_id, "unknown")
breakpoint() # with the default hook this opens pdb in the server terminal
return title
sys.breakpointhook = recording_hook
try:
response = app.test_client().get("/books/1")
finally:
sys.breakpointhook = sys.__breakpointhook__
print(stops)
assert stops == [("show_book", ["book_id", "title"])]
assert response.text == "Dune"
Source: Python: breakpoint()
Debugging tests follows the same logic: pytest --pdb opens pdb at the first failing assertion, and the test client
re-raises unhandled exceptions when TESTING is on, so the traceback points at your code and not at a 500 page. The
page about testing, later in this section, covers the fixtures.
Logging
Flask does not have its own logging system. It uses the standard-library logging module, and Flask’s contribution is
small: an app.logger attribute, a default handler, and the stream called wsgi_errors_stream. Everything else (levels,
handlers, formatters, filters, rotation, queues) is logging, so the Python logging how-tos apply directly.
What Flask Sets Up by Itself
app.logger is a normal logging.Logger named like the application (app.name, usually the import name of the
package). Flask creates it the first time you read the attribute, and create_logger() does two things then:
-
in debug mode, when the logger has no level, it sets it to
DEBUG; -
when the logging chain has no handler that would accept the logger’s effective level, it adds
default_handler, aStreamHandlerthat writes[time] LEVEL in module: messagetowsgi_errors_stream.
wsgi_errors_stream is a proxy. During a request it resolves to environ["wsgi.errors"], the error stream that the WSGI
server gives the application (Gunicorn sends it to its error log), and outside a request it resolves to sys.stderr.
The effective level of an unconfigured application is the root logger’s WARNING, so app.logger.info() calls disappear
until you configure logging. The test client lets you replace the stream per request, which makes the default behaviour
visible:
import io
from flask import Flask, request
from flask.logging import default_handler
app = Flask("bookshelf_defaults")
@app.get("/hello")
def hello():
app.logger.warning("hello from %s", request.path)
app.logger.info("this info message is below the WARNING level")
return "ok"
errors = io.StringIO()
app.test_client().get("/hello", environ_overrides={"wsgi.errors": errors})
print(errors.getvalue(), end="")
assert default_handler in app.logger.handlers
assert "hello from /hello" in errors.getvalue()
assert "below the WARNING level" not in errors.getvalue()
Configure Logging Before Creating the Application
The documentation’s rule is: configure logging as soon as the program starts, before the Flask object exists. If
app.logger is read first, Flask adds default_handler and your own handlers then log twice or in a different format.
If it happened anyway, app.logger.removeHandler(default_handler) removes it. The tool is
logging.config.dictConfig(), which describes formatters, filters, handlers and loggers as a dictionary that can come
from a Python module or from YAML or JSON (see the page
Configuration for the loaders). The pipeline that results is the one in the figure:
a call on a logger is checked against its level, handed to the handlers of that logger, then propagated to the parent
loggers up to the root; each handler applies its own level, filters and formatter before it writes anywhere.
The next block configures the Bookshelf logger the way a real application would: a console handler that writes to
wsgi_errors_stream (reference it as ext://flask.logging.wsgi_errors_stream in a dictionary), a rotating file with a
size limit, and a filter that copies request details onto every record. The request identifier comes from an incoming
X-Request-ID header when it looks safe, or is generated, and is echoed on the response so that a user can quote it in a
bug report. The file lives in a temporary directory here; in a real deployment prefer standard output (see
Logs in Containers and the Cloud) or an absolute path outside the repository.
import io
import logging
import logging.config
import pathlib
import re
import tempfile
import uuid
from flask import Flask, g, has_request_context, request
LOG_DIR = pathlib.Path(tempfile.mkdtemp(prefix="bookshelf-logs-"))
SAFE_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
class RequestContextFilter(logging.Filter):
"""Copy request details onto the log record (or '-' outside a request)."""
def filter(self, record: logging.LogRecord) -> bool:
if has_request_context():
record.request_id = g.get("request_id", "-")
record.method = request.method
record.path = request.path
else:
record.request_id = record.method = record.path = "-"
return True
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {"request": {"()": RequestContextFilter}},
"formatters": {
"plain": {
"format": "%(asctime)s %(levelname)-8s %(name)s [%(request_id)s] %(method)s %(path)s: %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://flask.logging.wsgi_errors_stream",
"formatter": "plain",
"filters": ["request"],
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "bookshelf.log"),
"maxBytes": 1_000_000,
"backupCount": 5,
"encoding": "utf-8",
"level": "INFO",
"formatter": "plain",
"filters": ["request"],
},
},
"root": {"level": "WARNING", "handlers": ["console"]},
"loggers": {"bookshelf": {"level": "INFO", "handlers": ["console", "file"], "propagate": False}},
}
logging.config.dictConfig(LOGGING) # before the application object exists
app = Flask("bookshelf")
@app.before_request
def assign_request_id():
incoming = request.headers.get("X-Request-ID", "")
g.request_id = incoming if SAFE_ID.fullmatch(incoming) else uuid.uuid4().hex[:12]
@app.after_request
def echo_request_id(response):
response.headers["X-Request-ID"] = g.request_id
return response
@app.get("/books/<int:book_id>")
def show_book(book_id: int):
books = {1: "Dune"}
try:
title = books[book_id]
except KeyError:
app.logger.exception("lookup of book %s failed", book_id) # ERROR with the traceback
return {"error": "not found"}, 404
app.logger.info("book %s served", book_id)
return {"title": title}
client = app.test_client()
console = io.StringIO()
response = client.get("/books/1", headers={"X-Request-ID": "req-1234"}, environ_overrides={"wsgi.errors": console})
client.get("/books/99", environ_overrides={"wsgi.errors": console})
print(console.getvalue())
print((LOG_DIR / "bookshelf.log").read_text(encoding="utf-8").splitlines()[0])
assert response.headers["X-Request-ID"] == "req-1234"
assert "[req-1234] GET /books/1: book 1 served" in console.getvalue()
assert "lookup of book 99 failed" in (LOG_DIR / "bookshelf.log").read_text(encoding="utf-8")
assert "Traceback" in console.getvalue()
Four details of that configuration are worth the attention:
-
The logger named
bookshelfhaspropagateoff. Without it, every record would reach the root logger as well and be written twice. -
The filter is attached to the handler, not to the logger. A filter on a logger is skipped for records that arrive from child loggers, while a filter on a handler sees every record that handler emits.
-
logger.exception()islogger.error()with the active traceback attached. Use it insideexceptblocks. -
Format arguments are passed to the logger (
"book %s served", book_id) and not formatted by the caller. The string is only built when a handler will emit the record, and the arguments cannot inject extra log lines into the format string.
The documentation’s own variant of the request-aware formatter subclasses logging.Formatter, reads request.url and
request.remote_addr when has_request_context() is true, and sets them to None otherwise; the filter above does the
same work in a form that any formatter, text or JSON, can use.
Unhandled Exceptions Are Already Logged
When a view raises and nothing handles the exception, Flask.handle_exception() calls log_exception(), which logs
Exception on <path> [<method>] at ERROR with the traceback through app.logger. That is the record that reaches the
file, the e-mail handler and Sentry’s logging integration in the next sections. The log call happens before your
500 error handler runs. A handler registered for the exception class itself (not for 500) bypasses
the automatic log, so it must log on its own; see Error Handling.
import io
from flask import Flask
crash_app = Flask("bookshelf") # same name: same logger, configured by the dictConfig above
@crash_app.get("/crash")
def crash():
raise ZeroDivisionError("shelf ratio is zero")
console = io.StringIO()
response = crash_app.test_client().get("/crash", environ_overrides={"wsgi.errors": console})
print(response.status_code)
print(console.getvalue().splitlines()[0])
assert response.status_code == 500
assert "Exception on /crash [GET]" in console.getvalue()
assert "ZeroDivisionError: shelf ratio is zero" in console.getvalue()
Source: Flask: API (Flask.log_exception)
Choosing Levels
| Level | Use it for |
|---|---|
|
Detail for a developer: payloads, branch decisions, SQL. Off in production (it can contain personal data) |
|
Business events: a user logged in, an order was created, a job finished |
|
Something unusual that the application handled: a retry, a deprecated API call, a rejected upload |
|
An operation failed and the user noticed. Alerts and Sentry events start here |
|
The application cannot continue: no database, no disk |
Third-party libraries log through their own loggers. Werkzeug logs one line per request to the werkzeug logger (the
development server’s access log; when the root logger has no handler, Werkzeug adds a StreamHandler of its own), and
SQLAlchemy logs statements to sqlalchemy.engine. Set their levels in the same loggers section, or put the handlers on
the root logger, as the Flask documentation shows, so that their messages end up in the same place as yours.
Structured (JSON) Logs
Log aggregators index fields, not sentences. A formatter that writes one JSON object per line needs only the standard
library; the request filter from before supplies the identifier. The same pattern works with a dictionary entry
"formatters": {"json": {"()": JsonFormatter}}.
import io
import json
import logging
from flask import Flask, g
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": getattr(record, "request_id", "-"),
"path": getattr(record, "path", "-"),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload)
json_app = Flask("bookshelf_json")
stream = io.StringIO()
json_handler = logging.StreamHandler(stream)
json_handler.setFormatter(JsonFormatter())
json_handler.addFilter(RequestContextFilter())
json_app.logger.addHandler(json_handler)
json_app.logger.setLevel(logging.INFO)
json_app.logger.propagate = False
@json_app.get("/shelf")
def shelf():
json_app.logger.info("shelf listed")
return "ok"
@json_app.before_request
def set_id():
g.request_id = "req-json-1"
json_app.test_client().get("/shelf")
line = json.loads(stream.getvalue().splitlines()[0])
print(line)
assert line["message"] == "shelf listed" and line["request_id"] == "req-json-1" and line["path"] == "/shelf"
Log Files and Rotation
A log file that is never rotated fills the disk. logging.handlers.RotatingFileHandler(filename, maxBytes=..., backupCount=...)
starts a new file when the current one reaches the size limit and keeps the given number of old ones as app.log.1,
app.log.2 and so on; TimedRotatingFileHandler rotates by time (when="midnight"). The first block of the logging
section already used it through dictConfig. This small demonstration forces a rollover with a tiny limit:
import logging
import logging.handlers
import pathlib
import tempfile
folder = pathlib.Path(tempfile.mkdtemp(prefix="bookshelf-rotation-"))
rotating = logging.handlers.RotatingFileHandler(folder / "app.log", maxBytes=200, backupCount=2, encoding="utf-8")
rotation_logger = logging.getLogger("bookshelf_rotation")
rotation_logger.addHandler(rotating)
rotation_logger.setLevel(logging.INFO)
rotation_logger.propagate = False
for number in range(20):
rotation_logger.info("book %02d was added to the shelf", number)
rotating.close()
names = sorted(path.name for path in folder.iterdir())
print(names)
assert names == ["app.log", "app.log.1", "app.log.2"]
Three limits matter in production. Rotation by renaming files is not safe when several processes write the same file,
which is exactly what a Gunicorn deployment with several workers does; the Python logging cookbook describes the options
(one file per process, a WatchedFileHandler with an external rotation tool such as logrotate, or a socket or queue
that a single process drains). A container has an ephemeral file system, so files vanish with the container. And log
files hold personal data, so they need the same retention and permissions as the database. For most modern deployments
the answer is to avoid files, as the next section explains.
Logs in Containers and the Cloud
The convention for containers is to write logs to standard output or standard error and let the platform collect them. Docker, Kubernetes and the managed container services attach an agent (or a logging driver) that ships each line to a central store (CloudWatch Logs, Google Cloud Logging, Azure Monitor, Loki, Elasticsearch, and similar), where JSON lines are indexed field by field. The application then needs no file handlers, no rotation and no knowledge of the destination. Practical rules:
-
Use the console handler of the
dictConfigabove (orlogging.StreamHandler(sys.stdout)) and a JSON formatter. -
Keep a request identifier in every line, and pass the incoming identifier of a gateway or tracing system through instead of generating a new one when it exists. The page about cloud platforms and monitoring, later in this section, builds on that, and Cloud covers each provider’s service.
-
Under Gunicorn,
--access-logfile -writes the access log to standard output and--error-logfile -writes the error log, which is where the application’swsgi_errors_streamoutput goes, to standard error;--log-levelsets the server’s own verbosity and--capture-outputredirects the process’s standard streams (includingprint) into the error log. The deployment page, later in this section, shows complete commands.
gunicorn --workers 4 --access-logfile - --error-logfile - --log-level info "bookshelf:create_app()"
Source: Gunicorn: Settings (Logging)
Error E-mails with SMTPHandler
For a small system without an error tracker, logging.handlers.SMTPHandler sends an e-mail for each record at or above
a level. The Flask documentation shows it with the level ERROR, a formatter, and the guard if not app.debug so that a
developer’s machine does not send mail. Two problems of the naive setup: the handler opens an SMTP connection inside the
request, so a slow mail server slows down the failing request even more, and a bug that fails on every request sends a
mail per request. A QueueHandler in the request thread and a QueueListener with the SMTPHandler in a background
thread solve the first problem; deduplication or rate limits are what an error tracker adds, which is why the Flask
documentation recommends Sentry when mail becomes overwhelming. The block runs against a tiny SMTP server written
for the example (a real deployment points mailhost at your relay and supplies credentials from the environment,
never from the source file):
import io
import logging
import logging.handlers
import queue
import socketserver
import threading
from flask import Flask, g
class FakeSmtp(socketserver.StreamRequestHandler):
def handle(self):
self.wfile.write(b"220 fake ESMTP\r\n")
body, in_data = [], False
for raw in self.rfile:
line = raw.rstrip(b"\r\n")
if in_data:
if line == b".":
in_data = False
self.server.messages.append(b"\n".join(body).decode("utf-8", "replace"))
self.wfile.write(b"250 queued\r\n")
else:
body.append(line)
continue
command = line[:4].upper()
if command == b"DATA":
in_data = True
self.wfile.write(b"354 end with a lone dot\r\n")
elif command == b"QUIT":
self.wfile.write(b"221 bye\r\n")
break
else:
self.wfile.write(b"250 ok\r\n")
smtp_server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), FakeSmtp)
smtp_server.daemon_threads = True
smtp_server.messages = []
threading.Thread(target=smtp_server.serve_forever, daemon=True).start()
mail_handler = logging.handlers.SMTPHandler(
mailhost=smtp_server.server_address,
fromaddr="bookshelf@example.com",
toaddrs=["admin@example.com"],
subject="Bookshelf application error",
timeout=5,
)
mail_handler.setLevel(logging.ERROR)
mail_handler.setFormatter(
logging.Formatter("%(asctime)s [%(request_id)s] %(levelname)s in %(module)s: %(message)s")
)
log_queue = queue.Queue()
queue_handler = logging.handlers.QueueHandler(log_queue)
queue_handler.addFilter(RequestContextFilter()) # runs in the request thread
listener = logging.handlers.QueueListener(log_queue, mail_handler, respect_handler_level=True)
listener.start()
mail_app = Flask("bookshelf_mail")
mail_app.logger.addHandler(queue_handler)
mail_app.logger.setLevel(logging.INFO)
mail_app.logger.propagate = False
@mail_app.before_request
def mail_request_id():
g.request_id = "req-mail-7"
@mail_app.get("/crash")
def mail_crash():
raise RuntimeError("the index is corrupted")
mail_app.test_client().get("/crash", environ_overrides={"wsgi.errors": io.StringIO()})
listener.stop() # flushes the queue and joins the thread
smtp_server.shutdown()
message = smtp_server.messages[0]
print(message.splitlines()[:6])
assert "Subject: Bookshelf application error" in message
assert "[req-mail-7]" in message and "the index is corrupted" in message
Remember to call listener.stop() when the process shuts down (atexit.register(listener.stop)), or queued records are
lost. Python 3.12 and later can also build the queue, the listener and the handlers from a dictConfig dictionary.
What Not to Log
-
Secrets and personal data: passwords, tokens, session cookies, full
Authorizationheaders, card numbers and e-mail addresses in clear text. Log identifiers (a user id, a book id) instead. Debug-level request dumps are the usual leak. -
Unsanitised user input in the message format: a newline in a user name forges a second log line. Pass user data as arguments, not inside the format string, and prefer JSON, which escapes it.
-
Everything at
DEBUGin production: volume and cost grow quickly, and the library loggers of a database driver or an HTTP client can print credentials in URLs.
The OWASP Logging Cheat Sheet lists the events a security-conscious application should log (authentication, access control failures, input validation failures) and the data it must keep out.
Flask-DebugToolbar
Flask-DebugToolbar is a development extension in the spirit of Django’s debug toolbar. It injects a side panel into every
HTML response and shows, for that request: the versions of Python and Flask, the time spent, the request headers and
variables, the configuration, the templates rendered with their context, the SQL queries of Flask-SQLAlchemy, the log
records, the route list and an optional profiler. It is installed with pip install flask-debugtoolbar and needs
a SECRET_KEY. The panel appears only when the toolbar is enabled (DEBUG_TB_ENABLED, which defaults to app.debug)
and the response is HTML with a <body> tag; JSON responses are left alone.
| Setting | Effect | Default |
|---|---|---|
|
Turns the toolbar on or off |
|
|
Restrict the toolbar to these client addresses |
Empty (everyone) |
|
Show an intermediate page with the toolbar instead of following redirects |
|
|
The panels to load, as dotted class names |
All the built-in panels |
|
Profile each request and show the result in the Profiler panel |
Off |
|
Serve the toolbar’s own routes on a host name (needed with |
None |
|
Needed by the SQLAlchemy panel; set to |
|
import secrets
from flask import Flask
from flask_debugtoolbar import DebugToolbarExtension
toolbar_app = Flask("bookshelf_toolbar")
toolbar_app.debug = True # development only: the toolbar follows app.debug
toolbar_app.config["SECRET_KEY"] = secrets.token_hex()
toolbar_app.config["DEBUG_TB_INTERCEPT_REDIRECTS"] = False
toolbar_app.config["DEBUG_TB_PROFILER_ENABLED"] = True
DebugToolbarExtension(toolbar_app)
@toolbar_app.get("/")
def home():
return "<html><body><h1>Bookshelf</h1></body></html>"
@toolbar_app.get("/api/books")
def api_books():
return {"books": []}
toolbar_client = toolbar_app.test_client()
page = toolbar_client.get("/")
print("toolbar in HTML:", "flDebug" in page.text)
assert "flDebug" in page.text
assert "flDebug" not in toolbar_client.get("/api/books").text
# The same code with debug off: the extension returns early and injects nothing.
prod_app = Flask("bookshelf_toolbar_off")
DebugToolbarExtension(prod_app)
prod_app.add_url_rule("/", "home", home)
assert "flDebug" not in prod_app.test_client().get("/").text
Source: Flask-DebugToolbar documentation
The toolbar changes dispatch_request and adds routes under /_debug_toolbar/, and the SQL panel can re-run SELECT
statements from the browser. That is why it must stay a development dependency, enabled by configuration and never by
accident: keep it out of requirements.txt of the production image, create it inside a factory branch that checks the
stage, and remember that the extension is installed only when DEBUG_TB_ENABLED is true. It does nothing to JSON APIs,
so for an API use the log, the profiler and the recorded queries of the next sections.
Profiling
A profiler answers "where does the time go" with measurements instead of guesses. There are three levels of precision: timing a request, profiling it function by function, and sampling a live process.
Timing Requests
The cheapest tool is a timestamp. before_request stores the start in g, after_request computes the duration, adds
a Server-Timing header (browsers show it in the network panel) and logs a warning for slow requests:
import io
import logging
import time
from flask import Flask, g
timing_app = Flask("bookshelf_timing")
timing_app.config["SLOW_REQUEST_SECONDS"] = 0.05
slow_log = io.StringIO()
slow_handler = logging.StreamHandler(slow_log)
timing_app.logger.addHandler(slow_handler)
timing_app.logger.propagate = False
@timing_app.before_request
def start_timer():
g.started = time.perf_counter()
@timing_app.after_request
def stop_timer(response):
elapsed = time.perf_counter() - g.started
response.headers["Server-Timing"] = f"app;dur={elapsed * 1000:.1f}"
if elapsed > timing_app.config["SLOW_REQUEST_SECONDS"]:
timing_app.logger.warning("slow request: %.0f ms", elapsed * 1000)
return response
@timing_app.get("/slow")
def slow_page():
time.sleep(0.08)
return "done"
response = timing_app.test_client().get("/slow")
print(response.headers["Server-Timing"], "|", slow_log.getvalue().strip())
assert response.headers["Server-Timing"].startswith("app;dur=")
assert "slow request" in slow_log.getvalue()
Source: Flask: API (Flask.after_request)
The page Hooks & View Decorators covers the hooks. The
Server-Timing header is visible to every client, so send durations only, never query text or host names.
Profiling with ProfilerMiddleware
Werkzeug’s ProfilerMiddleware wraps the WSGI application, runs each request under cProfile and either prints the top
functions or writes a .prof file per request. Wrap app.wsgi_app (not app) so Flask’s own extension points keep
working. Its parameters are stream (where to print, None to disable), sort_by (the pstats sort keys, ("time",
"calls") by default), restrictions (the pstats filter, for instance (30,) for the top thirty lines or (0.1,) for
the top ten percent), profile_dir (a directory for .prof files) and filename_format (a format string or callable
for their names, {method}.{path}.{elapsed:.0f}ms.{time:.0f}.prof by default). Since Werkzeug 2.0 the middleware also
puts a werkzeug.profiler item in the WSGI environ.
import io
import pathlib
import pstats
import tempfile
from flask import Flask
from werkzeug.middleware.profiler import ProfilerMiddleware
profiled = Flask("bookshelf_profiled")
def count_primes(limit: int) -> int:
return sum(1 for n in range(2, limit) if all(n % d for d in range(2, int(n**0.5) + 1)))
@profiled.get("/primes")
def primes():
return {"count": count_primes(20000)}
profile_dir = pathlib.Path(tempfile.mkdtemp(prefix="bookshelf-profile-"))
profiled.wsgi_app = ProfilerMiddleware(
profiled.wsgi_app,
stream=None, # do not print; write one .prof file per request
profile_dir=str(profile_dir),
sort_by=("cumulative",),
restrictions=(15,),
)
assert profiled.test_client().get("/primes").json == {"count": 2262}
prof_files = sorted(profile_dir.glob("*.prof"))
print(prof_files[0].name)
report = io.StringIO()
stats = pstats.Stats(str(prof_files[0]), stream=report)
stats.strip_dirs().sort_stats("cumulative").print_stats("primes") # lines that match the regex
print("\n".join(report.getvalue().splitlines()[:14]))
assert "count_primes" in report.getvalue()
Source: Werkzeug: Application Profiler
Read a .prof file with pstats (as above) or with a viewer such as SnakeViz. In the report, tottime is the time spent
inside a function without its callees and cumtime includes them; start from the largest cumtime that belongs to your
code and descend. The profiler slows the code down several times and the output grows with every request, so it is a
development and staging tool: enable it from the factory when an environment variable is set, never unconditionally.
In Werkzeug 3.1.9 the middleware uses profiling.tracing on Python 3.15. python -m cProfile -o out.prof script.py
profiles a script or a CLI command without a server, and tracemalloc (standard library) answers memory questions
the same way.
Sampling a Live Process with py-spy
ProfilerMiddleware has to be installed in the code. When a production process is already slow, stuck or using a lot of
CPU, py-spy attaches from outside and samples the stacks of a running Python process with small overhead and no code
change. Its commands:
py-spy dump --pid 12345 # where is every thread right now?
py-spy top --pid 12345 # a live "top" of the functions using CPU
py-spy record --pid 12345 --duration 30 -o profile.svg # a flame graph, flamegraph is the default format
py-spy record --pid 12345 --subprocesses -f speedscope -o profile.json
Source: py-spy on GitHub
dump is the first thing to try on a hung request, because it prints the current stack of every thread (--locals
adds the local variables). With Gunicorn, use the PID of a worker, or --subprocesses on the master. Attaching to another
process needs ptrace permission: typically sudo on Linux and macOS, and in Docker the container must be started with the
SYS_PTRACE capability (a Kubernetes pod needs it too), so decide in advance whether a debug image or an ephemeral
debug container is acceptable in your environment. py-spy is a sampler, so very short functions can be missed.
SQL: Echo, Logging and Recorded Queries
In a database-backed application the slow part is usually the queries, and the commonest problem is the N+1 pattern: one query for the list, then one more for each row’s related data. Three tools see the SQL:
-
The
sqlalchemy.enginelogger. At levelINFOit logs every statement; atDEBUGit also logs result rows. It is standardlogging, so it follows the handlers you configured.create_engine(echo=True)and Flask-SQLAlchemy’sSQLALCHEMY_ECHOsetting are shortcuts that attach a handler to the same logger. Do not leave it on in production. -
SQLALCHEMY_RECORD_QUERIESmakes Flask-SQLAlchemy record the statements of each request (statement, parameters, duration, location in your code). Read them withflask_sqlalchemy.record_queries.get_recorded_queries()inside the request or application context. Since Flask-SQLAlchemy 3.0 recording is no longer switched on by debug or testing mode; Flask-DebugToolbar turns it on for itself. -
The database’s own tools (
EXPLAIN, slow query log,pg_stat_statements) for what the application cannot see.
The block counts queries per request through a header, which makes the N+1 problem visible, and fixes it with
selectinload:
import io
import logging
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.record_queries import get_recorded_queries
from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
class Author(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
author: Mapped[Author] = relationship(back_populates="books")
sql_app = Flask("bookshelf_sql")
sql_app.config.update(SQLALCHEMY_DATABASE_URI="sqlite://", SQLALCHEMY_RECORD_QUERIES=True)
db.init_app(sql_app)
sql_log = io.StringIO()
sql_logger = logging.getLogger("sqlalchemy.engine")
sql_handler = logging.StreamHandler(sql_log)
sql_logger.addHandler(sql_handler)
sql_logger.setLevel(logging.INFO)
sql_logger.propagate = False
@sql_app.after_request
def count_queries(response):
response.headers["X-Query-Count"] = str(len(get_recorded_queries()))
return response
@sql_app.get("/authors/slow")
def authors_slow():
return {a.name: len(a.books) for a in db.session.scalars(select(Author))} # 1 + N queries
@sql_app.get("/authors/fast")
def authors_fast():
query = select(Author).options(selectinload(Author.books))
return {a.name: len(a.books) for a in db.session.scalars(query)} # 2 queries
with sql_app.app_context():
db.create_all()
for name in ("Herbert", "Le Guin", "Asimov", "Butler"):
db.session.add(Author(name=name, books=[Book(title=f"{name} {i}") for i in range(2)]))
db.session.commit()
sql_log.truncate(0)
sql_log.seek(0)
sql_client = sql_app.test_client()
slow = sql_client.get("/authors/slow")
fast = sql_client.get("/authors/fast")
print("slow:", slow.headers["X-Query-Count"], "queries | fast:", fast.headers["X-Query-Count"], "queries")
assert slow.json == fast.json
assert int(slow.headers["X-Query-Count"]) == 5 and int(fast.headers["X-Query-Count"]) == 2
assert "SELECT" in sql_log.getvalue()
sql_logger.removeHandler(sql_handler)
sql_logger.setLevel(logging.NOTSET)
sql_logger.propagate = True
A test that asserts a query count (assert len(get_recorded_queries()) <= 2) turns a performance regression into a
failing test. The page Flask-SQLAlchemy explains sessions, relationships and
loading strategies.
Error Tracking with Sentry
Logs answer "what happened to this request". They are poor at answering "which errors are new this week and how many users do they affect". An error tracker receives each exception with its traceback, request data and release, groups duplicates and notifies you about new ones. The Flask documentation recommends Sentry, which is available as a hosted service and as a self-hosted source-available project. Install the SDK with the Flask extra:
pip install "sentry-sdk[flask]"
The SDK hooks into Flask through its signals: request_started supplies the request data and
got_request_exception sends the exception. In sentry-sdk 2.x the Flask integration is enabled automatically when Flask
is installed, so sentry_sdk.init() is enough; passing integrations=[FlaskIntegration()] (the form in the Flask
documentation and in older tutorials) is still accepted and is how you set its options, such as transaction_style
("endpoint" by default, or "url") and http_methods_to_capture. Because the SDK also installs a logging integration,
records at ERROR or above become events and records at INFO or above become breadcrumbs attached to later events.
The DSN identifies the project and contains a key, so it is configuration, not source code: read it from the environment, and make the application work without it (development, tests). Keep the initialisation as early as possible, before the application is created.
import io
import os
import secrets
import sentry_sdk
from flask import Flask
from sentry_sdk.integrations.flask import FlaskIntegration
captured = []
def scrub_and_capture(event, hint):
"""before_send: last chance to remove data. Returning None drops the event."""
request_data = event.get("request", {})
request_data.pop("cookies", None)
captured.append(event)
return None # this demo keeps the event local instead of sending it
def init_sentry() -> bool:
dsn = os.environ.get("SENTRY_DSN")
if not dsn:
return False # no DSN, no tracking: development and tests
sentry_sdk.init(
dsn=dsn,
integrations=[FlaskIntegration(transaction_style="endpoint")],
environment=os.environ.get("APP_STAGE", "development"),
release=os.environ.get("APP_RELEASE"),
send_default_pii=False, # no IP addresses, cookies or users by default
traces_sample_rate=0.0, # performance tracing off
before_send=scrub_and_capture,
)
return True
# The DSN of the demo is generated and points to a closed local port.
os.environ["SENTRY_DSN"] = f"http://{secrets.token_hex(16)}@127.0.0.1:9/1"
assert init_sentry()
sentry_app = Flask("bookshelf_sentry")
@sentry_app.get("/crash")
def sentry_crash():
raise ValueError("isbn checksum failed")
@sentry_app.get("/warn")
def sentry_warn():
sentry_app.logger.error("import job %s finished with errors", 42)
return "logged"
test_client = sentry_app.test_client()
test_client.get("/crash", headers={"Authorization": "Bearer not-a-real-token"}, environ_overrides={"wsgi.errors": io.StringIO()})
test_client.get("/warn", environ_overrides={"wsgi.errors": io.StringIO()})
sentry_sdk.flush()
kinds = [
event["exception"]["values"][0]["type"] if "exception" in event else event.get("logentry", {}).get("formatted")
for event in captured
]
print(kinds)
assert "ValueError" in kinds
assert any(kind and "import job 42" in kind for kind in kinds)
crash_event = next(e for e in captured if "exception" in e and e["exception"]["values"][0]["type"] == "ValueError")
print(crash_event["request"]["headers"].get("Authorization"))
assert crash_event["request"]["headers"].get("Authorization") in (None, "[Filtered]")
sentry_sdk.get_client().close()
del os.environ["SENTRY_DSN"]
Source: Sentry: Flask Integration
Choices that matter, all documented by Sentry:
-
send_default_pii=False(the default) keeps IP addresses, cookies and user data out of events. Turn it on only after a privacy review. The SDK also masks common sensitive keys such asAuthorizationheaders;before_sendis the place for your own rules, and the demonstration above shows that the bearer token never appears in the event. -
environmentandreleaselet you filter issues by stage and by deployment, and tell whether a fix worked. -
traces_sample_ratecontrols performance tracing (a fraction of requests); keep it low in production. -
Workers get their own initialisation: the SDK has integrations for Celery, RQ and others, described on the page Background Jobs, Celery & E-mail.
-
The SDK listens to
got_request_exceptionand toERRORlog records. Flask sends that signal, and logs the exception, only for exceptions that no error handler dealt with (and before a handler for500runs). A handler registered for the exception class itself hides the error from Sentry unless the handler logs it atERRORor callssentry_sdk.capture_exception(). HTTP errors such as a 404 fromabort()are not unhandled exceptions and are not reported.
The same pattern applies to other trackers (Rollbar, Bugsnag, Honeybadger, OpenTelemetry exporters): initialise from the environment, scrub before sending, tag with release and stage.
A Practical Workflow
-
Reproduce with the test client or
curland a fixed request identifier. A failing test is the best bug report. -
Read the log around that identifier, from the beginning of the request. The first
ERRORand its traceback usually name the cause. -
Stop the program with
--debugand the browser debugger for an exception, or withbreakpoint()or the IDE when nothing fails but the data is wrong. -
Measure before optimising: the timing header, then the profiler or the recorded queries, then py-spy for a production process.
-
Close the loop: add a regression test, keep the log line that would have shown the problem, and make the error tracker group the issue under a release.
Pitfalls and Security
| Mistake | Consequence | Fix |
|---|---|---|
|
Anyone who reaches the port can try the debugger; unhandled errors expose source and configuration |
A WSGI server, the guard from Debug Mode, and a deployment check for the flag |
Binding the development server to |
The development server and, behind it, the debugger are reachable from the network (Werkzeug still limits the debugger to trusted hosts, which is a second line of defence) |
Use a tunnel, a trusted host name and a development network only |
Disabling the PIN ( |
Any local user can open the console |
Keep it on; use |
Reading |
|
Configure logging first, or remove |
Handlers on the app logger plus |
Every record is written twice |
One place for handlers; |
Multiple Gunicorn workers writing one rotating file |
Lost or interleaved lines, rotation errors |
Log to standard output, or one file per process, or an external rotator |
|
Slow requests when the mail server is slow, a flood of mails when a bug repeats |
|
Logging request bodies, headers and tokens |
Credentials and personal data in log storage |
Log identifiers; scrub in Sentry’s |
|
Personal data leaves your infrastructure; anyone can send events to your project |
Environment variable, review, rate limits in the Sentry project |
Flask-DebugToolbar or |
A SQL console and detailed internals, plus a large slowdown |
Development dependency, enabled only by configuration |
|
No levels, no request identifier, lost under some servers |
|
Where Older Material Is Outdated
The Cookbook-style books and many tutorials teach this topic with recipes that still run but are no longer the documented form.
-
FLASK_ENV,ENVandapp.envselect development mode in old.flaskenvfiles and Dockerfiles. They were deprecated in Flask 2.2 and removed in 2.3. Useflask --app <module> run --debugorFLASK_DEBUG. -
app.run(debug=True)in a container or in production, andpython app.pyas the container command, run the development server. Use a WSGI server; the debugger must never be reachable. -
Handlers added after creating the application, typically inside
if not app.debug:and afterFlask(__name__), are still valid for a single handler, but the documented approach isdictConfigbefore the application object exists. -
Sentry initialisation with only
integrations=[FlaskIntegration()]is still accepted; in sentry-sdk 2.x the integration is enabled automatically, and the options to set areenvironment,release,send_default_piiandtraces_sample_rate. -
Tests and test runners:
unittest.mockpatching,nose2and coverage appear in the same chapters. nose is unmaintained and nose2 is marginal; the Flask documentation uses pytest, and the page about testing, later in this section, covers fixtures, mocking and coverage.pytest --pdbreplaces the "print and rerun" cycle of those books. -
Profiling with
ProfilerMiddlewareis unchanged in spirit; the middleware gotwerkzeug.profilerinenvironin 2.0 and Python 3.15 support in 3.1.9, and the debugger got the trusted-host limit in 3.0.3 (the Cookbook-era Werkzeug had none).
Recent Changes
| Version | Change |
|---|---|
Flask 2.2 |
|
Flask 2.3 |
|
Werkzeug 3.0.3 |
The debugger serves only |
Werkzeug 3.0.4 |
Debugger PIN authentication synchronised across threads and processes |
Werkzeug 3.1.5 |
Fix for |
Werkzeug 3.1.9 |
Better PIN generation inside Podman containers; |
sentry-sdk 2.x |
Flask integration enabled automatically; |
Python 3.14 |
|
Flask 3.2 (unreleased) |
Context handling is reworked; nothing in logging, debug mode or the debugger changes in the announced list, but
re-run filters that call |
Summary
-
Debug mode (
flask run --debug) gives the reloader, the Werkzeug debugger and aDEBUGlogger. The debugger runs code from the browser: development only, behind a PIN and trusted hosts, never in production. -
For an IDE debugger use
--debug --no-debugger --no-reload;breakpoint()andPYTHONBREAKPOINTcoverpdb. -
Flask logging is the standard
loggingmodule: configure it withdictConfigbefore creating the app, write towsgi_errors_streamor standard output, add a request identifier, and rotate files only on a single-process host. -
Unhandled exceptions are logged by
log_exception(); mail them through aQueueHandleror, better, send them to Sentry, configured from the environment and scrubbed inbefore_send. -
Flask-DebugToolbar is a development panel for HTML pages. For performance use timing headers,
ProfilerMiddleware, py-spy on live processes, and recorded SQL queries to find N+1 problems.