OpenAPI & API Documentation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-19), Werkzeug 3.1.9, flask-smorest 0.47.0 (2026-03-22), apispec 6.10.0, marshmallow 4.3.1, APIFlask 3.1.2 (2026-09-12) and Python 3.13, checked on 2026-10-07; every Python example was executed with those versions through Flask’s test client. Flask 3.2 is not released yet and does not change anything described here. An API that nobody can discover is an API nobody can use, and an API whose documentation is written by hand drifts away from the code within weeks. The cure is a machine-readable description, an OpenAPI document, that is the single source of truth: documentation pages, interactive consoles, client libraries, mocks, gateways and contract tests are all generated from it. The page covers what an OpenAPI document contains, the two ways of keeping it truthful (code-first, where the code produces the document, and design-first, where the document produces or constrains the code), how Flask does each, and how to publish, test and consume the result. It extends the Bookshelf API of the previous pages. Flask itself has no OpenAPI support: everything here comes from extensions, and the page says which library does which job.
Why Document an API with OpenAPI
Flask validates nothing and documents nothing by itself: a view receives a request and returns whatever it likes (see
Request Data and Responses & JSON).
That freedom is exactly why an API needs a contract. The OpenAPI Specification (OAS) is a language-neutral way of
writing that contract as a JSON or YAML document: the paths, the operations on each path, the parameters, the request
bodies, the responses, the data types and the security requirements. Because it is machine-readable, the same document
feeds many tools.
| Consumer of the document | What it does with it |
|---|---|
Documentation renderers (Swagger UI, ReDoc, RapiDoc) |
Render interactive reference pages, with a "try it out" console in the case of Swagger UI and RapiDoc |
Client generators |
Produce typed client libraries and SDKs for Python, TypeScript and many other languages |
Gateways, proxies and mock servers |
Route, authenticate, rate-limit or simulate the API from the same description |
Contract and property-based tests |
Generate requests from the schemas and check every response against them; see the page on API contract and property-based testing in this section |
Linters and governance tools |
Enforce naming, error-format and versioning rules across many services |
People |
Review the API shape in a pull request before any code exists (documentation-driven or API-first development) |
The API-first idea, argued at length in Microservice APIs (Manning), is that the interface between services is the part that is expensive to change, so it deserves a document that is reviewed, versioned and agreed before the implementation. Whether the document is written first or generated, the rule stays the same: the running service and the published document must never disagree.
Code-First and Design-First
There are two workflows, and Flask libraries exist for both.
views, schemas, decorators"] --> B["library collects metadata
apispec or APIFlask"] B --> C["OpenAPI document"] end subgraph DF["Design-first"] direction LR D["hand-written YAML
reviewed in a pull request"] --> E["OpenAPI document"] E --> F["implementation checked
against the document"] end C --> G["docs UI, clients,
mocks, contract tests"] E --> G
| Code-first | Design-first | |
|---|---|---|
The document is |
generated; it is always in sync with the code that was introspected |
written by hand; it must be kept in sync by tests |
Flask tools |
flask-smorest, APIFlask, flask-openapi3, Flask-RESTX (Swagger 2.0) |
Connexion (routes the document to your functions), a hand-written file validated by tests, plus any generator |
Strengths |
no duplication, refactoring-friendly, validation and documentation come from one declaration |
the contract can be reviewed and negotiated before coding, the document can carry richer prose and examples |
Risks |
the document is limited to what the library can express; it describes what the code does, not what was agreed |
document and code drift unless a test fails when they differ |
The rest of the page follows the code-first path with flask-smorest first, because it is the most complete option for plain Flask, then APIFlask, then the design-first path with a validated hand-written document. Both are only as good as the checks around them, which is why the page ends with contract validation.
Anatomy of an OpenAPI Document
An OpenAPI document is a single JSON or YAML object. The OpenAPI Specification 3.1 is aligned with JSON Schema draft
2020-12, so the objects that describe data (schema) are ordinary JSON Schema, with a few OpenAPI keywords added.
| Top-level field | Meaning |
|---|---|
|
the version of the specification the document follows, for example |
|
|
|
the base URLs where the API is available |
|
every URL template, each with its operations ( |
|
reusable objects referenced with |
|
the security requirements that apply to every operation unless an operation overrides them |
|
grouping and extra documentation |
|
incoming calls the API may receive (new in 3.1) |
A document needs openapi and info, and at least one of paths, components or webhooks. Inside paths, each
operation lists its parameters (each with a name, a location in in, either path, query, header or cookie,
and a schema), an optional requestBody keyed by media type, and responses keyed by status code (or default).
Every response has a mandatory description and optionally content and headers. The reusable parts live in
components and are referenced with $ref, so that the same Book schema is written once and used by the request of
POST /books and the response of GET /books/{bookId}. Security is declared once under
components.securitySchemes (types apiKey, http, oauth2, openIdConnect and mutualTLS) and applied with
security, which is how Swagger UI knows to show an "Authorize" button.
openapi: 3.1.0
info:
title: Bookshelf API
version: 1.0.0
paths:
/books/{bookId}:
get:
operationId: getBook
parameters:
- name: bookId
in: path
required: true
schema: {type: integer, minimum: 1}
responses:
"200":
description: The book
content:
application/json:
schema: {$ref: "#/components/schemas/Book"}
components:
schemas:
Book:
type: object
required: [id, title]
properties:
id: {type: integer}
title: {type: string, minLength: 1}
subtitle: {type: [string, "null"]}
Source: OpenAPI Specification v3.1.1
Which OpenAPI Version
OpenAPI 3.0 and 3.1 differ in ways that show up in generated documents.
| Topic | OpenAPI 3.0.x | OpenAPI 3.1.x |
|---|---|---|
Schema dialect |
a modified subset of JSON Schema draft 4/5 ( |
JSON Schema draft 2020-12 in full ( |
Webhooks |
not available |
top-level |
Root requirements |
|
one of |
The OpenAPI Initiative has published later versions as well (the specification site lists 3.2.1, dated 2026-09-10, as
the latest). The Flask libraries on this page generate documents that follow the 3.0 and 3.1 shapes: APIFlask defaults to
3.1.0 and flask-openapi3 uses 3.1.0, while flask-smorest has no default and requires you to set OPENAPI_VERSION
(its quickstart uses 3.0.2). apispec, the library underneath flask-smorest, accepts any version string without
checking it, so the value you choose is a promise you must keep: set 3.1.0 and let tools validate the result. The book
Microservice APIs configures 3.0.3; 3.1 is the better target for new work because its schemas are plain JSON Schema,
which every modern validator, test generator and code generator understands.
The Flask Libraries
| Library | Validation and schemas | OpenAPI output | Notes |
|---|---|---|---|
flask-smorest 0.47.0 |
marshmallow (through webargs), apispec |
the version you set in |
plain Flask |
APIFlask 3.1.2 |
marshmallow (apispec underneath) |
|
a |
flask-openapi3 4.3.2 |
Pydantic 2 |
|
|
Flask-RESTX 1.3.2 |
its own |
Swagger 2.0 ( |
community fork of Flask-RESTPlus; the output predates OpenAPI 3 |
Connexion |
the OpenAPI document itself |
the document you wrote |
spec-first framework that routes |
Flask-RESTful |
|
none |
code you will meet in older books; no OpenAPI support |
Flask-RESTful with reqparse, used by Flask Framework Cookbook (Packt), is maintained at a minimal level and has no
OpenAPI support; use one of the libraries above for new work. A fuller comparison with maintenance status belongs to
the page on building REST APIs in this section.
Source: flask-smorest documentation
Code-First with flask-smorest
flask-smorest (installed with pip install flask-smorest) combines three libraries: marshmallow describes the data,
webargs parses requests into those schemas, and apispec builds the OpenAPI document from everything it sees. The
Flask part is deliberately small: an Api object attached to the application, a Blueprint subclass whose decorators
carry the documentation, and the standard Flask MethodView from
Class-Based Views to group the methods of a resource.
The Minimal API
Three settings are required, and Api raises MissingAPIParameterError when one is absent: API_TITLE, API_VERSION
and OPENAPI_VERSION. Everything else is optional.
from flask import Flask
from flask.views import MethodView
import marshmallow as ma
from flask_smorest import Api, Blueprint, abort
class BookSchema(ma.Schema):
id = ma.fields.Int(dump_only=True)
title = ma.fields.Str(required=True, validate=ma.validate.Length(min=1, max=200))
year = ma.fields.Int(validate=ma.validate.Range(min=1450))
blp = Blueprint("books", __name__, url_prefix="/books", description="Operations on books")
BOOKS: dict[int, dict] = {}
@blp.route("/")
class Books(MethodView):
@blp.response(200, BookSchema(many=True))
def get(self):
"""List the books"""
return list(BOOKS.values())
@blp.arguments(BookSchema)
@blp.response(201, BookSchema)
def post(self, data):
"""Create a book"""
data["id"] = len(BOOKS) + 1
BOOKS[data["id"]] = data
return data
@blp.route("/<int:book_id>")
class Book(MethodView):
@blp.response(200, BookSchema)
def get(self, book_id):
"""Fetch one book"""
try:
return BOOKS[book_id]
except KeyError:
abort(404, message="No such book")
def create_app() -> Flask:
app = Flask(__name__)
app.config.update(
API_TITLE="Bookshelf API",
API_VERSION="v1",
OPENAPI_VERSION="3.1.0",
)
api = Api(app)
api.register_blueprint(blp)
return app
app = create_app()
client = app.test_client()
print(client.post("/books/", json={"title": "Dune", "year": 1965}).status_code)
print(client.get("/books/1").json)
print(client.get("/books/9").json)
print(client.post("/books/", json={"year": 1965}).json)
Source: flask-smorest: Quick Start
With the development server (flask --app bookshelf run) the same calls look like this, and the last one shows the
error format that flask-smorest installs for every HTTPException.
curl -s -X POST localhost:5000/books/ -H 'Content-Type: application/json' -d '{"title": "Dune", "year": 1965}'
curl -s -X POST localhost:5000/books/ -H 'Content-Type: application/json' -d '{"year": 1965}'
{"id": 1, "title": "Dune", "year": 1965}
{"code": 422, "errors": {"json": {"title": ["Missing data for required field."]}}, "status": "Unprocessable Entity"}
What happened underneath:
-
Api(app)read the three settings, built anapispec.APISpec, registered a default error handler forHTTPExceptionand added theflask openapicommand group toapp.cli. -
Blueprintis a subclass offlask.Blueprint;blp.routeregisters the view like Flask does and also stores the view in the documentation. Because it is a real Flask blueprint, everything in Blueprints (prefixes, error handlers, hooks) still applies. -
api.register_blueprint(blp)registers it in the application and generates the documentation of its views from the decorators and docstrings. It must be called afterApiis attached to the app; useApi().init_app(app)in an application factory, as in Application Factory & Project Layout. -
A blueprint registered with plain
app.register_blueprint(blp)works as a Flask blueprint but never appears in the document.
The Decorators and Their Order
The decorators belong to the Blueprint; each one both changes the runtime behaviour of the view and contributes to the
document.
| Decorator | Runtime effect | Documentation effect |
|---|---|---|
|
registers the view (a function or a |
creates the path item; |
|
parses and validates the request with the schema (through webargs) and passes the result as an extra positional argument; on failure answers 422 with the errors |
|
|
serializes the returned object with the schema; the status code is used unless the view returns a tuple with another |
the success response, with the schema referenced from |
|
nothing by default: it only documents a response the view may produce |
an additional response (typically an error) for the operation; the parameter |
|
adds |
documents the two parameters and the header |
|
nothing |
merges any keyword ( |
Three rules about the order, all of which the book Microservice APIs gets wrong or leaves out, decide whether the document and the behaviour are correct.
-
@blp.routemust be the outermost decorator when decorating a function. It registers whatever function it receives, so a@blp.responsewritten above it is applied to a function that is no longer the one Flask calls. With aMethodViewclass the problem does not exist, because@blp.routedecorates the class and the other decorators sit on the methods. -
Under
@blp.route, put@blp.argumentsfirst (outermost) and@blp.responsebelow it;@blp.paginategoes below@blp.response. The view’s own signature mirrors that order: the parsed arguments come first, thenpagination_parameterswhen pagination is handled in the view. -
The first positional argument of
@blp.responseis the status code and the schema is the second. Writing@blp.response(BookSchema)fails at import time with aTypeError, because a schema class is not a status code. Thestatus_code-first signature has existed since flask-smorest 0.29.0 (2021-02-13); older tutorials and the book use the previous form, with the schema first.
blp.route registered the view"] RT --> ARG["blp.arguments
webargs parses and validates
422 on failure"] ARG --> PG["blp.response and blp.paginate
wrap the call"] PG --> V["your view function
receives the parsed data"] V --> SER["blp.response
serializes with the schema
unknown attributes are dropped"] SER --> HDR["blp.paginate
adds X-Pagination"] HDR --> OUT["HTTP response"] ARG -. "documents requestBody or parameters" .-> SPEC["OpenAPI document"] SER -. "documents responses" .-> SPEC
The wrong order is not just a documentation bug, it is a data leak. The view below returns an attribute that is not in the schema; with the right order the response schema filters it out, with the wrong order the decorator is bypassed and the attribute is sent (and the success response is missing from the document).
import marshmallow as ma
from flask import Flask
from flask_smorest import Api, Blueprint
class PublicBook(ma.Schema):
title = ma.fields.Str()
app = Flask(__name__)
app.config.update(API_TITLE="Order demo", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
api = Api(app)
blp = Blueprint("order", __name__, url_prefix="/order")
@blp.response(200, PublicBook)
@blp.route("/wrong")
def wrong():
return {"title": "Dune", "internal_note": "do not publish"}
@blp.route("/right")
@blp.response(200, PublicBook)
def right():
return {"title": "Dune", "internal_note": "do not publish"}
api.register_blueprint(blp)
c = app.test_client()
print(c.get("/order/wrong").json)
print(c.get("/order/right").json)
spec = api.spec.to_dict()
print("200 documented for wrong:", "200" in spec["paths"]["/order/wrong"]["get"]["responses"])
print("200 documented for right:", "200" in spec["paths"]["/order/right"]["get"]["responses"])
Source: flask-smorest: Response
Configuration Reference
flask-smorest reads its settings from app.config. All keys can carry a prefix when several Api instances live in one
application: pass config_prefix="V2_" to Api and write V2_API_TITLE and so on.
| Key | Default | Meaning |
|---|---|---|
|
required |
the human-friendly title ( |
|
required |
the version of the API as a string ( |
|
required |
the specification version string written to |
|
|
base path under which the JSON document and the UIs are served; |
|
|
path of the JSON document relative to the prefix |
|
|
path of the Swagger UI page and the base URL of the Swagger UI scripts (version 3 or later); both must be set |
|
|
dictionary of Swagger UI configuration options |
|
|
path of the ReDoc page and URL of the ReDoc script; both must be set |
|
|
path of the RapiDoc page and URL of its script; both must be set |
|
|
dictionary of RapiDoc options |
|
|
extra root attributes of the document ( |
Api also accepts spec_kwargs (passed to apispec.APISpec, including flask_plugin, marshmallow_plugin and
extra_plugins) and Api.register_field and Api.register_converter to teach the generator how to document custom
marshmallow fields and custom URL converters.
Source: flask-smorest: OpenAPI
Describing Data with marshmallow Schemas
A marshmallow Schema does three jobs at once: it validates and deserializes input (load), it serializes output
(dump), and, through apispec, it becomes an entry under components.schemas of the document. The vocabulary that
matters for an API is small.
| Feature | Effect on behaviour and on the document |
|---|---|
|
types of the properties; |
|
input without the field is rejected; the field is listed under |
|
read-only fields ( |
|
value used when the field is missing on input / output; in marshmallow 3.13 and later these replace the old |
|
rejects the input with a 422; most standard validators become |
|
custom field-level and cross-field validation; custom validators are not visible in the document, so describe them in prose |
|
a nested object, rendered as a |
|
what to do with fields the input contains but the schema does not declare; see below |
|
subset or partial variants, which become separate component names, so use them sparingly |
schema inheritance |
a subclass inherits the parent’s fields; a subclass is a distinct component, so put the common fields in a base class
and keep |
The Unknown-Field Policy
Meta.unknown takes one of three values, and the two that matter are the opposite of what Microservice APIs states.
| Value | Behaviour on load |
Effect on the document |
|---|---|---|
|
unknown fields are rejected: the request fails with a 422 and |
|
|
unknown fields are silently dropped: the request succeeds and the view never sees them |
no |
|
unknown fields are kept in the result |
no |
EXCLUDE therefore means tolerant (drop what you do not know), and RAISE strict. The choice is a design decision:
strict input catches misspelt optional fields (a client that sends titel hears about it), tolerant input lets old
servers accept newer clients. Note that the default for output schemas is irrelevant at runtime, because dumping
ignores attributes the schema does not declare, as the leak demonstration above showed.
import json
import marshmallow as ma
class Base(ma.Schema):
"""Common configuration for every schema of the API."""
class Meta:
unknown = ma.RAISE
class AuthorSchema(Base):
name = ma.fields.Str(required=True)
class LenientAuthor(AuthorSchema):
class Meta:
unknown = ma.EXCLUDE
class BookInSchema(Base):
title = ma.fields.Str(required=True)
authors = ma.fields.List(ma.fields.Nested(LenientAuthor), load_default=list)
@ma.validates("title")
def no_padding(self, value, **kwargs):
if value != value.strip():
raise ma.ValidationError("must not start or end with spaces")
try:
BookInSchema().load({"title": "Dune", "isbn": "x"})
except ma.ValidationError as err:
print("RAISE:", err.messages)
print("EXCLUDE:", BookInSchema().load({"title": "Dune", "authors": [{"name": "Herbert", "born": 1920}]}))
try:
BookInSchema().load({"title": " Dune"})
except ma.ValidationError as err:
print("validator:", err.messages)
# What the policy does to the generated document
from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint
app = Flask(__name__)
app.config.update(API_TITLE="Policy demo", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
api = Api(app)
blp = Blueprint("policy", __name__, url_prefix="/policy")
@blp.route("/")
class Policy(MethodView):
@blp.arguments(BookInSchema)
@blp.response(200, BookInSchema)
def post(self, data):
return data
api.register_blueprint(blp)
schemas = api.spec.to_dict()["components"]["schemas"]
print(json.dumps({k: schemas[k].get("additionalProperties") for k in ("BookIn", "LenientAuthor")}))
Schema names in components.schemas come from the class name with a trailing Schema removed (BookInSchema becomes
BookIn); two different schema classes that end up with the same component name trigger an apispec warning, so keep
names distinct across blueprints.
Parsing Query Strings, Forms and Headers
@blp.arguments(schema, location="json") is not limited to bodies. The location argument chooses where webargs looks
for the data: json (the default), query, form, files, headers, cookies, among others webargs supports. For
json, form and files the schema becomes the requestBody and required and description apply to it; for the
other locations each field of the schema becomes one entry of parameters with its own required flag, and the
required argument of the decorator has no effect. That is the part worth remembering when a query parameter looks
optional in the document although you passed required=True.
Query schemas usually want unknown = EXCLUDE, because browsers, proxies and trackers add parameters (utm_source,
_ cache busters) that you do not want to turn into a 422.
import marshmallow as ma
from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint
class BookQuery(ma.Schema):
class Meta:
unknown = ma.EXCLUDE
q = ma.fields.Str(metadata={"description": "Title contains this text"})
year_from = ma.fields.Int(validate=ma.validate.Range(min=1450))
tags = ma.fields.List(ma.fields.Str())
app = Flask(__name__)
app.config.update(API_TITLE="Query demo", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
api = Api(app)
blp = Blueprint("search", __name__, url_prefix="/search")
@blp.route("/")
class Search(MethodView):
@blp.arguments(BookQuery, location="query")
@blp.response(200)
def get(self, args):
return args
api.register_blueprint(blp)
c = app.test_client()
print(c.get("/search/?q=dune&utm_source=mail&tags=a&tags=b").json)
print(c.get("/search/?year_from=10").json)
params = api.spec.to_dict()["paths"]["/search/"]["get"]["parameters"]
print([(p["name"], p["in"], p["required"]) for p in params])
Source: flask-smorest: Arguments
Responses, Errors and Status Codes
@blp.response(status_code, schema) describes the success response. A few points about it are easy to miss.
-
If a view returns a list, the schema must be instantiated with
many=True(BookSchema(many=True)), otherwise the list is serialized as if it were a single object. -
The decorator serializes with the schema (
dump) but does not validate the data: a view that returns{"title": 5}for aStrfield sends{"title": "5"}with status 200. Contract problems in outgoing data are caught by tests that compare responses to the document (see Design-First with a Hand-Written Document), not by the decorator. -
A view may return a tuple, such as
(body, 201, headers). A status code in the tuple overrides the one in the decorator, and the document keeps showing the decorator’s value, which is why the documentation of flask-smorest advises against it. -
A single
@blp.responseis allowed per view. Everything else the view may answer is documented with stacked@blp.alt_responsedecorators. With only a status code,alt_responsedocuments that status with its standard HTTP phrase as description; withresponse="NOT_FOUND"(the name of anhttp.HTTPStatusmember) it references the shared component#/components/responses/NOT_FOUND, created on demand with the error schema, so every operation that uses it shares one definition.
Errors have a default format. abort(status, message=..., errors=..., headers=...) is a republication of webargs'
abort that stores its keyword arguments so the registered handler can render them as
{"code": ..., "status": ..., "message": ..., "errors": ...}. Validation failures raised by @blp.arguments are the same
kind of HTTPException, with the field errors under errors keyed by location. The default answer is documented once
as components.schemas.Error and used for every operation as the default response, plus a 422 response for
operations with @blp.arguments.
import marshmallow as ma
from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint, abort
class ShelfItem(ma.Schema):
isbn = ma.fields.Str(required=True)
app = Flask(__name__)
app.config.update(API_TITLE="Errors demo", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
api = Api(app)
blp = Blueprint("shelf", __name__, url_prefix="/shelf")
SHELF: set[str] = set()
@blp.route("/")
class Shelf(MethodView):
@blp.arguments(ShelfItem)
@blp.response(201, ShelfItem)
@blp.alt_response(409, description="The book is already on the shelf")
def post(self, data):
if data["isbn"] in SHELF:
abort(409, message="Already on the shelf", errors={"isbn": ["duplicate"]})
SHELF.add(data["isbn"])
return data
@blp.response(200)
def get(self):
raise ma.ValidationError("a schema error raised by hand")
api.register_blueprint(blp)
app.config["PROPAGATE_EXCEPTIONS"] = False
c = app.test_client()
print(c.post("/shelf/", json={"isbn": "978-0"}).status_code)
print(c.post("/shelf/", json={"isbn": "978-0"}).json)
print(c.post("/shelf/", json={}).json)
print("hand-raised ValidationError ->", c.get("/shelf/").status_code)
print(sorted(api.spec.to_dict()["paths"]["/shelf/"]["post"]["responses"]))
Source: flask-smorest: Response
The last two lines show a pitfall: marshmallow’s ValidationError raised inside a view is not an HTTPException, so
it is not converted to a 422; Flask turns it into a plain 500 (see
Error Handling for how the handlers are chosen). Validation belongs in
@blp.arguments; to fail from the middle of a view use abort(422, ...) or abort(409, ...), or register your own
handler for the exception.
Problem Details Instead of the Default Format
RFC 9457 (Problem Details for HTTP APIs, which obsoletes RFC 7807) defines the application/problem+json format that
many clients and gateways understand; the books on this page predate it or ignore it. The default flask-smorest format
is a custom shape, but the error handler and the error schema are both attributes of Api and can be replaced by
subclassing: ERROR_SCHEMA is the schema documented in the spec and handle_http_exception builds the response.
import marshmallow as ma
from flask import Flask, make_response
from flask.views import MethodView
from flask_smorest import Api, Blueprint, abort
class Problem(ma.Schema):
type = ma.fields.Str(dump_default="about:blank")
title = ma.fields.Str()
status = ma.fields.Int()
detail = ma.fields.Str()
errors = ma.fields.Dict()
class ProblemApi(Api):
ERROR_SCHEMA = Problem
def handle_http_exception(self, error):
payload, status, headers = super().handle_http_exception(error)
body = {"title": payload["status"], "status": status}
if "message" in payload:
body["detail"] = payload["message"]
if "errors" in payload:
body["errors"] = payload["errors"]
response = make_response(Problem().dumps(body), status)
response.headers["Content-Type"] = "application/problem+json"
response.headers.update(headers)
return response
class Note(ma.Schema):
n = ma.fields.Int(required=True)
app = Flask(__name__)
app.config.update(API_TITLE="Problem demo", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
api = ProblemApi(app)
blp = Blueprint("notes", __name__, url_prefix="/notes")
@blp.route("/")
class Notes(MethodView):
@blp.arguments(Note)
@blp.response(200, Note)
def post(self, data):
abort(409, message="Conflict here")
api.register_blueprint(blp)
c = app.test_client()
r = c.post("/notes/", json={"n": "x"})
print(r.status_code, r.content_type, r.json)
r = c.post("/notes/", json={"n": 1})
print(r.status_code, r.content_type, r.json["detail"])
print("Error component replaced:", "Problem" in api.spec.to_dict()["components"]["schemas"])
The documented error responses still advertise application/json as their media type, because the default response
content type of the spec generator is a single class attribute used for success and error responses alike; check the
generated document if clients rely on that media type. The page on error handling in this section shows the same RFC
9457 format for plain Flask.
Pagination
@blp.paginate() adds page and page_size to the query parameters and the metadata to the response in an
X-Pagination header (total, total_pages, page, first_page, last_page, and previous_page and next_page
when they exist). It has two modes. In the first the view receives a pagination_parameters object, slices its own
data and sets item_count. In the second paginate receives a pager class (a subclass of flask_smorest.Page, such
as one wrapping a SQLAlchemy query) and the view just returns the whole collection or a lazy cursor.
| Setting | Default |
|---|---|
|
1 |
|
10 |
|
100 |
They can be changed globally in Blueprint.DEFAULT_PAGINATION_PARAMETERS or per endpoint as arguments of
@blp.paginate(page=..., page_size=..., max_page_size=...); the header name is Blueprint.PAGINATION_HEADER_NAME
(None disables the header). A request for a larger page_size than allowed is rejected by the generated parameter
schema (maximum: 100), which protects the database from page_size=1000000.
import marshmallow as ma
from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint, Page
class ItemSchema(ma.Schema):
n = ma.fields.Int()
app = Flask(__name__)
app.config.update(API_TITLE="Paging demo", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
api = Api(app)
blp = Blueprint("items", __name__, url_prefix="/items")
ITEMS = [{"n": i} for i in range(1, 26)]
class ListPage(Page):
@property
def item_count(self):
return len(self.collection)
@blp.route("/in-view")
class InView(MethodView):
@blp.response(200, ItemSchema(many=True))
@blp.paginate()
def get(self, pagination_parameters):
pagination_parameters.item_count = len(ITEMS)
return ITEMS[pagination_parameters.first_item : pagination_parameters.last_item + 1]
@blp.route("/with-pager")
class WithPager(MethodView):
@blp.response(200, ItemSchema(many=True))
@blp.paginate(ListPage, page_size=5, max_page_size=20)
def get(self):
return ITEMS
api.register_blueprint(blp)
c = app.test_client()
r = c.get("/items/in-view?page=3&page_size=10")
print(len(r.json), r.headers["X-Pagination"])
r = c.get("/items/with-pager?page=2")
print([x["n"] for x in r.json], r.headers["X-Pagination"])
print(c.get("/items/with-pager?page_size=500").status_code)
Source: flask-smorest: Pagination
The related ETag support (@blp.etag, conditional If-None-Match and If-Match requests for caching and optimistic
locking) is described in the flask-smorest documentation and adds its own documented headers; the page on building REST
APIs in this section covers when to use pagination styles and conditional requests.
Enriching the Generated Document
The decorators produce structure; people read the prose. Four sources add it.
-
Docstrings. The first line of a view’s docstring becomes the operation
summaryand the rest, until a line containing only---, thedescription. Anything after the---is not published, which makes the delimiter a place for internal notes. -
@blp.doc(...)merges arbitrary keywords into the operation:summary,description,tags,deprecated=True,operationId,security=[...],externalDocs,servers. -
Field
metadata:ma.fields.Str(metadata={"description": "...", "example": "..."})documents a property. -
API_SPEC_OPTIONSandapi.spec.components: root attributes (servers,info.description,info.contact…) and reusable components registered with apispec’sComponentsAPI (security_scheme,parameter,response…).
Security deserves its own mention. The document is where an operation says "this requires a bearer token", but
flask-smorest only describes security, it does not enforce it: the view or a decorator from
Hooks & View Decorators still has to authenticate the request, as
shown in Authorization & Roles. A securitySchemes entry plus a
security requirement makes Swagger UI and generated clients send the credential. The page on JWT and API
authentication in this section builds the matching implementation.
import json
import marshmallow as ma
from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint, abort
class BookOut(ma.Schema):
title = ma.fields.Str(metadata={"description": "Title as printed on the cover", "example": "Dune"})
app = Flask(__name__)
app.config.update(
API_TITLE="Bookshelf API",
API_VERSION="v1",
OPENAPI_VERSION="3.1.0",
API_SPEC_OPTIONS={
"servers": [{"url": "https://api.example.com", "description": "Production"}],
"info": {"description": "The Bookshelf catalogue.", "contact": {"name": "API team"}},
},
)
api = Api(app)
api.spec.components.security_scheme(
"bearerAuth", {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
)
blp = Blueprint("catalog", __name__, url_prefix="/catalog", description="The catalogue of books")
@blp.route("/<int:book_id>")
class CatalogBook(MethodView):
@blp.doc(security=[{"bearerAuth": []}], operationId="getCatalogBook")
@blp.response(200, BookOut)
@blp.alt_response(404, description="No book with that id")
def get(self, book_id):
"""Fetch a book
Returns the book with the given identifier.
---
Internal: backed by the read replica, see the runbook.
"""
abort(404)
@blp.route("/legacy")
class Legacy(MethodView):
@blp.doc(deprecated=True)
@blp.response(200)
def get(self):
"""Old listing, use /catalog/ instead"""
return []
api.register_blueprint(blp)
spec = api.spec.to_dict()
op = spec["paths"]["/catalog/{book_id}"]["get"]
print(op["summary"], "|", op["description"], "|", op["operationId"], op["security"])
print(spec["servers"], spec["components"]["securitySchemes"])
print(spec["paths"]["/catalog/legacy"]["get"]["deprecated"], spec["tags"])
print(json.dumps(spec["components"]["schemas"]["BookOut"]["properties"]))
Serving the Document and the Interactive UIs
When OPENAPI_URL_PREFIX is set, flask-smorest registers an extra blueprint that serves the document at
<prefix>/openapi.json (or OPENAPI_JSON_PATH) and, for each UI whose path and script URL are both set, an HTML page
that loads the UI from that URL. The three supported UIs are Swagger UI, ReDoc and RapiDoc; none is bundled, they are
pulled from a CDN or from a location you host.
from flask import Flask
from flask_smorest import Api
DOCS = {
"API_TITLE": "Bookshelf API",
"API_VERSION": "v1",
"OPENAPI_VERSION": "3.1.0",
"OPENAPI_URL_PREFIX": "/docs",
"OPENAPI_SWAGGER_UI_PATH": "/swagger",
"OPENAPI_SWAGGER_UI_URL": "https://cdn.jsdelivr.net/npm/swagger-ui-dist/",
"OPENAPI_SWAGGER_UI_CONFIG": {"persistAuthorization": True},
"OPENAPI_REDOC_PATH": "/redoc",
"OPENAPI_REDOC_URL": "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js",
"OPENAPI_RAPIDOC_PATH": "/rapidoc",
"OPENAPI_RAPIDOC_URL": "https://unpkg.com/rapidoc/dist/rapidoc-min.js",
}
app = Flask(__name__)
app.config.update(DOCS)
Api(app)
c = app.test_client()
for path in ("/docs/openapi.json", "/docs/swagger", "/docs/redoc", "/docs/rapidoc"):
print(path, c.get(path).status_code)
print([r.endpoint for r in app.url_map.iter_rules() if r.endpoint.startswith("api-docs")])
Publishing documentation raises operational questions that a decorator cannot answer.
-
Third-party scripts. The pages above execute JavaScript from
cdn.jsdelivr.netorunpkg.com, unpinned in the example (swagger-ui-distwith no version resolves to the latest release). That is convenient in development. In production pin an exact version in the URL, or download the distribution into the application’s static folder (see Static Files & Assets) and point the URL setting at it; this also keeps a strictContent-Security-Policyworkable, as in Security Considerations. -
Exposure. The document and the UI list every endpoint, including ones meant for internal clients. Decide whether they are public. A common pattern is to set
OPENAPI_URL_PREFIXonly whenDEBUGis on or from an environment variable, so production serves nothing (Nonedisables the whole blueprint), or to put the prefix behind the same authentication as the API. -
Try it out. Swagger UI sends real requests with real credentials from the visitor’s browser. Point its
serversentry at the right environment, and keep the CORS policy of the API consistent with where the UI is hosted; the page on single-page apps and CORS in this section and the site’s own web/cors page explain preflight requests. -
Hosting the files elsewhere. The document is just a file: publishing the output of
flask openapi writeto a documentation site or an API portal avoids exposing the live service at all.
The OpenAPI Command-Line Group
Api registers a Click group named openapi (see the page on the Flask CLI). flask --app bookshelf openapi
print writes the document to standard output and flask --app bookshelf openapi write openapi.json writes it to a
file; both accept --format json|yaml (YAML needs PyYAML installed) and --config-prefix for applications with several
Api objects; openapi list-config-prefixes shows the prefixes.
flask --app bookshelf openapi print --format yaml
flask --app bookshelf openapi write openapi.json
git diff --exit-code openapi.json # fail the CI job when the committed spec is stale
Committing the generated file and failing the build when git diff is not empty turns every API change into a visible
diff in the pull request, which is how code-first projects get the review step that design-first projects get from
writing the document by hand. The same generated file is the input to linters, generators and contract tests, so no
server has to be running.
import json
import tempfile
from pathlib import Path
from openapi_spec_validator import validate
app = create_app() # the factory defined in the minimal API example
runner = app.test_cli_runner()
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "openapi.json"
result = runner.invoke(args=["openapi", "write", str(target)])
print("exit code:", result.exit_code)
document = json.loads(target.read_text())
validate(document) # raises on an invalid OpenAPI document
print(document["openapi"], sorted(document["paths"]))
APIFlask: the Same Idea in a Flask Subclass
APIFlask (pip install apiflask) takes a different shape. APIFlask subclasses Flask, so plain Flask views keep
working, and it adds decorators on the application and its blueprints (APIBlueprint) instead of on a separate
Blueprint class. It documents and serves the OpenAPI document and a UI out of the box with no extra configuration, and
it accepts marshmallow schemas.
| Concern | flask-smorest | APIFlask |
|---|---|---|
Application object |
|
|
Request body / query |
|
|
Success response |
|
|
Other responses |
|
|
Extra documentation |
|
|
Automatic responses |
|
|
Default document version |
set |
|
UI |
Swagger UI, ReDoc, RapiDoc (URLs set by you) |
Swagger UI at |
CLI |
|
|
Spec file in the repository |
redirect |
|
from apiflask import APIFlask, Schema, abort
from apiflask.fields import Integer, String
from apiflask.validators import Length, Range
app = APIFlask(__name__, title="Bookshelf API", version="1.0")
class BookIn(Schema):
title = String(required=True, validate=Length(1, 200))
year = Integer(validate=Range(min=1450))
class BookOut(BookIn):
id = Integer()
BOOKS: dict[int, dict] = {}
@app.post("/books")
@app.input(BookIn)
@app.output(BookOut, status_code=201)
@app.doc(summary="Create a book", responses=[409])
def create_book(json_data):
json_data["id"] = len(BOOKS) + 1
BOOKS[json_data["id"]] = json_data
return json_data
@app.get("/books/<int:book_id>")
@app.output(BookOut)
def get_book(book_id):
if book_id not in BOOKS:
abort(404, "No such book")
return BOOKS[book_id]
c = app.test_client()
print(c.post("/books", json={"title": "Dune"}).json)
print(c.post("/books", json={"title": ""}).json)
print(c.get("/books/9").json)
document = c.get("/openapi.json").json
print(document["openapi"], sorted(document["paths"]), sorted(document["components"]["schemas"]))
Source: APIFlask: OpenAPI Generating
In the command line, flask --app bookshelf spec --format yaml --output openapi.yaml produces the same kind of file as
the flask-smorest command. The flask spec command is installed as a Flask plugin command (a flask.commands entry
point), so it appears in the real flask executable but not in app.test_cli_runner(). APIFlask’s
@app.spec_processor hook lets you rewrite the finished document, and @app.doc(hide=True) or
APIFlask(__name__, enable_openapi=False) removes operations or the whole feature.
Source: APIFlask: API Documentations
Design-First with a Hand-Written Document
In design-first the document is the product of a design conversation. The team writes the YAML (an editor with an OpenAPI plug-in, or a design tool), reviews it, publishes it to front-end and partner teams, and only then implements. The risk is drift, so the workflow needs automation at three points: the document must be valid, the application must serve or reference it, and the implementation must be checked against it.
Validate the document. openapi-spec-validator (pip install openapi-spec-validator) checks a document against the
specification schema for the version it declares, including $ref resolution, and raises on the first problem.
Serve it. Nothing in Flask requires a framework: a view that returns the parsed document is enough, and any of the
UIs from the previous section can be pointed at it. With flask-smorest, set OPENAPI_URL_PREFIX to None (so the
generated document is not published) and serve your file from your own route. The book Microservice APIs replaces the
served document by monkeypatching APISpec.to_dict; that works only as a workaround, because it ties the application to
a private detail of the library and defeats the point of the generator. If you want the spec to drive the application
rather than sit beside it, Connexion maps each operationId to a Python function and validates requests and responses
against the document at run time.
Check the implementation. The simplest contract test needs only jsonschema: for every operation, call the endpoint
with the Flask test client and validate the response body against the schema the document declares for that status
code. Components are referenced as #/components/schemas/..., so the validator is given a root object that carries
both the schema and components. Dedicated tools do the same at scale: Schemathesis generates the requests from the
document and fuzzes the API, and openapi-core validates requests and responses with Flask integration (both
discussed in the page on API contract and property-based testing in this section; Dredd, used in the book, is
archived).
import yaml
from flask import Flask, jsonify
from jsonschema import Draft202012Validator
from openapi_spec_validator import validate
SPEC = yaml.safe_load(
"""
openapi: 3.1.0
info: {title: Bookshelf API, version: 1.0.0}
paths:
/books/{bookId}:
get:
operationId: getBook
parameters:
- {name: bookId, in: path, required: true, schema: {type: integer, minimum: 1}}
responses:
"200":
description: The book
content:
application/json:
schema: {$ref: "#/components/schemas/Book"}
"404":
description: Not found
content:
application/json:
schema: {$ref: "#/components/schemas/Problem"}
components:
schemas:
Book:
type: object
required: [id, title]
additionalProperties: false
properties:
id: {type: integer}
title: {type: string, minLength: 1}
subtitle: {type: [string, "null"]}
Problem:
type: object
required: [status, title]
properties:
status: {type: integer}
title: {type: string}
"""
)
validate(SPEC) # raises if the document itself is invalid
app = Flask(__name__)
@app.get("/openapi.json")
def serve_spec():
return jsonify(SPEC)
@app.get("/books/<int:book_id>")
def get_book(book_id):
if book_id != 1:
return jsonify(status=404, title="Not Found"), 404
return jsonify(id=1, title="Dune", subtitle=None)
def assert_conforms(path, method, status, body):
"""Validate a response body against the schema the document declares."""
response_doc = SPEC["paths"][path][method]["responses"][str(status)]
schema = response_doc["content"]["application/json"]["schema"]
root = {**schema, "components": SPEC["components"]}
problems = [e.message for e in Draft202012Validator(root).iter_errors(body)]
assert not problems, problems
client = app.test_client()
for book_id, expected in ((1, 200), (2, 404)):
response = client.get(f"/books/{book_id}")
assert response.status_code == expected
assert_conforms("/books/{bookId}", "get", expected, response.json)
print("implementation conforms")
try:
assert_conforms("/books/{bookId}", "get", 200, {"id": 1, "title": "", "isbn": "x"})
except AssertionError as err:
print("drift detected:", err)
Source: openapi-spec-validator
Generating Clients from the Document
Because the document is the contract, consumers can generate their side of it. Quality in equals quality out: give every
operation a stable operationId (flask-smorest does not invent one, so set it with @blp.doc(operationId=...);
APIFlask has the AUTO_OPERATION_ID setting) because generators use it as the method name, and give every schema a
meaningful name because they use it as the class name.
# Python client, typed, from a file or a URL (OpenAPI 3.0 and 3.1)
pipx install openapi-python-client --include-deps
openapi-python-client generate --path openapi.json
# Many languages through the Java-based OpenAPI Generator
npm install @openapitools/openapi-generator-cli -g
openapi-generator-cli generate -i openapi.json -g typescript-fetch -o ./client
Review generated clients like any dependency: regenerate them in CI from the committed document so that a breaking API change fails somewhere visible, and treat the generated code as build output, not as hand-edited source.
Source: openapi-python-client
Pitfalls and Security Notes
| Pitfall | What to do |
|---|---|
|
the response schema is bypassed (unfiltered data) and the operation has no documented success response; keep |
|
the status code comes first; fix the call ( |
Expecting |
it serializes only; test the output against the document |
Raising |
it becomes a 500; use |
Unknown fields silently dropped |
|
|
for non-body locations the requirement is per field in the schema; a schema with |
Wrong or missing |
the three required settings must be present; pick |
Blueprint registered with |
it works but is missing from the document; use |
Documentation endpoints exposed in production |
set |
Unpinned CDN scripts |
pin versions or self-host; they run with your origin’s privileges |
|
the document does not authenticate anything; protect the views |
Documented examples with real credentials |
use obvious placeholders; generated documents and Swagger "Authorize" fields are often copied around |
Generated document not reviewed |
commit it and diff it in CI so API changes are visible and deliberate |
Where the Books Are Outdated
The Manning book Microservice APIs (José Haro Peralta, 2022/2023) is a good guide to API design, and chapter 5 gives the anatomy of an OpenAPI document; for flask-smorest, its chapter 6 contains errors and outdated choices that this page corrects. The Packt Flask Framework Cookbook editions and Full-Stack Flask and React document APIs with Flask-RESTful or Postman collections instead of OpenAPI. In summary:
| In the book | Current behaviour |
|---|---|
|
it is the other way round: |
|
|
|
the status code is the first positional argument (since flask-smorest 0.29.0) |
marshmallow |
it is a 500; only |
the served document replaced by monkeypatching |
a workaround only; serve your own file, or use a design-first framework; see Design-First with a Hand-Written Document |
OpenAPI 3.0.3 |
3.1 is aligned with JSON Schema 2020-12 and is the better target for new documents; see Which OpenAPI Version |
RFC 7231 and a custom error payload |
RFC 7231 is superseded by RFC 9110; use RFC 9457 Problem Details; see Problem Details Instead of the Default Format |
Flask-RESTful with |
|
Dredd for contract testing |
archived; use Schemathesis or a validator-based test; see Design-First with a Hand-Written Document |
Postman collections as the API documentation (Full-Stack Flask and React) |
a Postman collection documents requests, not the contract; generate or write an OpenAPI document and import it |
Recent Changes
| Version | Change that matters here |
|---|---|
flask-smorest 0.29.0 (2021-02-13) |
the status code becomes the first positional argument of |
flask-smorest 0.40.0 (2022-10-16) |
apispec 6 is required |
flask-smorest 0.43.0 (2024-02-12) |
requires Flask 3.0.2 or later and Werkzeug 3.0 or later |
flask-smorest 0.46.1 (2025-04-26) |
marshmallow 4 is supported |
flask-smorest 0.47.0 (2026-03-22) |
Python 3.9 dropped |
flask-smorest 0.48.0 (unreleased at the check date) |
drops marshmallow 3; the Python 3.14 support is announced in its changelog |
Python 3.13 |
the generated names of the |
OpenAPI 3.1 and later |
JSON Schema 2020-12 dialect, |
Summary
-
An OpenAPI document is the contract of the API; renderers, clients, mocks and tests are generated from it. Flask has no built-in support, so the document comes from an extension.
-
Code-first with flask-smorest:
Api,Blueprint,MethodView, marshmallow schemas, and the decorators@blp.arguments,@blp.response,@blp.alt_response,@blp.paginateand@blp.doc, with@blp.routeoutermost. -
The unknown-field policy decides strict or tolerant input;
RAISEis the default and shows up asadditionalProperties: false. -
@blp.responseserializes but does not validate; errors raised inside views are not formatted unless they areHTTPExceptioninstances; Problem Details is a subclass ofApiaway. -
Set
OPENAPI_VERSIONto3.1.0, serve the UIs from pinned or self-hosted scripts, and disable or protect them in production. -
Commit the generated document (
flask openapi write, orflask specin APIFlask) and fail CI when it changes unexpectedly. -
Design-first means a validated hand-written document plus contract tests; Connexion routes from the document, and Schemathesis and
openapi-coreautomate the checks.