Error Handling
|
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. Flask 3.2 is not released yet; the few changes the official changelog announces for error handling are marked as upcoming. Things go wrong in every web application: a client asks for a book that does not exist, posts a body that is too large, calls a method the route does not allow, or the code hits a bug. Flask has one mechanism for all of them. Every failure becomes a Python exception, and Flask looks for an error handler that turns that exception into a response. The page explains the exceptions (they come from Werkzeug), the two ways to register a handler, the exact order in which Flask picks one, how to answer API clients with JSON (including the RFC 9457 Problem Details format) and browsers with HTML pages, what blueprint handlers cannot do, which configuration keys change the behaviour, and how to test all of it. The running example is the Bookshelf application of the previous pages.
How Flask Handles an Error
Flask does not have a separate "error" code path for HTTP errors. A view (or a before hook, or the router) signals a problem by raising an exception. There are two families:
-
HTTP exceptions: instances of
werkzeug.exceptions.HTTPException. Each has a status code, a name and a short description.abort(404)raisesNotFound; the router raisesNotFoundandMethodNotAllowedby itself; reading a missing key fromrequest.formraisesBadRequestKeyError, aBadRequest; exceedingMAX_CONTENT_LENGTHraisesRequestEntityTooLarge. AnHTTPExceptionis also a valid WSGI response: if nobody handles it, Flask returns it as it is, and Werkzeug renders its default HTML page. -
Any other exception: a
ValueErrorfrom your code, aZeroDivisionError, a database driver error. If no handler matches, Flask logs it and answers with a generic500 Internal Server Error.
The page Application & Request Lifecycle shows where this
sits in the request. In short, full_dispatch_request() wraps the before hooks, the view and the after hooks in a
try; an exception goes to handle_user_exception(), which dispatches to handle_http_exception() for HTTP
exceptions and otherwise searches a handler for the exception class. If none is found, the exception propagates out
to wsgi_app(), which calls handle_exception(): it sends the got_request_exception signal, logs the traceback
through app.logger, and builds the 500 response (or re-raises, see the configuration section). The
after_request hooks and the teardown functions still run for the response that results.
The HTTPException Hierarchy and abort
Werkzeug defines one HTTPException subclass per status code in werkzeug.exceptions. Every one has code,
name and description attributes, and the instance can be turned into a response with get_response(). You raise
them with abort(code) from flask, or by raising the class yourself; abort accepts the same arguments as the
exception constructor (description=, response=).
| Code | Class in werkzeug.exceptions |
Typical cause in the Bookshelf |
|---|---|---|
400 |
|
Malformed JSON, a missing form field, a failed validation |
401 |
|
Missing or invalid credentials. Send a |
403 |
|
Authenticated but not allowed (editing someone else’s shelf) |
404 |
|
Unknown URL, or |
405 |
|
|
409 |
|
Creating a book whose ISBN already exists |
413 |
|
The body is larger than |
415 |
|
|
422 |
|
Syntactically valid body that fails business validation |
429 |
|
A rate limit was hit |
500 |
|
An unhandled exception |
502 / 503 |
|
An upstream service failed or the app is overloaded |
The exceptions are instances, so they can carry data. The next example raises them in several ways and shows what a
client sees when no handler is registered: Werkzeug’s default HTML page with the right status code, and the headers
that belong to the error (for example Allow on a 405).
from flask import Flask, abort
from werkzeug.exceptions import Conflict, NotFound
app = Flask(__name__)
BOOKS = {1: {"id": 1, "title": "Dune", "isbn": "9780441013593"}}
@app.get("/books/<int:book_id>")
def get_book(book_id: int):
book = BOOKS.get(book_id)
if book is None:
abort(404, description=f"Book {book_id} is not on the shelf.")
return book
@app.post("/books")
def create_book():
raise Conflict("A book with this ISBN already exists.")
client = app.test_client()
response = client.get("/books/99")
print(response.status_code, response.content_type)
print(response.get_data(as_text=True))
assert response.status_code == 404 and "Book 99 is not on the shelf." in response.text
response = client.delete("/books")
print(response.status_code, response.headers["Allow"])
assert response.status_code == 405 and "POST" in response.headers["Allow"]
assert client.post("/books").status_code == 409
assert issubclass(NotFound, Exception) and NotFound.code == 404
Source: Werkzeug: HTTP Exceptions
abort(code, *args, **kwargs) calls current_app.aborter since Flask 2.2, so an application can register its own
exception classes with the aborter (the aborter_class attribute of the application); without an application context
it falls back to Werkzeug’s abort. A handful of codes need special arguments: abort(401) is more useful with a
WWW-Authenticate header, abort(405) needs the list of valid methods, and abort(429) should carry Retry-After.
For those, give abort a prepared response: abort(Response(...)), or pass response= to the exception.
Source: Flask: API (flask.abort)
Registering Handlers
An error handler is a function that takes the exception and returns anything a view may return. There are two equivalent ways to register one, both on the application and on a blueprint:
-
the decorator
@app.errorhandler(code_or_exception); -
the method
app.register_error_handler(code_or_exception, function), which is what the decorator calls, and is the form to use when the handlers live in a module of their own (typical of an application factory).
The argument is either an integer status code (404, converted to the Werkzeug class that has it) or an exception
class. Registering a code Werkzeug does not know raises ValueError; registering an instance instead of a class
raises TypeError. Handlers may be async def functions when Flask runs with the async extra, and Flask wraps
them with ensure_sync.
from flask import Flask, abort, render_template_string, request
app = Flask(__name__)
@app.get("/books/<int:book_id>")
def get_book(book_id: int):
abort(404)
@app.errorhandler(404)
def not_found(error):
# error is a werkzeug.exceptions.NotFound instance
return f"Nothing at {request.path}", 404
def method_not_allowed(error):
return "That method is not allowed here.", 405
app.register_error_handler(405, method_not_allowed)
client = app.test_client()
assert client.get("/books/1").text == "Nothing at /books/1"
assert client.get("/missing").text == "Nothing at /missing"
assert client.post("/books/1").text == "That method is not allowed here."
Three details of the return value cause most of the bugs found in real handlers.
-
Return the status code yourself. The handler’s return value is converted to a response exactly as a view’s is.
return "Nothing here"produces 200 OK: the client, a search engine, and any monitoring tool see a successful response with an error message. Return a tuple,return body, 404, or useerror.code. -
Headers of the exception are lost. A 405 carries
Allow, a 401 may carryWWW-Authenticate, a 429Retry-After. If the handler builds a fresh response they disappear. Callerror.get_response()to start from the response Werkzeug would have sent, then change the body. -
Errors in the handler are not handled again. An exception raised inside an error handler becomes a plain 500, and the handler is not called for it. Keep handlers short and free of database access.
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.get("/books")
def books():
return jsonify([])
@app.errorhandler(HTTPException)
def keep_headers(error: HTTPException):
response = error.get_response() # status code and Allow header kept
response.data = f"{error.code} {error.name}"
response.content_type = "text/plain"
return response
client = app.test_client()
response = client.delete("/books")
print(response.status_code, response.headers["Allow"], response.text)
assert response.status_code == 405
assert "GET" in response.headers["Allow"]
assert response.text == "405 Method Not Allowed"
How Flask Chooses a Handler
A handler may be registered for a status code, for an HTTP exception class, or for any exception class, on the
application and on blueprints. When several could apply, Flask’s _find_error_handler searches in a fixed order
(this is also the order in which the official documentation lists the rule):
-
a blueprint handler registered for the status code, for the blueprints that handled the request (innermost blueprint first);
-
an application handler for the status code;
-
a blueprint handler for the exception class, walking the class’s method resolution order (MRO), so the most specific class wins;
-
an application handler for the exception class, in the same MRO order;
-
no handler: an
HTTPExceptionis returned as the response, any other exception propagates tohandle_exception().
Because the lookup walks the MRO, a handler for HTTPException (since Flask 1.0) catches every HTTP error that has no
more specific handler, and a handler for Exception catches everything that is not handled more specifically,
including HTTP exceptions.
with a status code?} B -- yes --> C[Look up by status code:
blueprint handlers, then application handlers] C -- found --> H[Call the handler,
use its return value as the response] C -- not found --> D[Look up by class along the MRO:
blueprint handlers, then application handlers] B -- no --> D D -- found --> H D -- not found --> E{HTTP exception?} E -- yes --> F[The exception itself is the response:
Werkzeug default page] E -- no --> G[handle_exception:
send got_request_exception, log, then look for a 500 or InternalServerError handler] G -- found --> I[Call the handler with an InternalServerError
whose original_exception is the cause] G -- not found --> J[Generic 500 page] G -- PROPAGATE_EXCEPTIONS true --> K[Re-raise to the server or the debugger]
The test below registers handlers at every level and records which one answered, so the order stops being something to
memorise. It also demonstrates the trap of the catch-all handler: a handler for Exception that always answers 500
turns a 404 into a 500.
from flask import Flask, Blueprint, abort
app = Flask(__name__)
bp = Blueprint("shelf", __name__, url_prefix="/shelf")
class ShelfFull(Exception):
pass
@bp.get("/full")
def full():
raise ShelfFull()
@bp.get("/gone")
def gone():
abort(404)
@bp.errorhandler(ShelfFull)
def bp_class(error):
return "blueprint, exception class", 409
@bp.errorhandler(404)
def bp_code(error):
return "blueprint, status code", 404
@app.errorhandler(404)
def app_code(error):
return "application, status code", 404
@app.errorhandler(Exception)
def app_catch_all(error):
return f"application, catch-all ({type(error).__name__})", 500
app.register_blueprint(bp)
@app.get("/crash")
def crash():
1 / 0
client = app.test_client()
print(client.get("/shelf/gone").text) # blueprint, status code
print(client.get("/shelf/full").text) # blueprint, exception class
print(client.get("/nothing").text) # application, status code
print(client.get("/crash").text) # application, catch-all
print(client.delete("/crash").status_code, client.delete("/crash").text)
assert client.get("/shelf/gone").text == "blueprint, status code"
assert client.get("/shelf/full").text == "blueprint, exception class"
assert client.get("/nothing").text == "application, status code"
assert client.get("/crash").text == "application, catch-all (ZeroDivisionError)"
# the catch-all swallowed a 405 and turned it into a 500
assert client.delete("/crash").status_code == 500
Catch-all Handlers Done Right
A catch-all is useful (every error leaves the application in the same format), but a naive one hides information. Two rules make it safe.
-
Register
HTTPExceptionfor the HTTP errors and keep the original status code, and registerExceptiononly for the bugs. -
Inside the
Exceptionhandler, return the exception itself when it is anHTTPException, because a handler forExceptionalso receivesNotFound,MethodNotAllowedand the rest. Flask treats a returnedHTTPExceptionas the default response for that error.
Note also that handle_exception() is not reached when a handler matched. Flask logs an unhandled error and sends
got_request_exception only from handle_exception(), so a handler for Exception that returns a response silences
the log: the bug disappears from your logs unless the handler logs it. The next example makes this visible and
fixes it.
import logging
from flask import Flask
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
records: list[str] = []
class Capture(logging.Handler):
def emit(self, record):
records.append(record.getMessage())
app.logger.addHandler(Capture())
@app.get("/crash")
def crash():
raise RuntimeError("database is down")
@app.errorhandler(Exception)
def catch_all(error: Exception):
if isinstance(error, HTTPException):
return error # keep 404, 405 ... as they are
app.logger.exception("Unhandled exception") # the handler must log it itself
return {"error": "internal server error"}, 500
client = app.test_client()
assert client.get("/crash").status_code == 500
assert client.get("/nothing").status_code == 404
print(records)
assert records == ["Unhandled exception"]
The 500 Handler and original_exception
When nothing matches, Flask builds an InternalServerError, logs the traceback, and looks for a handler registered
for 500 or for InternalServerError. Since Flask 1.1 that handler always receives the InternalServerError
instance, and the exception that caused it is available as error.original_exception (it is None when the 500 was
raised deliberately with abort(500)). The distinction matters: a handler registered for 500 never sees a
ZeroDivisionError, only its wrapper. A handler registered for Exception does see the original one, as the previous
example showed.
from flask import Flask, abort
app = Flask(__name__)
app.logger.disabled = True # keep the output of the example short
@app.get("/crash")
def crash():
return {"ratio": 1 / 0}
@app.get("/explicit")
def explicit():
abort(500)
@app.errorhandler(500)
def server_error(error):
original = error.original_exception
cause = type(original).__name__ if original else "none"
return {"error": "internal server error", "cause_type": cause}, 500
client = app.test_client()
print(client.get("/crash").json) # {'cause_type': 'ZeroDivisionError', ...}
print(client.get("/explicit").json) # {'cause_type': 'none', ...}
assert client.get("/crash").json["cause_type"] == "ZeroDivisionError"
assert client.get("/explicit").json["cause_type"] == "none"
Treat original_exception as something for your logs, not for the client. Exception messages contain SQL, file
paths, internal host names and sometimes credentials; the response body should only carry a stable, generic message
and an identifier (see below) that lets you find the full traceback in the logs. If the 500
handler itself raises, Flask gives up and returns the plain default 500.
If the application needs a database rollback after a failed request, do it in a handler for the database error class
or in a teardown_request function (which receives the exception), not by catching Exception in every view.
JSON Error Responses
A browser can show Werkzeug’s HTML page; an API client cannot parse it. The default behaviour of Flask for a JSON API
is therefore wrong: a 404 for /api/books/99 arrives as text/html. The fix is a single handler for
HTTPException that returns JSON. Return the code, a stable machine-readable error name, and a message, and use
error.code so the status is preserved.
from flask import Flask, abort, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
BOOKS = {1: {"id": 1, "title": "Dune"}}
@app.get("/api/books/<int:book_id>")
def get_book(book_id: int):
if book_id not in BOOKS:
abort(404, description="No such book.")
return BOOKS[book_id]
@app.errorhandler(HTTPException)
def http_error(error: HTTPException):
response = error.get_response() # keeps Allow, Retry-After ...
response.data = jsonify(
error=error.name.lower().replace(" ", "_"),
message=error.description,
status=error.code,
).get_data()
response.content_type = "application/json"
return response
client = app.test_client()
response = client.get("/api/books/99")
print(response.status_code, response.get_json())
assert response.status_code == 404
assert response.get_json() == {"error": "not_found", "message": "No such book.", "status": 404}
assert client.post("/api/books/1").get_json()["error"] == "method_not_allowed"
assert "GET" in client.post("/api/books/1").headers["Allow"]
This is the pattern the official documentation recommends for APIs. Flask-smorest and APIFlask, covered in the REST API pages of this section, install an equivalent handler for you; with plain Flask you write it once.
A site that serves both HTML and JSON can pick the format per request. Do not infer it from the removed
request.is_xhr (see the outdated material section). The reliable signals are the URL space (everything
under /api is JSON) or the Accept header, read with request.accept_mimetypes.best_match(["application/json",
"text/html"]).
from flask import Flask, jsonify, render_template_string, request
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
PAGE = "<h1>{{ code }} {{ name }}</h1><p>{{ description }}</p>"
@app.errorhandler(HTTPException)
def negotiated(error: HTTPException):
best = request.accept_mimetypes.best_match(["text/html", "application/json"])
if request.path.startswith("/api/") or best == "application/json":
body = jsonify(status=error.code, error=error.name, message=error.description)
else:
body = render_template_string(
PAGE, code=error.code, name=error.name, description=error.description
)
response = error.get_response()
response.data = body.get_data() if hasattr(body, "get_data") else body
response.content_type = "application/json" if hasattr(body, "get_data") else "text/html; charset=utf-8"
return response
client = app.test_client()
as_json = client.get("/missing", headers={"Accept": "application/json"})
as_html = client.get("/missing", headers={"Accept": "text/html"})
assert as_json.is_json and as_json.get_json()["status"] == 404
assert as_html.content_type.startswith("text/html") and "<h1>404 Not Found</h1>" in as_html.text
assert client.get("/api/missing", headers={"Accept": "text/html"}).is_json
Problem Details (RFC 9457)
A custom {"error": ...} shape works, but every API invents its own. RFC 9457, Problem Details for HTTP APIs
(July 2023, which obsoletes RFC 7807) defines a standard JSON error document with the media type
application/problem+json. Clients and gateways can then handle errors generically. The members it defines:
| Member | Meaning |
|---|---|
|
A URI that identifies the kind of problem and may point to documentation. |
|
A short, human-readable summary that does not change between occurrences of the same problem |
|
The HTTP status code, repeated in the body for the benefit of clients that lose the response line |
|
A human-readable explanation specific to this occurrence |
|
A URI that identifies this occurrence, for example the request path or a correlation id to find it in the logs |
Extension members are allowed (a errors list for validation problems is common). The handler below produces Problem
Details for every HTTP error and for unexpected bugs, keeps the headers Werkzeug set, never exposes the exception
message, and puts a correlation id in instance and in the log.
import uuid
from flask import Flask, abort, jsonify, request
from werkzeug.exceptions import HTTPException, UnprocessableEntity
app = Flask(__name__)
app.logger.disabled = True
def problem(status: int, title: str, detail: str | None = None, **extra):
body = {"type": "about:blank", "title": title, "status": status}
if detail:
body["detail"] = detail
body["instance"] = f"urn:uuid:{uuid.uuid4()}"
body.update(extra)
response = jsonify(body)
response.status_code = status
response.content_type = "application/problem+json"
return response
@app.errorhandler(HTTPException)
def http_problem(error: HTTPException):
response = problem(error.code, error.name, error.description)
for name, value in error.get_response().headers:
if name.lower() in ("allow", "retry-after", "www-authenticate"):
response.headers[name] = value
return response
@app.errorhandler(Exception)
def unexpected(error: Exception):
response = problem(500, "Internal Server Error", "The server could not process the request.")
# the same id goes to the log, where the full traceback is, and to the client
app.logger.exception("Unhandled error %s", response.json["instance"])
return response
@app.post("/books")
def create_book():
data = request.get_json()
if not data.get("title"):
raise UnprocessableEntity("title is required")
return data, 201
@app.get("/boom")
def boom():
raise RuntimeError("password=hunter2 leaked into the message")
client = app.test_client()
response = client.post("/books", json={})
print(response.status_code, response.content_type, response.get_json())
assert response.status_code == 422
assert response.content_type == "application/problem+json"
assert response.get_json()["title"] == "Unprocessable Entity"
response = client.delete("/books")
assert response.status_code == 405 and "POST" in response.headers["Allow"]
response = client.get("/boom")
assert response.status_code == 500
assert "hunter2" not in response.text
When a problem has extra members, pass them as keyword arguments (problem(422, "Validation failed",
errors=[{"field": "title", "message": "required"}])). Return request.path or a generated id in instance, never a
stack trace.
Custom Exception Classes
Application code should raise exceptions that say what went wrong in business terms (BookNotFound,
IsbnAlreadyExists) and leave the HTTP translation to one place. Flask lets you register a handler for any exception
class, so there are two clean designs.
A domain exception plus a handler. Domain code stays free of Flask, and the handler maps the exception to a response. An exception hierarchy lets one handler cover a family.
An HTTPException subclass. Set code and description on the class, and Flask treats it like any built-in
error, including the default HTML page and the handlers registered for HTTPException or for its code.
Registering a code with app.aborter is rarely worth it; raise the class directly.
from flask import Flask
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
BOOKS = {"9780441013593": {"title": "Dune"}}
class BookshelfError(Exception):
"""Base class of every business error of the Bookshelf."""
status_code = 400
code = "bookshelf_error"
def __init__(self, message: str, **details):
super().__init__(message)
self.message = message
self.details = details
class BookNotFound(BookshelfError):
status_code = 404
code = "book_not_found"
class IsbnAlreadyExists(BookshelfError):
status_code = 409
code = "isbn_already_exists"
@app.errorhandler(BookshelfError)
def business_error(error: BookshelfError):
body = {"code": error.code, "message": error.message, **error.details}
return body, error.status_code
class ShelfClosed(HTTPException):
code = 503
description = "The shelf is closed for inventory."
@app.get("/books/<isbn>")
def get_book(isbn: str):
if isbn not in BOOKS:
raise BookNotFound("No such book.", isbn=isbn)
return BOOKS[isbn]
@app.post("/books/<isbn>")
def add_book(isbn: str):
if isbn in BOOKS:
raise IsbnAlreadyExists("Already on the shelf.", isbn=isbn)
raise ShelfClosed()
client = app.test_client()
response = client.get("/books/123")
print(response.status_code, response.get_json())
assert response.status_code == 404 and response.get_json()["code"] == "book_not_found"
assert client.post("/books/9780441013593").status_code == 409
response = client.post("/books/555")
assert response.status_code == 503 and "closed for inventory" in response.text
Do not carry the HTTP status inside the domain layer if the domain is shared with a command line tool or a worker; keep the mapping from exception class to status in the handler (a dictionary is enough) and keep the exception classes free of Flask.
Handlers on Blueprints, and the 404 and 405 Limitation
@bp.errorhandler(...) registers a handler that applies to errors raised while a view of that blueprint is
handling the request. @bp.app_errorhandler(...) is the same call as @app.errorhandler(...): it registers an
application-wide handler from inside the blueprint, which is the right choice for a reusable blueprint that wants
to bring its own error format.
There is a limitation that surprises everyone once. A blueprint cannot handle 404 and 405 errors that are raised by
the router, because the router raises them before a blueprint has been selected: when the URL matches nothing,
request.blueprints is empty. A @bp.errorhandler(404) therefore fires only for a abort(404) inside one of the
blueprint’s views. A request for /api/nothing is answered by the application’s handler, and so is a 405 for a URL
that exists in the blueprint but not for that method.
The official documentation states it plainly, and the workaround is to register the handler on the application and
look at the URL (or request.blueprint) there.
from flask import Blueprint, Flask, abort, jsonify, request
app = Flask(__name__)
api = Blueprint("api", __name__, url_prefix="/api")
@api.get("/books/<int:book_id>")
def get_book(book_id: int):
abort(404)
@api.errorhandler(404)
def api_not_found(error):
return jsonify(error="api: no such book"), 404
@app.errorhandler(404)
def not_found(error):
if request.path.startswith("/api/"): # the router's 404 has no blueprint
return jsonify(error="api: no such route"), 404
return "html: not found", 404
@app.errorhandler(405)
def not_allowed(error):
if request.path.startswith("/api/"):
return jsonify(error="api: method not allowed"), 405, {"Allow": error.get_response().headers["Allow"]}
return "html: method not allowed", 405
app.register_blueprint(api)
client = app.test_client()
print(client.get("/api/books/1").json) # raised by abort(404) in the view: the blueprint handler
print(client.get("/api/nothing").json) # raised by the router: the application handler
print(client.post("/api/books/1").json) # 405 from the router: the application handler
assert client.get("/api/books/1").json == {"error": "api: no such book"}
assert client.get("/api/nothing").json == {"error": "api: no such route"}
assert client.post("/api/books/1").json == {"error": "api: method not allowed"}
assert client.get("/nothing").text == "html: not found"
When blueprints are nested, request.blueprints lists the active blueprint and its parents, and a handler on the
parent applies to the nested one; the most specific blueprint is searched first. The page on blueprints later in this
section covers nesting.
Configuration That Changes Error Handling
Four configuration keys decide whether Flask handles an exception or lets it escape, and three more limit the size of what a client may send, which is the usual source of 413 errors.
| Key | Default | Effect |
|---|---|---|
|
|
If |
|
|
If |
|
|
If |
|
|
Makes |
|
|
Largest request body, in bytes. A larger request is rejected with |
|
|
Largest size in bytes of a non-file field of a |
|
|
Largest number of multipart fields in a request. Exceeding it raises 413. Added in Flask 3.1 |
An important consequence is how a test sees a 500. With TESTING = True, the test client does not return the 500
page: the exception reaches the test and fails it. That is what you want to find bugs, and the opposite of what you want
when testing a 500 handler. See Testing Error Handlers.
from flask import Flask, abort
app = Flask(__name__)
app.logger.disabled = True
@app.get("/crash")
def crash():
raise RuntimeError("bug")
@app.get("/gone")
def gone():
abort(404)
@app.get("/forbidden")
def forbidden():
abort(403)
@app.errorhandler(404)
def not_found(error):
return "handled 404", 404
client = app.test_client()
# Default, TESTING false: the bug becomes a 500 response
assert client.get("/crash").status_code == 500
# TESTING true: the exception propagates to the caller
app.config["TESTING"] = True
try:
client.get("/crash")
except RuntimeError as exc:
print("propagated:", exc)
else:
raise AssertionError("expected the exception to propagate")
# PROPAGATE_EXCEPTIONS can override TESTING explicitly
app.config["PROPAGATE_EXCEPTIONS"] = False
assert client.get("/crash").status_code == 500
# TRAP_HTTP_EXCEPTIONS: an abort() without a handler stops being a plain 403 page
assert client.get("/forbidden").status_code == 403
app.config["TRAP_HTTP_EXCEPTIONS"] = True
assert client.get("/forbidden").status_code == 500 # logged like a bug
assert client.get("/gone").text == "handled 404" # handlers still run
The size limits are enforced when the body is read, not when the headers arrive. A small MAX_CONTENT_LENGTH makes
the test below fail with 413 for a body of 100 bytes. Set the limit at the reverse proxy too: Flask can only refuse
a request after the WSGI server has accepted it.
import io
from flask import Flask, request
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1024 # 1 KiB, small for the example
@app.post("/covers")
def upload():
return request.files["cover"].filename
@app.errorhandler(413)
def too_large(error):
limit = request.max_content_length
return {"error": "payload_too_large", "limit_bytes": limit}, 413
client = app.test_client()
small = client.post("/covers", data={"cover": (io.BytesIO(b"x" * 100), "a.png")})
large = client.post("/covers", data={"cover": (io.BytesIO(b"x" * 5000), "b.png")})
print(small.status_code, large.status_code, large.get_json())
assert small.status_code == 200
assert large.status_code == 413 and large.get_json()["limit_bytes"] == 1024
Custom 4xx and 5xx Pages
For browsers, a styled page is better than Werkzeug’s default. Create a template per error, or one template that takes the status as a variable, and render it from the handler. Keep these rules:
-
Return the status with the page:
return render_template("errors/404.html"), 404. -
Make the error templates independent of everything that can be broken. A
500.htmlthat extends the site layout, which in turn queries the database for a menu, fails with the very error it is reporting. Use a minimal static layout, nosession, no database access, nocurrent_user. -
Show the user a short message and, for 5xx, a correlation id to quote to support. Never show
original_exceptionor the traceback (this is exactly whatDEBUGmode does, which is why it is off in production). -
The pages are for browsers; keep API paths on the JSON handler (see JSON Error Responses).
The templates of this section can be saved in templates/errors/. The example renders them from strings so that it is
self-contained; a real application uses render_template("errors/404.html").
import uuid
from flask import Flask, render_template_string, request
app = Flask(__name__)
app.logger.disabled = True
LAYOUT = """<!doctype html>
<title>{{ code }} {{ title }}</title>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
{% if reference %}<p>Reference: <code>{{ reference }}</code></p>{% endif %}
<p><a href="{{ url_for('index') }}">Back to the Bookshelf</a></p>
"""
@app.get("/")
def index():
return "Bookshelf"
@app.get("/crash")
def crash():
raise RuntimeError("secret internal detail")
@app.errorhandler(404)
def page_not_found(error):
html = render_template_string(
LAYOUT, code=404, title="Page not found",
message=f"We could not find {request.path}.", reference=None,
)
return html, 404
@app.errorhandler(500)
def server_error(error):
reference = uuid.uuid4().hex[:8]
app.logger.error("500 reference=%s", reference, exc_info=error.original_exception)
html = render_template_string(
LAYOUT, code=500, title="Something went wrong",
message="The problem has been recorded.", reference=reference,
)
return html, 500
client = app.test_client()
response = client.get("/nothing")
assert response.status_code == 404 and "We could not find /nothing." in response.text
response = client.get("/crash")
assert response.status_code == 500
assert "secret internal detail" not in response.text and "Reference:" in response.text
The 404 page of a site that uses render_template_string here is only a demonstration. Template inheritance, the
autoescaping of request.path (a user-controlled value, shown above) and the url_for helper are covered in the
Jinja templates page of this section. Autoescaping is what keeps request.path from being an injection point; if you
build an error page by string concatenation, escape it yourself.
Errors, Hooks and Teardown
Because a handled error produces an ordinary response, the machinery around it still runs:
-
after_requestfunctions run for the response of an error handler, including the default 500 page, so security headers and request ids reach error responses too. -
before_requestfunctions do not run again for the handler, and a before hook that raises skips the view and goes to the error handler. -
Teardown functions run last, whatever happened. For an error that a handler turned into a response they receive
Noneas the exception; for an exception that propagated they receive it. -
Errors that happen after the response has started (inside a streaming generator, for example) cannot be turned into an error response, because the status line has already been sent. The generator’s exception reaches the server, which closes the connection. Catch the exception inside the generator, or stream a final error record in the body.
-
Exceptions in
after_requestor in an error handler are not handed to the same handler again; they result in the default 500 page.
from flask import Flask, abort, g
app = Flask(__name__)
events: list[str] = []
@app.before_request
def before():
events.append("before_request")
@app.after_request
def after(response):
events.append(f"after_request {response.status_code}")
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@app.teardown_request
def teardown(exc):
events.append(f"teardown {exc!r}")
@app.errorhandler(404)
def not_found(error):
events.append("errorhandler 404")
return "nope", 404
@app.get("/gone")
def gone():
abort(404)
client = app.test_client()
response = client.get("/gone")
print(events)
assert events == ["before_request", "errorhandler 404", "after_request 404", "teardown None"]
assert response.headers["X-Content-Type-Options"] == "nosniff"
Logging, Notification and the Debugger
An error handler decides what the client sees. What operators see is a separate concern, covered in the page on debugging, logging and profiling of this section; the points that belong here:
-
Flask logs the traceback of an unhandled exception at
ERRORlevel throughapp.loggerfromhandle_exception(). A handler registered for the specific exception class, or forException, bypasses that logging (see Catch-all Handlers Done Right), so log inside it. -
A
RotatingFileHandlerkeeps the error log on disk, anSMTPHandlerwith levelERRORe-mails the maintainers, and a Sentry client with its Flask integration captures unhandled exceptions. The first two attach toapp.logger; thegot_request_exceptionsignal is available for your own reporting code. -
Cloud platforms collect whatever the process writes to standard error; a handler that writes structured log lines there is enough to ship them. The deployment pages of this section cover the platform side.
-
Debug mode turns the Werkzeug debugger on for unhandled exceptions: an interactive console in the browser, protected by a PIN that is printed in the terminal. It is development-only: it runs arbitrary code. Flask-DebugToolbar adds a panel with SQL queries and templates for development. Never enable either in production, and never set
PROPAGATE_EXCEPTIONS = Truethere to "see the errors".
Testing Error Handlers
Tests should prove two things: that each handler produces the right status, body and headers, and that unexpected
errors do not leak details. Use app.test_client(). The only trap is TESTING: with it on, an unhandled exception
propagates to the test instead of being rendered by the 500 handler. To test the 500 handler turn the propagation
off for that test; to test that a bug fails loudly, leave it on and use pytest.raises.
import pytest
from flask import Flask
def create_app(testing: bool = False) -> Flask:
app = Flask(__name__)
app.config.update(TESTING=testing)
app.logger.disabled = True
@app.get("/crash")
def crash():
raise RuntimeError("bug")
@app.errorhandler(500)
def server_error(error):
return {"error": "internal server error"}, 500
@app.errorhandler(404)
def not_found(error):
return {"error": "not found"}, 404
return app
def test_404_is_json():
response = create_app(testing=True).test_client().get("/nothing")
assert response.status_code == 404
assert response.get_json() == {"error": "not found"}
def test_500_handler_renders_generic_error():
app = create_app(testing=True)
app.config["PROPAGATE_EXCEPTIONS"] = False # let the 500 handler answer
response = app.test_client().get("/crash")
assert response.status_code == 500
assert response.get_json() == {"error": "internal server error"}
def test_unhandled_bug_fails_loudly_under_testing():
app = create_app(testing=True)
with pytest.raises(RuntimeError):
app.test_client().get("/crash")
test_404_is_json()
test_500_handler_renders_generic_error()
test_unhandled_bug_fails_loudly_under_testing()
Pitfalls and Security
-
A handler that returns a body without a status code answers
200 OK. Always return the status. -
A catch-all
Exceptionhandler hides the log entry and converts 404 and 405 into 500, unless it passesHTTPExceptionthrough. -
The handler for
500receives anInternalServerError, not the original exception; readoriginal_exception. -
Blueprint handlers do not see the router’s 404 and 405. Use application handlers for those.
-
Never put
str(error)of an unexpected exception into a response: it leaks SQL, paths and secrets. For HTTP exceptions thedescriptionis meant for clients, but a description built from user input must be escaped when rendered into HTML (Werkzeug escapes the default page; your template must too). -
Do not use the debugger or
DEBUG = Truein production. Error pages in debug mode show source code and allow code execution with the PIN. -
A 404 and a 403 can reveal whether a resource exists. For private resources answer 404 to unauthorised users, as GitHub does, if the existence itself is sensitive.
-
Include a correlation id in 5xx responses and the log line, so support can find the traceback without exposing it.
-
Handlers run for every request, including probes and scanners. Keep them cheap and free of database calls.
-
Return an
Allowheader with a 405 andWWW-Authenticatewith a 401, as RFC 9110 requires;error.get_response()does it for you.
What Changed and Where Older Material Is Outdated
The Flask cookbooks for versions of Flask before 2.x, and the full-stack book written for Flask 2, teach the same
handler API that works today: @app.errorhandler(404) returning a tuple has not changed since Flask 0.7. What has
changed around it:
| Older material | Today | Changed in |
|---|---|---|
Handlers registered only for HTTP status codes |
Handlers can be registered for any exception class (including your own), and looked up by code and then by the exception’s class hierarchy |
Flask 0.7 and 1.0 |
A handler for |
The 500 handler receives an |
Flask 1.1.0 |
|
Finalization, including |
Flask 1.1.0 |
|
|
Flask 2.2 |
|
Removed in Werkzeug 1.0. Use the URL space or the |
Werkzeug 1.0 |
RFC 7231 for the meaning of status codes |
Superseded by RFC 9110 |
2022 |
|
RFC 9457 Problem Details, which obsoletes RFC 7807 |
2023 |
Teardown functions after one that fails are skipped |
All teardown functions run, even if one fails. |
Upcoming in Flask 3.2 (unreleased) |
The book code that renders a styled 404.html and 500.html with render_template still works; what the books do
not cover is what to put in the 500 page (nothing that can fail), the handler lookup rules above, the blueprint
limitation, and Problem Details. The Flask 2 era books also run the application with app.run(debug=True) in the
chapters on errors; the documented command is flask --app <module> run --debug, and no deployment should use either.
Their logging examples (rotating file logs, e-mail on error, cloud log shipping) remain valid in principle; the page on
debugging, logging and profiling of this section shows the current setup with dictConfig.
Summary
-
Every failure is an exception.
HTTPExceptionsubclasses from Werkzeug carry a status code;abort(code)raises one. -
Register handlers with
@app.errorhandler(code_or_class)orregister_error_handler, and return the status code. -
Flask picks a handler in this order: blueprint by code, application by code, blueprint by class (MRO), application by class. A handler for
HTTPExceptionorExceptionis a catch-all. -
Return JSON, preferably Problem Details (RFC 9457), for API paths; HTML pages for browsers; keep the headers with
error.get_response(). -
Blueprints do not see the router’s 404 and 405; handle those on the application.
-
The 500 handler gets an
InternalServerError; the cause isoriginal_exception. A handler for the exception class itself bypasses the automatic log, so it must log. -
PROPAGATE_EXCEPTIONS,TRAP_HTTP_EXCEPTIONSandTRAP_BAD_REQUEST_ERRORSdecide what escapes the handlers;TESTINGimplies propagation. -
Never expose exception messages or the debugger to clients.