What’s Changed & Migration

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-18), Werkzeug 3.1.9 (2026-09-27), Jinja 3.1.6, Click 8.5.0, ItsDangerous 2.2.0, MarkupSafe 3.0.4 and Blinker 1.9.0, checked on 2026-10-07. Flask 3.2 has not been released: the official documentation still lists "Version 3.2.0 - Unreleased", so everything described below as "Flask 3.2" is upcoming and may change before the release. Every current-style Python example was executed on Python 3.13 with app.test_client(); deprecated or removed forms appear as the old way and are only run to show the error they raise. A large part of the material on the web, including the Flask books listed in the section bibliography, was written for Flask 1.x, 2.2 or 2.3. This page is the map between that material and today’s documentation: which release changed what, on which date, what the old code looked like, what to write now, and which page of this section covers it.

How to Read the Tables

Each release table lists the kind of change (added, changed, deprecated or removed), what changed, and the page of this section that covers it. Dates are the official release dates from the changelog. "Removed" means the name raises an error, or silently does nothing, in the versions you install today. Flask follows a deprecation policy: a name is deprecated in one feature release, keeps working with a DeprecationWarning, and is removed in a later major or minor release, which is why a lot of old code still works on 2.x and then stops on 2.3 or 3.0.

Two habits make upgrades safe. Pin Flask, Werkzeug and every extension to exact versions (or a lock file) and upgrade one major step at a time. Run the whole test suite with deprecation warnings turned into errors before each step, as the upgrade checklist below explains.

The Dated Baseline

Table 1. Versions this section is written against (checked 2026-10-07)
Component Version Notes

Flask

3.1.3 (2026-02-18)

Python 3.9 or newer. Releases 3.1.1, 3.1.2 and 3.1.3 are bug and security fixes (key rotation, session access tracking).

Flask 3.2

unreleased

The development docs say it drops Python 3.9 and 3.10, so it will need Python 3.11 or newer. The examples of this section use syntax that is valid on 3.10 and newer.

Werkzeug

3.1.9 (2026-09-27)

Python 3.9 or newer. Werkzeug 3.2 is also unreleased: it drops Python 3.9 and 3.10 and removes OrderedMultiDict.

Jinja

3.1.6 (2025-03-05)

jinja2.Markup and jinja2.escape are gone since 3.1; import from markupsafe.

Click

8.5.0 (2026-08-26)

Python 3.10 or newer.

ItsDangerous

2.2.0 (2024-04-16)

Required by Flask 3.1 for the signed session cookie.

MarkupSafe

3.0.4 (2026-10-02)

Provides Markup and escape.

Blinker

1.9.0 (2024-11-08)

A required dependency since Flask 2.3.

Quart

0.23.1 (2026-08-29)

Requires Python 3.13 or newer; builds on Flask’s Sans-IO base classes.

Source: Flask: Changes

Which Release Changed What

Flask makes its breaking changes in two moments: 2.2 and 2.3 (2022 and 2023) removed or deprecated most of the idioms found in books, and 3.0 (2023-09-30) removed what had been deprecated since 2.3. The diagram shows an upgrade order that follows those moments.

flowchart LR A["Flask 1.x or 2.0 code"] --> B["2.1 to 2.2: fix warnings, adopt --app and --debug"] B --> C["2.3: remove FLASK_ENV, before_first_request, JSON_* keys, flask.Markup"] C --> D["3.0 and 3.1: drop the version attribute, add key rotation and limits"] D --> E["3.2 when released: contexts merged, redirect 303, Python 3.11"]
Figure 1. An upgrade path that follows the deprecation steps

Flask 2.0 (2021-05-11)

Kind Change Covered on

Removed

Python 2 and 3.5. Minimums raised to Werkzeug 2, Jinja 3, MarkupSafe 2, ItsDangerous 2 and Click 8 (Click 7 still worked with a warning).

Getting Started

Added

async def views, error handlers, request hooks and teardown functions (pip install "flask[async]").

Async, gevent & Quart

Added

Nested blueprints, and the name= option of register_blueprint (2.0.1) to register one blueprint several times.

Blueprints

Added

Route shortcuts such as @app.get("/books") and @app.post("/books").

Routing & URL Building

Added

Config.from_file(name, load=...) to read JSON, TOML and other formats; Config.from_json is deprecated.

Configuration

Added

Type hints across the package; Scaffold as the common base of Flask and Blueprint.

Introduction & Architecture

Changed

A (response, headers) return tuple now replaces headers on the response instead of extending them.

Responses & JSON

Changed

send_file and send_from_directory wrap the Werkzeug implementations: attachment_filename becomes download_name, cache_timeout becomes max_age and add_etags becomes etag (old names deprecated); send_file now sends Cache-Control: no-cache unless max_age is set.

Responses & JSON

Deprecated

Passing script_info to app factories; helpers.safe_join (use werkzeug.utils.safe_join).

Application Factory & Project Layout

Changed

The JSON helpers no longer use simplejson; flask shell sets up completion and history.

CLI & Shell

Flask 2.1 (2022-03-28)

Kind Change Covered on

Removed

Python 3.6. Click 8 is now required.

Getting Started

Removed

Everything deprecated in 2.0: config.from_json (use config.from_file(name, load=json.load)), safe_join, the test client’s as_tuple, script_info for factories, the encoding parameter of the JSON functions.

Testing

Changed

The same blueprint can no longer be registered twice under the same name; pass name=.

Blueprints

Added

Config.from_prefixed_env() loads FLASK_-prefixed environment variables, parsing values as JSON and using a double underscore for nested keys.

Configuration

Added

--exclude-patterns for the reloader of flask run.

CLI & Shell

Changed

Redirect Location headers stay relative by default (Werkzeug).

Responses & JSON

Deprecated

RequestContext.g (use g directly).

Application & Request Lifecycle

Flask 2.2 (2022-08-01)

Kind Change Covered on

Added

flask --app <module> run --debug: the --app and --debug options replace FLASK_APP and FLASK_ENV for everyday use (--debug itself arrived in 2.2.3, 2023-02-15).

CLI & Shell

Added

--env-file; custom commands on app.cli no longer need @with_appcontext; FlaskGroup can be nested.

CLI & Shell

Removed

flask run --eager-loading and --lazy-loading.

CLI & Shell

Deprecated

FLASK_ENV, app.env and the ENV key: debug mode is now controlled only by --debug or FLASK_DEBUG.

Configuration

Deprecated

app.before_first_request (do setup in the application factory); json_encoder, json_decoder and the JSON_AS_ASCII, JSON_SORT_KEYS, JSONIFY_MIMETYPE and JSONIFY_PRETTYPRINT_REGULAR keys; the session_cookie_name, send_file_max_age_default, use_x_sendfile, propagate_exceptions and templates_auto_reload properties.

Application & Request Lifecycle

Added

The JSON provider interface (app.json, DefaultJSONProvider); app.url_for, app.redirect and app.aborter customisation points.

Responses & JSON

Changed

The application and request contexts are managed with contextvars instead of Werkzeug’s LocalStack; _app_ctx_stack.top and _request_ctx_stack.top are deprecated (store extension data on g with a unique prefix).

Application & Request Lifecycle

Added

Views can return a list (converted to JSON) or a generator (streamed); stream_template and stream_template_string.

Streaming & Server-Sent Events

Added

View.init_every_request = False creates one class-based view instance instead of one per request.

Class-based Views

Changed

Teardown functions always run at the end of the request, even when the context is preserved; request and g work inside the interactive debugger console.

Debugging, Logging & Profiling

Removed

The old send_file and send_from_directory names (attachment_filename, cache_timeout, add_etags, filename); RequestContext.g.

Responses & JSON

Changed

Autoescaping is enabled for .svg templates (2.2.3).

Jinja Templates

Flask 2.3 (2023-04-25)

Kind Change Covered on

Removed

Python 3.7. Minimums raised to Werkzeug 2.3, Jinja 3.1.2, ItsDangerous 2.1.2 and Click 8.1.3.

Getting Started

Removed

Everything that 2.2 deprecated: FLASK_ENV, ENV, app.env; the config-proxy properties; the JSON_* keys; before_first_request and before_app_first_request; json_encoder and json_decoder; json.htmlsafe_dumps. The push and pop of _app_ctx_stack and _request_ctx_stack are gone.

Application & Request Lifecycle

Changed

Calling setup methods (route, add_url_rule, errorhandler, register_blueprint, and so on) after the app handled its first request, or on a blueprint after registration, is an error instead of a warning.

Blueprints

Deprecated

Importing escape and Markup from flask (use markupsafe; 2.3.1 restored the import for a while).

Jinja Templates

Deprecated

app.got_first_request, locked_cached_property, and the signals_available attribute.

Signals

Changed

Blinker (1.6.2 or newer) is a required dependency, signals are always available and receivers may be async.

Signals

Changed

SESSION_COOKIE_DOMAIN no longer falls back to SERVER_NAME; by default no domain is set, which browsers treat as an exact host match.

Sessions, Cookies & Flashing

Changed

Vary: Cookie is set when the session is accessed, modified or refreshed (2.3.2, 2023-05-01, a security fix; also 2.2.5).

Sessions, Cookies & Flashing

Changed

Subdomains are applied with nested blueprints; a blueprint with an empty name raises ValueError; config.from_file(..., text=False) for binary parsers such as tomllib.

Blueprints

Changed

The flask routes command shows each rule’s subdomain or host; pyproject.toml replaces setup.cfg; 2.3.3 switches the build backend to flit_core.

CLI & Shell

Flask 3.0 (2023-09-30)

Kind Change Covered on

Removed

Everything deprecated in 2.3, including flask.Markup and flask.escape, _app_ctx_stack and _request_ctx_stack, signals_available and locked_cached_property.

Application & Request Lifecycle

Deprecated

flask.__version__. Use importlib.metadata.version("flask") instead (removal is scheduled for 3.2).

Introduction & Architecture

Changed

Flask and Blueprint now have Sans-IO base classes, the foundation Quart builds on.

Async, gevent & Quart

Added

self is accepted as an argument to url_for.

Routing & URL Building

Changed

Requires Werkzeug 3.0 or newer, which uses scrypt for generate_password_hash by default.

Authentication & Flask-Login

Patch releases 3.0.1 to 3.0.3 untag session data without relying on json.loads(object_hook=...) (so other JSON providers work) and do not touch hashlib.sha1 at import time (FIPS builds).

Flask 3.1 (2024-11-13)

Kind Change Covered on

Removed

Python 3.8. Minimums raised to Werkzeug 3.1, ItsDangerous 2.2 and Blinker 1.9.

Getting Started

Added

SECRET_KEY_FALLBACKS: a list of old secret keys that can still unsign cookies, for key rotation (extensions must add support). 3.1.1 (2025-05-13) fixes the order in which signing keys are selected.

Sessions, Cookies & Flashing

Added

SESSION_COOKIE_PARTITIONED for the CHIPS cookie attribute.

Sessions, Cookies & Flashing

Added

TRUSTED_HOSTS, checked during routing; MAX_FORM_MEMORY_SIZE and MAX_FORM_PARTS; max_content_length can be set per request on Request.

Security Considerations

Added

PROVIDE_AUTOMATIC_OPTIONS to turn off automatic OPTIONS responses.

Routing & URL Building

Changed

flask run -e path takes precedence over the default .env and .flaskenv; load_dotenv loads the default files in addition to a given path unless load_defaults=False.

Configuration

Changed

host_matching=True and subdomain_matching=False interact correctly with SERVER_NAME; setting SERVER_NAME no longer restricts requests to that domain.

Routing & URL Building

Added

open_resource and open_instance_resource take an encoding parameter (default utf-8).

Application Factory & Project Layout

Fixed

3.1.2 (2025-08-19): stream_with_context works in async views, and session is correct after follow_redirects in the test client. 3.1.3 (2026-02-18): the session is marked as accessed for operations that read only keys (in, len), so Vary: Cookie is set (a security fix).

Streaming & Server-Sent Events

Flask 3.2 (unreleased)

The development changelog lists the items below. Nothing here can be relied upon until the release exists; the Coming in Flask 3.2 section explains what to prepare today.

Kind Change Covered on

Removed

Python 3.9 and 3.10 support, and the deprecated __version__ attribute.

Introduction & Architecture

Changed

RequestContext is merged into AppContext; RequestContext becomes a deprecated alias. An already pushed app context is not reused when dispatching a request.

Application & Request Lifecycle

Changed

Many Flask methods involved in request dispatch take the current AppContext as first parameter. Subclasses overriding them with the old signature keep working during a deprecation period, with a warning.

Application & Request Lifecycle

Changed

All teardown callbacks run even if one of them raises.

Hooks & View Decorators

Deprecated

should_ignore_error; handle errors in teardown handlers instead.

Hooks & View Decorators

Added

template_filter, template_test and template_global work without parentheses.

Jinja Templates

Changed

redirect() returns status 303 by default instead of 302.

Responses & JSON

Changed

provide_automatic_options=True on a view can re-enable OPTIONS when the config disabled it.

Routing & URL Building

Changed

Flask.select_jinja_autoescape compares file extensions case-insensitively.

Jinja Templates

Fixed

IPv6 addresses with a port are parsed correctly in flask run and in the test client.

Testing

Added

The app.query route decorator for the HTTP QUERY method.

Routing & URL Building

Werkzeug Highlights

Werkzeug does the HTTP work under Flask, so its changes show up in your application even when the Flask changelog is silent. These are the ones that matter for application code.

Version Date Change

2.1

2022-03-28

Request and response mixins merged into Request and Response; the test client returns TestResponse; the user agent parser was removed.

2.2

2022-07-23

A new, faster state-machine URL router. A custom converter that must match a / has to set part_isolating = False. MarkupSafe became a dependency.

2.3

2023-04-25

Request.get_json() raises 415 Unsupported Media Type instead of a generic 400 when Content-Type is not JSON. PBKDF2 work factor raised to 600,000 iterations. werkzeug.urls deprecated except uri_to_iri and iri_to_uri. A converter’s part_isolating defaults to False if its regex contains a /.

3.0

2023-09-30

generate_password_hash uses scrypt by default. Removal of everything deprecated in 2.3.

3.1

2024-10-31

Python 3.8 dropped. Request.max_form_memory_size defaults to 500 kB, so oversized non-file form fields raise RequestEntityTooLarge. OrderedMultiDict and ImmutableOrderedMultiDict deprecated. PBKDF2 work factor raised to 1,000,000 iterations. Partitioned (CHIPS) cookies supported. Behaviour of the cache_control properties adjusted. 421 MisdirectedRequest added.

3.1.8 and 3.1.9

2026-04-02 and 2026-09-27

Request.host returns an empty string for a missing or invalid Host; get_host validates the port range; x-www-form-urlencoded data is limited by max_content_length rather than max_form_memory_size; the int converter returns 404 instead of 500 for absurdly long values; a safe_join fix on Windows.

3.2

unreleased

Python 3.9 and 3.10 dropped; OrderedMultiDict removed; many data-structure helpers deprecated in favour of type annotations (ImmutableList, ImmutableDict, TypeConversionDict, and others); request properties are cached.

Code You Will Meet, and What to Write Now

The tables of this section map idioms you will find in books, tutorials and Stack Overflow answers to the current code. Each group is followed by examples that were executed. Where an old form is shown, the example runs it only to print the error it produces on Flask 3.1.

Running the App, the CLI and Packaging

Old idiom What happened Current

export FLASK_APP=app.py and flask run

Still works, but the documented form since 2.2 is the --app option. Without it, app.py or wsgi.py is discovered.

flask --app bookshelf run --debug

FLASK_ENV=development, ENV, app.env, .flaskenv with FLASK_ENV, Dockerfiles that set it

Deprecated in 2.2, removed in 2.3. There is no development/production switch any more.

--debug or FLASK_DEBUG; production never enables it

--eager-loading, --lazy-loading

Removed in 2.2. The app is eager loaded first, then lazily reloaded.

Nothing to pass

@with_appcontext on every app.cli command

Not needed since 2.2: an app context is already active.

@app.cli.command("seed") alone

python app.py with app.run(debug=True) inside Docker or production

The development server and the debugger are never for production: the interactive debugger can execute code.

Gunicorn or Waitress, see the Introduction & Architecture and the Docker and Kubernetes sections

python setup.py install, setup.py develop

Packaging moved to pyproject.toml (the official tutorial uses flit_core).

pip install -e .

virtualenvwrapper

Superseded by the standard library.

python -m venv .venv or uv venv

-e/--env-file, .env, .flaskenv

--env-file arrived in 2.2; in 3.1 -e overrides the default .env files.

flask --env-file .env.local --app bookshelf run

Local development uses the development server with debug mode, which is acceptable only on your own machine:

# Development only: the reloader and the interactive debugger must never face a network
flask --app bookshelf run --debug

# List the registered routes, including subdomain or host rules
flask --app bookshelf routes

# Load a specific dotenv file (it wins over the default .env and .flaskenv)
flask --env-file .env.local --app bookshelf run

In production, a WSGI server imports the application factory. The command below runs it with Gunicorn behind a reverse proxy; debug mode is never enabled:

gunicorn --workers 4 --bind 127.0.0.1:8000 "bookshelf:create_app()"

The current packaging file is the one the official tutorial builds:

[project]
name = "bookshelf"
version = "1.0.0"
description = "A server-rendered book catalogue with a JSON API."
dependencies = [
    "flask",
]

[build-system]
requires = ["flit_core<4"]
build-backend = "flit_core.buildapi"

Names That No Longer Exist

The following example checks the removed names in one place. flask.__version__ still works on 3.1 (with a DeprecationWarning) but is removed in 3.2, so read the version from the package metadata.

import importlib.metadata as metadata
import warnings

import flask
from markupsafe import Markup, escape

print(metadata.version("flask"), metadata.version("werkzeug"))

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    legacy_version = flask.__version__
print([w.category.__name__ for w in caught])  # a DeprecationWarning on 3.1

for name in ("Markup", "escape", "_app_ctx_stack", "_request_ctx_stack"):
    print(name, hasattr(flask, name))  # all False since 3.0

print(escape("<script>"), Markup("<b>trusted</b>"))

app = flask.Flask(__name__)
for name in ("env", "before_first_request", "json_encoder", "json_decoder"):
    print(name, hasattr(app, name))  # all False since 2.3

Application Lifecycle

Old idiom What happened Current

@app.before_first_request

Deprecated 2.2, removed 2.3.

Do one-time setup in the application factory; see the Application Factory & Project Layout

_app_ctx_stack.top, _request_ctx_stack.top

Contexts use contextvars since 2.2; the stacks were removed in 3.0.

current_app, g, has_app_context(), and g attributes with a unique prefix for extension data

Registering routes, blueprints or error handlers after the first request

An error since 2.3.

Finish all setup in the factory before the app serves a request

flask.__version__

Deprecated in 3.0, removed in 3.2.

importlib.metadata.version("flask")

The application factory is where the old before_first_request code belongs. The example also shows the error that late setup raises:

from flask import Flask


def create_app() -> Flask:
    app = Flask(__name__)
    # One-time setup that older code placed in @app.before_first_request
    app.extensions["shelves"] = ["fiction", "science"]

    @app.get("/shelves")
    def shelves():
        return {"shelves": app.extensions["shelves"]}

    return app


app = create_app()
client = app.test_client()
print(client.get("/shelves").json)

try:
    app.add_url_rule("/late", "late", lambda: "too late")
except AssertionError as exc:
    print("AssertionError:", str(exc).splitlines()[0])

Templates and Escaping

Old idiom What happened Current

from jinja2 import Markup, escape

Removed in Jinja 3.1.

from markupsafe import Markup, escape

from flask import Markup, escape

Deprecated in 2.3, removed in 3.0.

from markupsafe import Markup, escape

A Jinja date helper that builds <script>document.write(...)</script> with % formatting inside Markup

An XSS-prone pattern: the formatted string is marked safe without escaping its parts.

Format dates on the server with Babel, or emit <time datetime="..."> and let Intl.DateTimeFormat in the browser localize it; see the Jinja Templates and Internationalization with Flask-Babel pages

.svg templates rendered unescaped

Autoescape is on for .svg since 2.2.3.

Nothing to change; mark markup safe only when you fully trust it

Hand-written streaming responses for templates

stream_template and stream_template_string since 2.2.

stream_template("report.html", rows=rows)

The safe replacement for the date helper never builds HTML out of unescaped strings. Jinja escapes the value, and Markup.format escapes its arguments:

from flask import Flask, render_template_string
from markupsafe import Markup

app = Flask(__name__)


@app.template_filter()
def publication_date(value):
    # Markup.format escapes every argument, so the attribute and the text are safe
    return Markup('<time datetime="{iso}">{iso}</time>').format(iso=value.isoformat())


import datetime

with app.test_request_context():
    html = render_template_string(
        "{{ published | publication_date }} {{ title }}",
        published=datetime.date(2026, 10, 7),
        title='<script>alert("x")</script>',
    )
print(html)

Requests, Responses and JSON

Old idiom What happened Current

request.is_xhr

Removed in Werkzeug 1.0.

Check an explicit header, or better, use separate API routes returning JSON

request.get_json() on a non-JSON body

Werkzeug 2.3 returns 415 instead of a generic 400.

Send Content-Type: application/json; test with request.is_json

send_file(..., attachment_filename=, cache_timeout=, add_etags=), send_from_directory(filename=)

Renamed in 2.0, old names removed in 2.2.

download_name=, max_age=, etag=, path=

app.json_encoder, app.json_decoder, JSON_AS_ASCII, JSON_SORT_KEYS, JSONIFY_MIMETYPE, JSONIFY_PRETTYPRINT_REGULAR

Deprecated in 2.2, removed in 2.3.

A JSON provider (app.json, a DefaultJSONProvider subclass) and its attributes such as sort_keys

Wrapping a list or generator in Response

A returned list becomes JSON and a generator is streamed since 2.2.

return [1, 2] and return generator()

Flask-RESTful resources returning json.dumps(...)

The extension JSON-encodes the return value again, so clients receive a JSON string inside JSON.

Return a dict or use jsonify

The first example covers the renamed send_file arguments and the 415 behaviour:

import io

from flask import Flask, request, send_file

app = Flask(__name__)


@app.post("/reviews")
def add_review():
    data = request.get_json()  # 415 when Content-Type is not application/json
    return {"stars": data["stars"]}, 201


@app.get("/probe")
def probe():
    return {
        "has_is_xhr": hasattr(request, "is_xhr"),
        "wants_json": request.accept_mimetypes.best == "application/json",
    }


client = app.test_client()
print(client.post("/reviews", data="stars=5", content_type="text/plain").status_code)
print(client.post("/reviews", json={"stars": 5}).status_code)
print(client.get("/probe", headers={"Accept": "application/json"}).json)

with app.test_request_context():
    try:
        send_file(io.BytesIO(b"cover"), attachment_filename="cover.png")
    except TypeError as exc:
        print("TypeError:", exc)
    response = send_file(
        io.BytesIO(b"cover"),
        download_name="cover.png",
        as_attachment=True,
        max_age=3600,
        etag=False,
    )
    print(response.headers["Content-Disposition"], response.headers["Cache-Control"])

The JSON provider replaces json_encoder. Setting the old attribute is silently ignored, which is why old code can look fine and still serialise dates wrongly:

import datetime

from flask import Flask
from flask.json.provider import DefaultJSONProvider


class BookshelfJSON(DefaultJSONProvider):
    sort_keys = False  # was the JSON_SORT_KEYS config key

    @staticmethod
    def default(o):
        if isinstance(o, datetime.date):
            return o.isoformat()
        return DefaultJSONProvider.default(o)


app = Flask(__name__)
app.json = BookshelfJSON(app)


@app.get("/published")
def published():
    return {"title": "Dune", "date": datetime.date(1965, 8, 1)}


@app.get("/list")
def as_list():
    return [1, 2, 3]  # a list becomes JSON since 2.2


@app.get("/stream")
def as_stream():
    def pieces():
        yield "a"
        yield "b"

    return pieces()  # a generator is streamed since 2.2


client = app.test_client()
print(client.get("/published").data)
print(client.get("/list").json, client.get("/stream").data)

app.json_encoder = object  # the removed attribute: assigning it changes nothing
print(client.get("/published").data)

The Flask-RESTful double-encoding is easy to see. The first resource returns a string, the second a dict:

import json

from flask import Flask
from flask_restful import Api, Resource

app = Flask(__name__)
api = Api(app)


class ShelfString(Resource):
    def get(self):
        return json.dumps({"shelf": "fiction"})  # double-encoded


class ShelfDict(Resource):
    def get(self):
        return {"shelf": "fiction"}


api.add_resource(ShelfString, "/shelf-string")
api.add_resource(ShelfDict, "/shelf-dict")
client = app.test_client()
print(client.get("/shelf-string").data)
print(client.get("/shelf-dict").data)

Flask-RESTful and its reqparse module are barely maintained. Prefer MethodView with plain return values, then flask-smorest, APIFlask or Flask-RESTX; see the Building REST APIs and OpenAPI & API Documentation pages. Treat Flask-RESTful as code you will meet, not code to start with.

Sessions, Hashing and Secrets

Old claim or idiom What is true now Current

"The session cookie is encrypted"

The default session is signed with ItsDangerous, not encrypted: anyone holding the cookie can read it.

Keep secrets out of session; use server-side sessions (Flask-Session) when the data must stay private

One SECRET_KEY forever

SECRET_KEY_FALLBACKS (3.1) lets old keys unsign cookies during rotation.

Rotate keys: new key in SECRET_KEY, old keys in SECRET_KEY_FALLBACKS

SESSION_COOKIE_DOMAIN falls back to SERVER_NAME

It does not since 2.3: the cookie has no domain, so browsers use an exact host match.

Set the domain explicitly only when subdomains must share the cookie

Cookies without Vary

Vary: Cookie is set whenever the session is accessed (2.3.2, completed in 3.1.3).

Nothing to do; keep proxies and caches honouring Vary

SESSION_COOKIE_PARTITIONED, TRUSTED_HOSTS, MAX_FORM_MEMORY_SIZE, MAX_FORM_PARTS

New in 3.1.

Set them in config; see the Security Considerations page

generate_password_hash with PBKDF2 or an explicit method

Werkzeug 3.0 defaults to scrypt; PBKDF2 uses 1,000,000 iterations since 3.1.

Accept the default and verify with check_password_hash

A hard-coded placeholder string as the secret_key and cloud keys in source

Publishing them hands the keys to everyone.

Generate with secrets.token_hex() and read from config or the environment

The example below decodes a session cookie to show that it is only signed, then rotates the key with SECRET_KEY_FALLBACKS. Keys are generated at run time, never written in source:

import base64
import secrets

from flask import Flask, jsonify, session

old_key = secrets.token_hex()
new_key = secrets.token_hex()

app = Flask(__name__)
app.config["SECRET_KEY"] = old_key


@app.get("/login")
def login():
    session["user_id"] = 42
    return "ok"


@app.get("/me")
def me():
    return jsonify(user=session.get("user_id"))


client = app.test_client()
response = client.get("/login")
cookie = response.headers["Set-Cookie"].split(";")[0].split("=", 1)[1]
payload = cookie.split(".")[0]
payload += "=" * (-len(payload) % 4)
print(base64.urlsafe_b64decode(payload))  # readable: signed, not encrypted
print(response.headers.get("Vary"))

returning = app.test_client()
returning.set_cookie("session", cookie)
app.config["SECRET_KEY"] = new_key  # rotated: the old cookie no longer verifies
print(returning.get("/me").json)
app.config["SECRET_KEY_FALLBACKS"] = [old_key]  # the old key may still unsign
print(returning.get("/me").json)

Password hashing needs no arguments, and the plaintext is never stored:

import secrets

from werkzeug.security import check_password_hash, generate_password_hash

candidate = secrets.token_urlsafe(12)
stored = generate_password_hash(candidate)
print(stored.split("$")[0])  # scrypt:32768:8:1 on Werkzeug 3.x
print(check_password_hash(stored, candidate), check_password_hash(stored, "wrong"))

JWT, Forms and Translations

Old idiom or claim What happened Current

@jwt_required without parentheses, Flask-JWT-Extended 3.x style

Flask-JWT-Extended 4 requires @jwt_required().

@jwt_required()

create_access_token(identity=user.id) with an integer

Recent PyJWT requires the sub claim to be a string; the protected route answers 422 "Subject must be a string".

create_access_token(identity=str(user.id))

A JWT prose that says the client holds the HMAC key, that every request issues a new token, or that localStorage is a safe place

All three are wrong: only the server holds the key, a token is issued at login and on refresh, and localStorage is readable by any injected script (XSS).

Short-lived access tokens, refresh tokens in HttpOnly cookies when the client is a browser; see the JWT & API Authentication page

wtforms.TextField

Removed in WTForms 3.0.

StringField; see the Forms with Flask-WTF page

@babel.localeselector

Removed in Flask-Babel 3.

Babel(app, locale_selector=pick_locale)

import secrets

from flask import Flask
from flask_babel import Babel
from flask_jwt_extended import JWTManager, create_access_token, get_jwt_identity, jwt_required

app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = secrets.token_hex()  # read it from the environment in a real deployment
JWTManager(app)


@app.get("/profile")
@jwt_required()  # the parentheses are required in Flask-JWT-Extended 4
def profile():
    return {"user": get_jwt_identity()}


with app.app_context():
    good = create_access_token(identity=str(7))
    bad = create_access_token(identity=7)  # an integer subject is rejected when the token is checked

client = app.test_client()
print(client.get("/profile", headers={"Authorization": f"Bearer {good}"}).json)
print(client.get("/profile", headers={"Authorization": f"Bearer {bad}"}).status_code)

try:
    from wtforms import TextField  # noqa: F401
except ImportError as exc:
    print("ImportError:", exc.name)

from wtforms import StringField  # noqa: E402,F401


def pick_locale():
    return "es"


babel = Babel()
print(hasattr(babel, "localeselector"))  # False: the decorator is gone
Babel(app, locale_selector=pick_locale)

Data Access

Old idiom What happened Current

Model.query.get(id), Model.query.filter_by(...)

The legacy Query API: SQLAlchemy 2.0 marks it legacy and Flask-SQLAlchemy emits a LegacyAPIWarning.

db.session.get(Model, id), db.get_or_404(Model, id), db.session.execute(db.select(Model)).scalars()

db.Column, untyped models and backref

Works, but the typed form is current.

Mapped[...], mapped_column, relationship(back_populates=...)

lazy="dynamic"

Legacy loader.

WriteOnlyMapped

Query.paginate(page, 10) with positional arguments

TypeError since Flask-SQLAlchemy 3.0: the arguments are keyword-only.

db.paginate(select, page=page, per_page=10)

from flask_sqlalchemy import Pagination

Removed in 3.1.

Use the object returned by db.paginate

db.engine.table_names()

Removed in SQLAlchemy 2.0.

inspect(db.engine).get_table_names()

default=datetime.utcnow()

The parentheses call the function once, at import time, so every row gets the same timestamp. utcnow is also deprecated since Python 3.12.

default=lambda: datetime.now(timezone.utc) or server_default=func.now()

from sqlalchemy.ext.declarative import declarative_base

Moved in SQLAlchemy 2.0.

from sqlalchemy.orm import DeclarativeBase

import datetime as dt
import warnings

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import ForeignKey, String, func, inspect
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


db = SQLAlchemy(model_class=Base)


class Author(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    books: Mapped[list["Book"]] = relationship(back_populates="author")


class Book(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
    author: Mapped[Author] = relationship(back_populates="books")
    created: Mapped[dt.datetime] = mapped_column(default=lambda: dt.datetime.now(dt.timezone.utc))
    added: Mapped[dt.datetime] = mapped_column(server_default=func.now())


app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://"
db.init_app(app)

with app.app_context():
    db.create_all()
    ada = Author(name="Ada")
    db.session.add_all([ada, *[Book(title=f"Title {i}", author=ada) for i in range(25)]])
    db.session.commit()

    print(db.session.get(Book, 1).title)
    print(db.get_or_404(Book, 2).title)
    newest = db.session.execute(db.select(Book).order_by(Book.id).limit(2)).scalars().all()
    print([book.title for book in newest])

    page = db.paginate(db.select(Book).order_by(Book.id), page=2, per_page=10)
    print(page.page, len(page.items), page.pages, page.total)
    try:
        db.paginate(db.select(Book), 2, 10)  # positional arguments
    except TypeError as exc:
        print("TypeError:", exc)

    print(inspect(db.engine).get_table_names())

    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        Book.query.get(1)  # the legacy API still works, with a warning
    print([w.category.__name__ for w in caught])

Pagination and the Pagination object are documented on their own page:

The migration from the old Model.query style to Session calls is described on the Flask-SQLAlchemy page; schema changes are covered by Database Migrations and plain sqlite3 by SQLite3 and Database Basics.

APIs and Contracts

Chapter 6 of the Manning book on microservice APIs applies flask-smorest and marshmallow to a Flask API. Several of its statements are wrong or out of date. The table lists them; the example after it proves the corrections.

Book statement or idiom Correct behaviour Current

Meta.unknown = EXCLUDE rejects unknown fields

The opposite: EXCLUDE silently drops them, and RAISE (the default) rejects them with a validation error.

Choose RAISE for strict contracts

@blp.response(...) stacked above @blp.route(...)

route must be the outermost decorator.

@blp.route(...) first, then @blp.arguments, then @blp.response

@blp.response(Schema) with the schema as the first positional argument

Since flask-smorest 0.25 the first positional argument is the status code.

@blp.response(201, Schema)

Raising marshmallow’s ValidationError inside a view

It produces a 500, not a formatted 422: validation errors are converted only by @blp.arguments.

Let @blp.arguments validate, or call abort(422, ...)

Overriding the served spec by monkeypatching APISpec.to_dict

Possible, but only a workaround.

Use the supported configuration keys and spec_kwargs

OpenAPI 3.0.3

OpenAPI 3.1 is current.

OPENAPI_VERSION = "3.1.0"

RFC 7231

Superseded by RFC 9110.

Cite RFC 9110 for HTTP semantics

Ad hoc error JSON

RFC 9457 defines Problem Details (application/problem+json); the books do not cover it.

Return problem details from error handlers

DELETE answering 202 with a body

202 means "accepted for later processing".

204 with no body, or 200 with a body

No validation library

Validation by hand is error-prone.

marshmallow with flask-smorest, or Pydantic with APIFlask

import logging

from flask import Flask, jsonify
from flask.views import MethodView
from flask_smorest import Api, Blueprint
from marshmallow import EXCLUDE, RAISE, Schema, ValidationError, fields


class StrictBook(Schema):
    class Meta:
        unknown = RAISE

    title = fields.Str(required=True)


class LenientBook(Schema):
    class Meta:
        unknown = EXCLUDE

    title = fields.Str(required=True)


payload = {"title": "Dune", "isbn_typo": "x"}
print(LenientBook().load(payload))  # the unknown field is dropped silently
try:
    StrictBook().load(payload)
except ValidationError as exc:
    print(exc.messages)

app = Flask(__name__)
app.config.update(
    API_TITLE="Bookshelf API",
    API_VERSION="v1",
    OPENAPI_VERSION="3.1.0",
    OPENAPI_URL_PREFIX="/",
    OPENAPI_JSON_PATH="openapi.json",
)
api = Api(app)
blp = Blueprint("books", __name__, url_prefix="/books")


@blp.route("/")  # route is the outermost decorator
class Books(MethodView):
    @blp.arguments(StrictBook)
    @blp.response(201, StrictBook)  # the status code comes first
    def post(self, data):
        return data


@blp.route("/<int:book_id>")
class OneBook(MethodView):
    @blp.response(204)
    def delete(self, book_id):
        return None


@blp.route("/boom")
class Boom(MethodView):
    def get(self):
        StrictBook().load({"nope": 1})  # not validated by @blp.arguments


api.register_blueprint(blp)


@app.errorhandler(404)
def problem_details(exc):
    body = {"type": "about:blank", "title": "Not Found", "status": 404, "detail": "No such resource."}
    return jsonify(body), 404, {"Content-Type": "application/problem+json"}


logging.disable(logging.CRITICAL)  # keep the demo output short
client = app.test_client()
print(client.post("/books/", json=payload).status_code)  # 422: unknown field rejected
print(client.post("/books/", json={"title": "Dune"}).status_code)
print(client.delete("/books/1").status_code)
print(client.get("/books/boom").status_code)  # 500
print(client.get("/openapi.json").json["openapi"])
print(client.get("/missing").content_type)

Marshmallow’s own behaviour for unknown fields is described in its upgrade and schema documentation:

Problem Details are specified by RFC 9457:

Testing

Old idiom What happened Current

nose, nose2

nose is dead and nose2 is marginal.

pytest with app.test_client() and app.test_cli_runner() fixtures

Tests that call a live server with requests

Not unit tests: they need a running process and a real database.

The test client, with TESTING=True and a test database

response.json()

response.json is a property on the Flask test client.

response.json or response.get_json()

response.content

That is the requests attribute.

response.data or response.text

nose-style setup and teardown methods

Removed in pytest 8.

setup_method and teardown_method, or fixtures

Dredd for contract tests

Archived.

Schemathesis (the 4.x CLI differs from 3.x) and Hypothesis; see the API Contract & Property-based Testing page

import pytest
from flask import Flask


def make_client():
    app = Flask(__name__)
    app.config.update(TESTING=True)  # a real project also points SQLALCHEMY_DATABASE_URI at a test database

    @app.get("/ping")
    def ping():
        return {"ok": True}

    return app.test_client()


@pytest.fixture()
def client():
    return make_client()


class TestPing:
    def setup_method(self):  # replaces the nose-style setup removed in pytest 8
        self.client = make_client()

    def test_ping(self):
        response = self.client.get("/ping")
        assert response.status_code == 200
        assert response.json == {"ok": True}  # a property, not response.json()
        assert "ok" in response.get_data(as_text=True)


suite = TestPing()
suite.setup_method()
suite.test_ping()
print("passed")
schemathesis run http://127.0.0.1:8000/openapi.json

See the Testing page for the full testing workflow.

Extensions and Ecosystem

Old idiom What happened Current

CACHE_TYPE = "simple" (Flask-Caching)

Cache-type names are class names now; the old name fails at import.

CACHE_TYPE = "SimpleCache"; see the Caching page

make_celery(app) with a ContextTask and uppercase CELERY_* keys

Celery 5 uses lowercase settings.

celery_init_app with a FlaskTask subclass and a CELERY config dictionary; see Background Jobs, Celery & Email

Sending mail from a bare threading.Thread

The thread has no application context, so current_app fails.

app.app_context() in the worker, copy_current_request_context, or a task queue

Flask-WhooshAlchemy, pip install git+git://...

Dead projects and a protocol GitHub no longer serves.

Maintained search engines; see NoSQL, Redis, MongoDB & Search

Elasticsearch body= arguments

Deprecated in elasticsearch-py 8.

document= and query= keyword arguments

openai.Completion, ChatCompletion, Image.create, text-davinci-003

Removed in openai 1.0 (November 2023); the model was retired in January 2024.

The OpenAI() client and a model that is current when you write the code

CKEditor 4, jQuery 2 from a CDN, Moment.js

CKEditor 4 reached end of life in June 2023; jQuery 2 is obsolete; Moment.js is in maintenance mode.

Current editors, the browser fetch API and Intl

Flask-Dance’s Twitter recipe, python-ldap binds with raw user input

The old Twitter API is gone; unescaped distinguished names allow injection.

See OAuth Social Login & LDAP

Create React App

Deprecated in February 2025.

Vite; see the React section and Single-Page Apps & CORS

The first example shows the Celery integration from the official documentation, including the context-aware task class, and a worker thread that opens its own application context:

import threading

from celery import Celery, Task
from flask import Flask, current_app


def celery_init_app(app: Flask) -> Celery:
    class FlaskTask(Task):
        def __call__(self, *args, **kwargs):
            with app.app_context():
                return self.run(*args, **kwargs)

    celery_app = Celery(app.name, task_cls=FlaskTask)
    celery_app.config_from_object(app.config["CELERY"])
    celery_app.set_default()
    app.extensions["celery"] = celery_app
    return celery_app


app = Flask("digest")
app.config.from_mapping(
    CELERY=dict(broker_url="memory://", result_backend="cache+memory://", task_always_eager=True)
)
celery_app = celery_init_app(app)


@celery_app.task
def app_name():
    return current_app.name


print(app_name.delay().get())

results = []


def send_digest(flask_app: Flask, address: str) -> None:
    with flask_app.app_context():  # without this, current_app is unavailable in the thread
        results.append((current_app.name, address))


worker = threading.Thread(target=send_digest, args=(app, "reader@example.com"))
worker.start()
worker.join()
print(results)

The caching configuration uses the class name:

from flask import Flask
from flask_caching import Cache

app = Flask(__name__)
app.config.update(CACHE_TYPE="SimpleCache", CACHE_DEFAULT_TIMEOUT=60)  # was "simple"
cache = Cache(app)
cache.set("shelf", "fiction")
print(cache.get("shelf"))

The OpenAI SDK moved to a client object. Building the client needs no network access; calling a model is covered on the LLM integration page of this section:

import os

from openai import OpenAI

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", "unset-for-demo"), timeout=30.0, max_retries=2)
print(type(client).__name__, client.max_retries)

Configuration

Several old recipes read configuration from Python modules only. Since 2.0 Config.from_file loads any format, and since 2.1 Config.from_prefixed_env reads environment variables. Binary parsers such as tomllib need text=False.

import os
import pathlib
import tempfile
import tomllib

from flask import Flask

app = Flask(__name__)
folder = pathlib.Path(tempfile.mkdtemp())
(folder / "bookshelf.toml").write_text('DEBUG = false\n[DB]\nPOOL = 5\n')
app.config.from_file(str(folder / "bookshelf.toml"), load=tomllib.load, text=False)
print(app.config["DB"])

os.environ["FLASK_DB__POOL"] = "9"  # a double underscore nests the key
os.environ["FLASK_TITLE"] = '"Bookshelf"'  # values are parsed as JSON
app.config.from_prefixed_env()
print(app.config["DB"], app.config["TITLE"])
print(hasattr(app, "env"), app.config.get("ENV"))  # False None since 2.3

Deployment and Infrastructure

Deployment recipes age faster than code. The items found in the books are outdated as follows; the surrounding topics have their own sections, so this page links instead of repeating them.

Old recipe Current practice

Heroku free tier, Zappa

The free tier no longer exists. Deploy containers or a WSGI server; see the Cloud section.

gcr.io (Container Registry)

Shut down in 2025; use Artifact Registry.

JSON service-account keys stored in GitHub Actions

Workload Identity Federation issues short-lived credentials without a long-lived key.

Aurora Serverless v1

End of life in March 2025; use a current database engine or Aurora Serverless v2.

python:3.8, python:3.9 and buster images; Node 14 images

Use supported base images and pin their versions; see the Docker section.

version: in Compose files, docker-compose (v1)

The version key is obsolete and Compose v2 is the docker compose plugin.

Eventlet

No longer maintained (the Flask docs say so); use gevent, see Async, gevent & Quart

python app.py as the container command

Run Gunicorn or Waitress; see the Kubernetes section for probes and scaling.

A container for the application runs the WSGI server, never the debugger:

FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir . gunicorn
USER nobody
EXPOSE 8000
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:8000", "bookshelf:create_app()"]
services:
  web:
    build: .
    ports:
      - "8000:8000"

Upgrading from 1.x or 2.x to 3.x

Follow the steps in order. Each step is small enough to keep the test suite green.

  • Read the changelog entries between your version and the target and list the removals that apply to your code.

  • Pin every dependency, then upgrade Flask and Werkzeug together with the extensions that support the target.

  • Raise the Python version first: Flask 2.3 needs 3.8, 3.1 needs 3.9, and 3.2 will need 3.11.

  • Run the tests with DeprecationWarning turned into an error and fix each warning before moving on.

  • Replace FLASK_ENV, ENV and app.env with --debug (development) or nothing (production).

  • Move before_first_request code into the application factory and finish all setup before the first request.

  • Replace flask.Markup and flask.escape with markupsafe, and json_encoder settings with a JSON provider.

  • Replace Model.query with db.session.get, db.get_or_404, db.select and db.paginate (keyword arguments).

  • Replace flask.__version__ with importlib.metadata.version("flask").

  • Set SECRET_KEY from the environment and plan key rotation with SECRET_KEY_FALLBACKS.

  • Review TRUSTED_HOSTS, MAX_CONTENT_LENGTH, MAX_FORM_MEMORY_SIZE and MAX_FORM_PARTS for the limits you need.

  • Update extension calls (@jwt_required(), locale_selector=, StringField, SimpleCache).

  • Build and deploy the container with a production WSGI server and re-run the smoke tests.

Turning warnings into errors uses the pytest configuration, so the next step is a test run that fails on any deprecated call:

[tool.pytest.ini_options]
filterwarnings = [
    "error::DeprecationWarning",
]

Coming in Flask 3.2

Flask 3.2.0 is unreleased: its changelog is a work in progress. The list below is what the development docs say today. You can already write code that works on both 3.1 and 3.2.

  • Contexts merge. RequestContext becomes a deprecated alias of AppContext, and an app context that was already pushed is no longer reused when a request is dispatched. Code that pushes an app context and then expects a request to join it must be re-checked; code that subclasses Flask and overrides dispatch methods gets a deprecation warning for the old signatures. See the Application & Request Lifecycle page.

  • redirect() defaults to 303. The client always switches to GET, which keeps the behaviour of both GET and POST redirects and is correct for HTMX. Pass code=303 today to get the future behaviour.

  • All teardown callbacks run. A teardown that raises no longer prevents the others from running. Make each teardown handle its own errors. should_ignore_error is deprecated.

  • Decorators without parentheses. template_filter, template_test and template_global can be used bare. The form with parentheses keeps working.

  • provide_automatic_options=True. A view can re-enable OPTIONS handling when the config turned it off; today only disabling works.

  • app.query. A decorator for the HTTP QUERY method.

  • Smaller items. __version__ is removed, Python 3.9 and 3.10 are dropped, select_jinja_autoescape is case-insensitive, and IPv6 addresses with a port are parsed correctly.

The example runs on 3.1 and behaves the same on 3.2. It uses an explicit 303, the parenthesised template decorator, a QUERY route declared through methods, and teardown handlers that handle their own errors:

from flask import Flask, redirect, render_template_string

app = Flask(__name__)
app.config["PROVIDE_AUTOMATIC_OPTIONS"] = False


@app.route("/search", methods=["QUERY"])  # on 3.2 you can use @app.query("/search")
def search():
    return "results"


@app.get("/old")
def old():
    return "x"


@app.route("/enabled", provide_automatic_options=True)  # re-enables OPTIONS only on 3.2
def enabled():
    return "y"


@app.template_filter()  # parentheses work on 3.1 and 3.2
def shout(value):
    return value.upper()


calls = []


@app.teardown_appcontext
def close_one(error):
    calls.append("one")
    try:
        raise RuntimeError("handled inside the teardown")
    except RuntimeError:
        calls.append("one handled")


@app.teardown_appcontext
def close_two(error):
    calls.append("two")


@app.get("/go")
def go():
    return redirect("/old", code=303)  # explicit: the default on 3.2


client = app.test_client()
print(client.open("/search", method="QUERY").status_code)
print(client.options("/old").status_code, client.options("/enabled").status_code)  # 405 405 on 3.1; 405 200 on 3.2
with app.app_context():
    print(render_template_string("{{ 'ok' | shout }}"))
response = client.get("/go")
print(response.status_code, calls[-3:])
print(hasattr(app, "query"))  # False on 3.1.3, True once 3.2 is released

Moving Away from Flask

Two migrations are common: to Quart when a Flask code base should become asynchronous with the fewest edits, and to FastAPI when typed models and generated OpenAPI documents matter more than Flask’s flexibility.

Flask to Quart

Quart implements the Flask API on ASGI, and Flask 3.0 split Flask and Blueprint into Sans-IO bases so the two share code. Most migrations change imports, make views async def and add await where the Quart API is asynchronous. Quart 0.23.1 needs Python 3.13 or newer. Flask extensions are not automatically compatible, because many call blocking code; check each one. The Quart section of the Async, gevent & Quart page compares the options, including when to switch.

import asyncio

from flask import Flask, jsonify, request
from quart import Quart
from quart import jsonify as quart_jsonify
from quart import request as quart_request

flask_app = Flask(__name__)


@flask_app.post("/books")
def create_book():
    data = request.get_json()
    return jsonify(title=data["title"]), 201


quart_app = Quart(__name__)


@quart_app.post("/books")
async def create_book_async():
    data = await quart_request.get_json()  # request data is awaited in Quart
    return quart_jsonify(title=data["title"]), 201


async def main():
    response = await quart_app.test_client().post("/books", json={"title": "Dune"})
    print(response.status_code, await response.get_json())


print(flask_app.test_client().post("/books", json={"title": "Dune"}).status_code)
asyncio.run(main())

Flask to FastAPI

A gradual migration mounts the existing Flask application inside a FastAPI application with a WSGI adapter, then moves routes to FastAPI one at a time. The FastAPI section explains the mount, the path prefix and the pitfalls in Embedding Flask or Django with WSGIMiddleware (on the Sub-applications, WSGI & GraphQL page); the FastAPI introduction compares both frameworks, and the FastAPI changes and migration page covers FastAPI’s own moving parts. The example mounts a Flask app under /legacy with a2wsgi and serves a new typed route next to it. It needs fastapi, a2wsgi and httpx, which are not installed with Flask:

from a2wsgi import WSGIMiddleware
from fastapi import FastAPI
from fastapi.testclient import TestClient
from flask import Flask, jsonify, request
from pydantic import BaseModel

flask_app = Flask(__name__)


@flask_app.post("/books")
def create_book():
    return jsonify(title=request.get_json()["title"]), 201


class BookIn(BaseModel):
    title: str


api = FastAPI()


@api.post("/v2/books", status_code=201)
async def create_book_typed(book: BookIn):
    return {"title": book.title}


api.mount("/legacy", WSGIMiddleware(flask_app))  # the Flask app keeps working under /legacy

client = TestClient(api)
print(client.post("/v2/books", json={"title": "Dune"}).json())
print(client.post("/legacy/books", json={"title": "Dune"}).status_code)

Where to Go Next

This page links the pages of the section that each change affects. For the section’s starting points, see Introduction & Architecture and Getting Started. Other pages touched by the changes above:

References