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
| 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, |
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
|
Jinja |
3.1.6 (2025-03-05) |
|
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 |
Blinker |
1.9.0 (2024-11-08) |
A required dependency since Flask 2.3. |
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.
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). |
|
Added |
|
|
Added |
Nested blueprints, and the |
|
Added |
Route shortcuts such as |
|
Added |
|
|
Added |
Type hints across the package; |
|
Changed |
A |
|
Changed |
|
|
Deprecated |
Passing |
|
Changed |
The JSON helpers no longer use |
Source: Flask: Changes, Version 2.0.0
Flask 2.1 (2022-03-28)
| Kind | Change | Covered on |
|---|---|---|
Removed |
Python 3.6. Click 8 is now required. |
|
Removed |
Everything deprecated in 2.0: |
|
Changed |
The same blueprint can no longer be registered twice under the same name; pass |
|
Added |
|
|
Added |
|
|
Changed |
Redirect |
|
Deprecated |
|
Source: Flask: Changes, Version 2.1.0
Flask 2.2 (2022-08-01)
| Kind | Change | Covered on |
|---|---|---|
Added |
|
|
Added |
|
|
Removed |
|
|
Deprecated |
|
|
Deprecated |
|
|
Added |
The JSON provider interface ( |
|
Changed |
The application and request contexts are managed with |
|
Added |
Views can return a |
|
Added |
|
|
Changed |
Teardown functions always run at the end of the request, even when the context is preserved; request and |
|
Removed |
The old |
|
Changed |
Autoescaping is enabled for |
Source: Flask: Changes, Version 2.2.0
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. |
|
Removed |
Everything that 2.2 deprecated: |
|
Changed |
Calling setup methods ( |
|
Deprecated |
Importing |
|
Deprecated |
|
|
Changed |
Blinker (1.6.2 or newer) is a required dependency, signals are always available and receivers may be |
|
Changed |
|
|
Changed |
|
|
Changed |
Subdomains are applied with nested blueprints; a blueprint with an empty name raises |
|
Changed |
The |
Source: Flask: Changes, Version 2.3.0
Flask 3.0 (2023-09-30)
| Kind | Change | Covered on |
|---|---|---|
Removed |
Everything deprecated in 2.3, including |
|
Deprecated |
|
|
Changed |
|
|
Added |
|
|
Changed |
Requires Werkzeug 3.0 or newer, which uses scrypt for |
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).
Source: Flask: Changes, Version 3.0.0
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. |
|
Added |
|
|
Added |
|
|
Added |
|
|
Added |
|
|
Changed |
|
|
Changed |
|
|
Added |
|
|
Fixed |
3.1.2 (2025-08-19): |
Source: Flask: Changes, Version 3.1.0
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 |
|
Changed |
|
|
Changed |
Many |
|
Changed |
All teardown callbacks run even if one of them raises. |
|
Deprecated |
|
|
Added |
|
|
Changed |
|
|
Changed |
|
|
Changed |
|
|
Fixed |
IPv6 addresses with a port are parsed correctly in |
|
Added |
The |
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 |
2.2 |
2022-07-23 |
A new, faster state-machine URL router. A custom converter that must match a |
2.3 |
2023-04-25 |
|
3.0 |
2023-09-30 |
|
3.1 |
2024-10-31 |
Python 3.8 dropped. |
3.1.8 and 3.1.9 |
2026-04-02 and 2026-09-27 |
|
3.2 |
unreleased |
Python 3.9 and 3.10 dropped; |
Source: Werkzeug: Changes
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 |
|---|---|---|
|
Still works, but the documented form since 2.2 is the |
|
|
Deprecated in 2.2, removed in 2.3. There is no development/production switch any more. |
|
|
Removed in 2.2. The app is eager loaded first, then lazily reloaded. |
Nothing to pass |
|
Not needed since 2.2: an app context is already active. |
|
|
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 |
|
Packaging moved to |
|
virtualenvwrapper |
Superseded by the standard library. |
|
|
|
|
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
Source: Flask: Command Line Interface
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()"
Source: Flask: Deploying with Gunicorn
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
Source: Flask: Changes, Version 3.0.0
Application Lifecycle
| Old idiom | What happened | Current |
|---|---|---|
|
Deprecated 2.2, removed 2.3. |
Do one-time setup in the application factory; see the Application Factory & Project Layout |
|
Contexts use |
|
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 |
|
Deprecated in 3.0, removed in 3.2. |
|
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])
Source: Flask: Application Factories
Templates and Escaping
| Old idiom | What happened | Current |
|---|---|---|
|
Removed in Jinja 3.1. |
|
|
Deprecated in 2.3, removed in 3.0. |
|
A Jinja date helper that builds |
An XSS-prone pattern: the formatted string is marked safe without escaping its parts. |
Format dates on the server with Babel, or emit |
|
Autoescape is on for |
Nothing to change; mark markup safe only when you fully trust it |
Hand-written streaming responses for templates |
|
|
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)
Source: MarkupSafe documentation
Requests, Responses and JSON
| Old idiom | What happened | Current |
|---|---|---|
|
Removed in Werkzeug 1.0. |
Check an explicit header, or better, use separate API routes returning JSON |
|
Werkzeug 2.3 returns 415 instead of a generic 400. |
Send |
|
Renamed in 2.0, old names removed in 2.2. |
|
|
Deprecated in 2.2, removed in 2.3. |
A JSON provider ( |
Wrapping a list or generator in |
A returned |
|
Flask-RESTful resources returning |
The extension JSON-encodes the return value again, so clients receive a JSON string inside JSON. |
Return a dict or use |
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"])
Source: Flask API: flask.send_file
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)
Source: Flask API: DefaultJSONProvider
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)
Source: Flask-RESTful documentation
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 |
One |
|
Rotate keys: new key in |
|
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 |
|
Nothing to do; keep proxies and caches honouring |
|
New in 3.1. |
Set them in config; see the Security Considerations page |
|
Werkzeug 3.0 defaults to scrypt; PBKDF2 uses 1,000,000 iterations since 3.1. |
Accept the default and verify with |
A hard-coded placeholder string as the |
Publishing them hands the keys to everyone. |
Generate with |
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"))
Source: Werkzeug: generate_password_hash
JWT, Forms and Translations
| Old idiom or claim | What happened | Current |
|---|---|---|
|
Flask-JWT-Extended 4 requires |
|
|
Recent PyJWT requires the |
|
A JWT prose that says the client holds the HMAC key, that every request issues a new token, or that
|
All three are wrong: only the server holds the key, a token is issued at login and on refresh, and
|
Short-lived access tokens, refresh tokens in |
|
Removed in WTForms 3.0. |
|
|
Removed in Flask-Babel 3. |
|
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 |
|---|---|---|
|
The legacy Query API: SQLAlchemy 2.0 marks it legacy and Flask-SQLAlchemy emits a |
|
|
Works, but the typed form is current. |
|
|
Legacy loader. |
|
|
|
|
|
Removed in 3.1. |
Use the object returned by |
|
Removed in SQLAlchemy 2.0. |
|
|
The parentheses call the function once, at import time, so every row gets the same timestamp. |
|
|
Moved in SQLAlchemy 2.0. |
|
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:
Source: Flask-SQLAlchemy: Pagination
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 |
|---|---|---|
|
The opposite: |
Choose |
|
|
|
|
Since flask-smorest 0.25 the first positional argument is the status code. |
|
Raising marshmallow’s |
It produces a 500, not a formatted 422: validation errors are converted only by |
Let |
Overriding the served spec by monkeypatching |
Possible, but only a workaround. |
Use the supported configuration keys and |
OpenAPI 3.0.3 |
OpenAPI 3.1 is current. |
|
RFC 7231 |
Superseded by RFC 9110. |
Cite RFC 9110 for HTTP semantics |
Ad hoc error JSON |
RFC 9457 defines Problem Details ( |
Return problem details from error handlers |
|
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)
Source: flask-smorest: Serialization
Marshmallow’s own behaviour for unknown fields is described in its upgrade and schema documentation:
Source: marshmallow: Schema
Problem Details are specified by RFC 9457:
Testing
| Old idiom | What happened | Current |
|---|---|---|
nose, nose2 |
nose is dead and nose2 is marginal. |
pytest with |
Tests that call a live server with |
Not unit tests: they need a running process and a real database. |
The test client, with |
|
|
|
|
That is the |
|
nose-style |
Removed in pytest 8. |
|
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
Source: Schemathesis documentation
See the Testing page for the full testing workflow.
Extensions and Ecosystem
| Old idiom | What happened | Current |
|---|---|---|
|
Cache-type names are class names now; the old name fails at import. |
|
|
Celery 5 uses lowercase settings. |
|
Sending mail from a bare |
The thread has no application context, so |
|
Flask-WhooshAlchemy, |
Dead projects and a protocol GitHub no longer serves. |
Maintained search engines; see NoSQL, Redis, MongoDB & Search |
Elasticsearch |
Deprecated in elasticsearch-py 8. |
|
|
Removed in openai 1.0 (November 2023); the model was retired in January 2024. |
The |
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 |
Flask-Dance’s Twitter recipe, |
The old Twitter API is gone; unescaped distinguished names allow injection. |
|
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"))
Source: Flask-Caching documentation
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)
Source: OpenAI Python SDK
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. |
|
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. |
|
Use supported base images and pin their versions; see the Docker section. |
|
The |
Eventlet |
No longer maintained (the Flask docs say so); use gevent, see Async, gevent & Quart |
|
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()"]
Source: Flask: Deploying with Gunicorn
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
DeprecationWarningturned into an error and fix each warning before moving on. -
Replace
FLASK_ENV,ENVandapp.envwith--debug(development) or nothing (production). -
Move
before_first_requestcode into the application factory and finish all setup before the first request. -
Replace
flask.Markupandflask.escapewithmarkupsafe, andjson_encodersettings with a JSON provider. -
Replace
Model.querywithdb.session.get,db.get_or_404,db.selectanddb.paginate(keyword arguments). -
Replace
flask.__version__withimportlib.metadata.version("flask"). -
Set
SECRET_KEYfrom the environment and plan key rotation withSECRET_KEY_FALLBACKS. -
Review
TRUSTED_HOSTS,MAX_CONTENT_LENGTH,MAX_FORM_MEMORY_SIZEandMAX_FORM_PARTSfor 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",
]
Source: pytest: How to capture warnings
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.
RequestContextbecomes a deprecated alias ofAppContext, 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 subclassesFlaskand 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 toGET, which keeps the behaviour of bothGETandPOSTredirects and is correct for HTMX. Passcode=303today 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_erroris deprecated. -
Decorators without parentheses.
template_filter,template_testandtemplate_globalcan be used bare. The form with parentheses keeps working. -
provide_automatic_options=True. A view can re-enableOPTIONShandling when the config turned it off; today only disabling works. -
app.query. A decorator for the HTTPQUERYmethod. -
Smaller items.
__version__is removed, Python 3.9 and 3.10 are dropped,select_jinja_autoescapeis 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())
Source: Quart: Migrating from Flask
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:
-
Application structure: Application Factory & Project Layout, Blueprints, Configuration and Extensions & Ecosystem.
-
Requests and responses: Request Data, Responses & JSON, Error Handling, Hooks & View Decorators and Class-based Views.
-
Templates and files: Jinja Templates, Static Files & Assets and File Uploads.
-
Security and identity: Sessions, Cookies & Flashing, Authentication & Flask-Login, Authorization & Roles and Security Considerations.
-
Admin and tooling: Admin Interfaces with Flask-Admin, CLI & Shell and Debugging, Logging & Profiling.
-
Streaming: Streaming & Server-Sent Events and Signals.
-
APIs: JWT & API Authentication.