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) raises NotFound; the router raises NotFound and MethodNotAllowed by itself; reading a missing key from request.form raises BadRequestKeyError, a BadRequest; exceeding MAX_CONTENT_LENGTH raises RequestEntityTooLarge. An HTTPException is 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 ValueError from your code, a ZeroDivisionError, a database driver error. If no handler matches, Flask logs it and answers with a generic 500 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

BadRequest (and its subclass BadRequestKeyError)

Malformed JSON, a missing form field, a failed validation

401

Unauthorized

Missing or invalid credentials. Send a WWW-Authenticate header with it

403

Forbidden

Authenticated but not allowed (editing someone else’s shelf)

404

NotFound

Unknown URL, or abort(404) for an unknown book id

405

MethodNotAllowed

DELETE /books when the route only allows GET and POST. Carries an Allow header

409

Conflict

Creating a book whose ISBN already exists

413

RequestEntityTooLarge

The body is larger than MAX_CONTENT_LENGTH (or a multipart form exceeds MAX_FORM_MEMORY_SIZE or MAX_FORM_PARTS)

415

UnsupportedMediaType

request.get_json() called on a request whose Content-Type is not JSON

422

UnprocessableEntity

Syntactically valid body that fails business validation

429

TooManyRequests

A rate limit was hit

500

InternalServerError

An unhandled exception

502 / 503

BadGateway / ServiceUnavailable

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

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.

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.

  1. 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 use error.code.

  2. Headers of the exception are lost. A 405 carries Allow, a 401 may carry WWW-Authenticate, a 429 Retry-After. If the handler builds a fresh response they disappear. Call error.get_response() to start from the response Werkzeug would have sent, then change the body.

  3. 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):

  1. a blueprint handler registered for the status code, for the blueprints that handled the request (innermost blueprint first);

  2. an application handler for the status code;

  3. a blueprint handler for the exception class, walking the class’s method resolution order (MRO), so the most specific class wins;

  4. an application handler for the exception class, in the same MRO order;

  5. no handler: an HTTPException is returned as the response, any other exception propagates to handle_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.

flowchart TD A([Exception raised in a hook, a view or the router]) --> B{HTTP exception
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]
Figure 1. How Flask chooses an error handler for an exception

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.

  1. Register HTTPException for the HTTP errors and keep the original status code, and register Exception only for the bugs.

  2. Inside the Exception handler, return the exception itself when it is an HTTPException, because a handler for Exception also receives NotFound, MethodNotAllowed and the rest. Flask treats a returned HTTPException as 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

type

A URI that identifies the kind of problem and may point to documentation. about:blank means "no more information than the status code"

title

A short, human-readable summary that does not change between occurrences of the same problem

status

The HTTP status code, repeated in the body for the benefit of clients that lose the response line

detail

A human-readable explanation specific to this occurrence

instance

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

PROPAGATE_EXCEPTIONS

None

If True, an exception that no handler took is re-raised from handle_exception() instead of becoming a 500. If None the value is True when TESTING or DEBUG is on. In debug mode this is how the interactive debugger gets the traceback

TRAP_HTTP_EXCEPTIONS

False

If True, an HTTP exception that has no registered handler is not returned as the default error page: it is re-raised like any unhandled exception (so it reaches the debugger, or becomes a logged 500). Useful to find an unexpected abort() with a traceback. Registered handlers still run

TRAP_BAD_REQUEST_ERRORS

None

If True, a BadRequest (a missing key in request.form or request.args) is trapped. If None, it is enabled in debug mode, so that the debugger shows which key was missing

TESTING

False

Makes PROPAGATE_EXCEPTIONS effectively true. Test requests raise the original exception instead of returning a 500 page

MAX_CONTENT_LENGTH

None

Largest request body, in bytes. A larger request is rejected with 413 RequestEntityTooLarge. Always set it for an application that accepts uploads

MAX_FORM_MEMORY_SIZE

500000

Largest size in bytes of a non-file field of a multipart/form-data body. Exceeding it raises 413. Added in Flask 3.1

MAX_FORM_PARTS

1000

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.html that 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, no session, no database access, no current_user.

  • Show the user a short message and, for 5xx, a correlation id to quote to support. Never show original_exception or the traceback (this is exactly what DEBUG mode 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_request functions run for the response of an error handler, including the default 500 page, so security headers and request ids reach error responses too.

  • before_request functions 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 None as 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_request or 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 ERROR level through app.logger from handle_exception(). A handler registered for the specific exception class, or for Exception, bypasses that logging (see Catch-all Handlers Done Right), so log inside it.

  • A RotatingFileHandler keeps the error log on disk, an SMTPHandler with level ERROR e-mails the maintainers, and a Sentry client with its Flask integration captures unhandled exceptions. The first two attach to app.logger; the got_request_exception signal 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 = True there 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 Exception handler hides the log entry and converts 404 and 405 into 500, unless it passes HTTPException through.

  • The handler for 500 receives an InternalServerError, not the original exception; read original_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 the description is 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 = True in 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 Allow header with a 405 and WWW-Authenticate with 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 500 that receives the original exception, or one that cannot reach it

The 500 handler receives an InternalServerError; the cause is original_exception

Flask 1.1.0

after_request functions skipped for the default 500 page

Finalization, including after_request, also happens for the default 500 response

Flask 1.1.0

abort always uses Werkzeug’s table of exceptions

abort calls current_app.aborter

Flask 2.2

request.is_xhr to decide between a JSON and an HTML error page

Removed in Werkzeug 1.0. Use the URL space or the Accept header (request.accept_mimetypes)

Werkzeug 1.0

RFC 7231 for the meaning of status codes

Superseded by RFC 9110

2022

{"error": ...} payloads of every shape, or RFC 7807 Problem Details

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. should_ignore_error is deprecated

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. HTTPException subclasses from Werkzeug carry a status code; abort(code) raises one.

  • Register handlers with @app.errorhandler(code_or_class) or register_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 HTTPException or Exception is 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 is original_exception. A handler for the exception class itself bypasses the automatic log, so it must log.

  • PROPAGATE_EXCEPTIONS, TRAP_HTTP_EXCEPTIONS and TRAP_BAD_REQUEST_ERRORS decide what escapes the handlers; TESTING implies propagation.

  • Never expose exception messages or the debugger to clients.