Building REST APIs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-19), Werkzeug 3.1.9 and Python 3.13, with marshmallow 4.3.1,
Pydantic 2.13.5, flask-smorest 0.47.0, APIFlask 3.1.2, Flask-RESTX 1.3.2, flask-openapi3 4.3.2 and Flask-RESTful 0.3.10,
all checked on 2026-10-07; every example was executed with those versions through Flask’s test client. Flask 3.2 is not
released yet; nothing on this page depends on it. A REST API is the part of the Bookshelf application that other
programs talk to: a single-page application, a mobile client, another microservice. The page starts with the design
decisions (resources, methods, status codes, error payloads, pagination, versions), builds the Bookshelf API in plain
Flask with MethodView, jsonify and get_json, then shows how validation and serialization libraries and the
API-oriented extensions take over parts of that work. The OpenAPI document that describes such an API, Swagger UI and
contract testing belong to the following pages of the section: OpenAPI & API Documentation and API Contract &
Property-Based Testing. Authentication is covered by JWT & API Authentication and CORS by Single-Page Apps & CORS.
REST in One Page
REST (Representational State Transfer) is an architectural style that Roy Fielding described in his doctoral dissertation; it is not a protocol and no library can make an API "RESTful" on its own. Applied to HTTP it comes down to six constraints, and Flask’s building blocks map onto them directly.
| Constraint | Meaning for an HTTP API | Where it shows up in Flask |
|---|---|---|
Client-server |
The client owns presentation, the server owns data and rules; they evolve independently. |
The API is a separate blueprint or application; see Application Factory & Project Layout. |
Stateless |
Every request carries everything the server needs; no server-side session between calls. |
No |
Cacheable |
Responses say whether and how long they may be reused. |
|
Uniform interface |
Resources identified by URLs, manipulated through representations, with standard methods and self-describing messages. |
|
Layered system |
Proxies, gateways and caches may sit between client and server without the client knowing. |
WSGI servers behind reverse proxies; |
Code on demand (optional) |
The server may ship executable code to the client. |
Almost never used by JSON APIs. |
The uniform interface includes hypermedia as the engine of application state (HATEOAS): representations contain links that tell the client what it can do next, so clients follow links instead of constructing URLs. Leonard Richardson’s maturity model, popularised by Martin Fowler, grades how far an API goes:
| Level | Name | What it adds |
|---|---|---|
0 |
The swamp of POX |
One URL, one method (usually |
1 |
Resources |
Many URLs, one per resource, but still mostly |
2 |
HTTP verbs |
Methods carry the semantics ( |
3 |
Hypermedia controls |
Responses include links to related resources and to the next possible actions. |
Most production "REST" APIs stop at level 2, and that is a legitimate choice. Adding a small links object to each
resource, as the Bookshelf API below does, is a cheap step towards level 3 that helps clients with pagination and
discoverability without a full hypermedia format.
The same discipline is what makes API-first (or documentation-driven) development work in a microservice architecture: the API is the integration point between services, so its contract (the OpenAPI document, the status codes, the error format) is written down and agreed before the implementation. Flask supports both directions, code-first with the extensions compared later on this page and design-first by serving a hand-written specification, which is described in OpenAPI & API Documentation. The architectural trade-offs of splitting a system into services are covered by Architectural Patterns.
Types of Web APIs Compared
REST is one of several API styles. The choice is about the clients and the data, not fashion.
| Style | What it exposes | Strengths | Typical use |
|---|---|---|---|
RPC and JSON-RPC |
Named procedures ( |
Simple, action-oriented, easy to map to functions |
Internal commands, tooling, editors (Language Server Protocol) |
SOAP |
XML envelopes described by WSDL |
Mature contracts, WS-* security and transaction extensions |
Legacy enterprise and banking integrations |
gRPC |
Protobuf services over HTTP/2, streaming |
Compact binary payloads, generated clients, bidirectional streaming |
Service-to-service calls inside a platform; not directly callable from a browser without a proxy |
REST |
Resources identified by URLs, standard HTTP methods |
Cache-friendly, uses every HTTP tool (proxies, CDNs, browsers), easy to debug with |
Public and partner APIs, SPAs, mobile back ends |
GraphQL |
One endpoint, a typed schema, queries chosen by the client |
The client selects fields and fetches related data in one round trip |
Many different clients with different data needs |
Flask is a good fit for REST and RPC style APIs and can serve GraphQL through Ariadne or Strawberry; the GraphQL section of this site has the decision guide in Tips: When to Use GraphQL or a Typical REST API and the Python implementations in Python with FastAPI and Strawberry and Python with Ariadne. FastAPI-based REST APIs are described in FastAPI: Introduction & Architecture.
Designing Resources and URLs
A resource is a noun the API talks about: a book, a shelf, a loan. The URL identifies it, the method says what to do, the body is a representation of it. The conventions that keep an API predictable:
-
Use plural nouns for collections (
/books) and the identifier for a single item (/books/42). The collection and the item are the two kinds of endpoint most resources need; a singleton resource that exists once per parent has no identifier of its own (/books/42/cover,/me). -
Never put verbs in the path for ordinary CRUD (
/getBook,/books/delete). When an operation really is not a CRUD verb, model it as a resource (POST /books/42/loanscreates a loan) or as a sub-resource action (POST /books/42/archive) and say so in the documentation. -
Nest only one level for ownership (
/shelves/3/books) and give every resource a top-level URL as well; deep trees make URLs long and clients fragile. -
Use lower-case, hyphen-separated path segments and identifiers that do not change. Remember Flask’s trailing-slash rule: a route declared with a trailing slash redirects the slash-less form, a route without one answers 404 for the slashed form (see Routing & URL Building). Pick the slash-less form for APIs; clients such as
fetchandrequestsfollow redirects but may turn aPOSTinto aGETon a 301 or 302. -
Put the what in the path and the how in the query string: filtering, sorting, searching and paging are query parameters, never separate endpoints.
The method decides the semantics. Two properties matter to clients and proxies: a method is safe if it does not change state, and idempotent if sending it twice has the same effect as sending it once. Retrying is only automatic for idempotent methods.
| Request | Meaning | Safe | Idempotent | Success status |
|---|---|---|---|---|
|
List (filtered, sorted, paged) books |
yes |
yes |
|
|
Create a book; the server chooses the identifier |
no |
no |
|
|
Read one book |
yes |
yes |
|
|
Replace the whole book (or create it at a client-chosen URL) |
no |
yes |
|
|
Change some fields of the book |
no |
not guaranteed |
|
|
Remove the book |
no |
yes |
|
|
Headers only; allowed methods (Flask adds both automatically) |
yes |
yes |
|
HTTP semantics are defined in RFC 9110, which in June 2022 replaced RFC 7231 and its siblings; older books and tutorials still cite the old numbers.
Status Codes and Error Payloads
The status code is the first thing a client checks, so it must be right. The ones a JSON API uses every day:
| Code | Name | Use in the Bookshelf API |
|---|---|---|
|
OK |
Successful |
|
Created |
Successful |
|
No Content |
Successful |
|
Not Modified |
A conditional |
|
Bad Request |
The request cannot be parsed: malformed JSON, a query parameter that is not a number. |
|
Unauthorized, Forbidden |
No or invalid credentials, or valid credentials without permission (see the authentication and authorization pages). |
|
Not Found |
The resource does not exist (or the caller must not know that it exists). |
|
Method Not Allowed |
The URL exists but not for this method; Flask sends an |
|
Conflict |
The request conflicts with the current state, for example a duplicate ISBN. |
|
Precondition Failed |
An |
|
Unsupported Media Type |
The body is not |
|
Unprocessable Content |
The JSON is well formed but violates the rules: a missing field, a value out of range. |
|
Too Many Requests |
Rate limit exceeded; add |
|
Server errors |
A bug ( |
Two distinctions cause most of the arguments. First, 4xx means the client must change the request and 5xx means the
server failed; clients and monitoring treat them differently, so an exception caused by bad input must never become a
500. Second, 400 versus 422: use 400 when the request cannot be understood at all and 422 (named
Unprocessable Content in RFC 9110) when it is understood but invalid. Many APIs use 400 for both; what matters is
being consistent. flask-smorest and APIFlask answer 422 for validation errors, and so does this page.
Error bodies should have one shape across the whole API. The standard shape is Problem Details, defined by RFC 9457
(which obsoleted RFC 7807) and sent as application/problem+json; the fields type, title, status, detail and
instance are standard and extension members such as errors are allowed. How Flask finds an error handler, and the
HTTPException hierarchy, are explained in Error Handling; here is the small
toolkit the rest of this page uses: an exception that carries a status and extra members, and a function that turns it
into a response.
import itertools
from threading import Lock
from flask import Flask, Blueprint, jsonify, request, url_for
from flask.views import MethodView
from werkzeug.exceptions import HTTPException
class ApiError(Exception):
"""An error that becomes an RFC 9457 Problem Details response."""
def __init__(self, status, title, detail=None, **extra):
super().__init__(title)
self.status, self.title, self.detail, self.extra = status, title, detail, extra
def problem(status, title, detail=None, **extra):
body = {"type": "about:blank", "title": title, "status": status}
if detail:
body["detail"] = detail
body.update(extra)
response = jsonify(body)
response.status_code = status
response.mimetype = "application/problem+json"
return response
def register_error_handlers(app):
@app.errorhandler(ApiError)
def handle_api_error(exc):
return problem(exc.status, exc.title, exc.detail, **exc.extra)
@app.errorhandler(HTTPException)
def handle_http_exception(exc):
response = problem(exc.code, exc.name, exc.description)
if getattr(exc, "valid_methods", None):
response.headers["Allow"] = ", ".join(exc.valid_methods)
return response
Registering a handler for HTTPException makes every framework-generated error (a 404 for an unknown URL, a 405, a 413
for an oversized body, the 400 and 415 raised by get_json()) come back in the same shape. The HTTPException handler
builds a new response, so it must copy headers that matter, such as Allow on a 405. The type about:blank means
"no more information than the status code"; a real API can replace it with a URL that documents each kind of problem.
Filtering, Sorting and Pagination
A collection endpoint must never return "everything": the response grows without bound and one client can exhaust the server. The conventions are query parameters on the collection URL, parsed and validated like any other input:
-
Filtering: one parameter per field (
?author=Ursula K. Le Guin) or a general search parameter (?q=dune). -
Sorting:
?sort=-year,title, a comma-separated list where a leading minus means descending. Accept only an allow-list of field names; passing a client string toorder_byis an injection risk. -
Pagination: either offset pagination (
?page=2&per_page=20), easy to implement and to jump around in, or keyset (cursor) pagination (?after=<cursor>&limit=20), which stays fast and stable for large or changing tables.
The helpers below work on a list of dictionaries so that they run without a database; the SQLAlchemy equivalents
(db.paginate() and order_by) are in Flask-SQLAlchemy. They validate every
parameter explicitly: Werkzeug’s request.args.get("page", 1, type=int) silently falls back to the default when the
value is not a number, which hides client bugs.
import math
SORTABLE = {"id", "title", "author", "year"}
MAX_PER_PAGE = 100
def int_arg(args, name, default, minimum, maximum=None):
raw = args.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError:
raise ApiError(400, "Invalid query parameter", f"{name} must be an integer", parameter=name)
if value < minimum or (maximum is not None and value > maximum):
raise ApiError(400, "Invalid query parameter", f"{name} is out of range", parameter=name)
return value
def query_books(books, args):
"""Filter, sort and paginate a list of book dictionaries; returns (items, meta)."""
page = int_arg(args, "page", 1, 1)
per_page = int_arg(args, "per_page", 20, 1, MAX_PER_PAGE)
if author := args.get("author"):
books = [b for b in books if b["author"].casefold() == author.casefold()]
if q := args.get("q"):
books = [b for b in books if q.casefold() in b["title"].casefold()]
for key in reversed(args.get("sort", "id").split(",")): # stable sort: last key first
field = key.lstrip("-")
if field not in SORTABLE:
raise ApiError(400, "Invalid query parameter", f"cannot sort by {field}", parameter="sort")
books = sorted(books, key=lambda b: (b.get(field) is None, b.get(field) or 0),
reverse=key.startswith("-"))
total = len(books)
pages = max(1, math.ceil(total / per_page))
start = (page - 1) * per_page
meta = {"page": page, "per_page": per_page, "total": total, "pages": pages}
return books[start:start + per_page], meta
def link_header(endpoint, meta, args):
"""RFC 8288 Link header with first, last, prev and next relations."""
def link(page, rel):
return f'<{url_for(endpoint, **{**args.to_dict(), "page": page})}>; rel="{rel}"'
links = [link(1, "first"), link(meta["pages"], "last")]
if meta["page"] > 1:
links.append(link(meta["page"] - 1, "prev"))
if meta["page"] < meta["pages"]:
links.append(link(meta["page"] + 1, "next"))
return ", ".join(links)
Source: Werkzeug: MultiDict
The response carries the page data in the body (an items array and a meta object) and the navigation in a Link
header, which is the standard way (RFC 8288) to point to the neighbouring pages; clients that only read the body can use
meta. Offset pagination has two known weaknesses: OFFSET n makes the database walk past n rows, and a row inserted
or deleted between two requests shifts every later page, so a client can see an item twice or miss one. Keyset
pagination avoids both by asking for "the rows after this one"; the cursor is an opaque token that clients must not
interpret (so the server can change it later).
import base64
import json
def encode_cursor(book_id):
return base64.urlsafe_b64encode(json.dumps({"after": book_id}).encode()).decode()
def decode_cursor(token):
try:
return int(json.loads(base64.urlsafe_b64decode(token.encode()))["after"])
except (ValueError, KeyError, TypeError):
raise ApiError(400, "Invalid cursor", "the cursor is malformed", parameter="after")
def keyset_page(books, after=None, limit=20):
last_seen = decode_cursor(after) if after else 0
window = [b for b in sorted(books, key=lambda b: b["id"]) if b["id"] > last_seen][:limit + 1]
items = window[:limit]
next_cursor = encode_cursor(items[-1]["id"]) if len(window) > limit else None
return items, next_cursor
Source: Flask API: Request.args
Choose offset pagination for admin screens and small tables where "page 7 of 12" matters, and keyset pagination for feeds,
exports and anything that grows quickly. With a real database, keyset pagination needs a unique, indexed sort key
(WHERE id > :last ORDER BY id LIMIT :n). flask-smorest ships its own @blp.paginate() decorator that fills an
X-Pagination response header; it is shown with that library below.
The Bookshelf API in Plain Flask
Flask needs no extension to build a REST API: MethodView maps HTTP methods to methods of a class, request.get_json()
reads the body and jsonify() writes it (class-based views are explained in
Class-based Views, request parsing in Request Data
and responses in Responses & JSON). The figure shows the path of one request
through the layers of the API built below; every arrow that leaves the happy path ends in a Problem Details response.
The layering matters more than the framework: the view translates between HTTP and the application (parse, validate, choose the status code), while the rules live in a service or repository that knows nothing about requests. Here the repository is an in-memory dictionary behind a lock, so the example runs anywhere; replace it with the SQLAlchemy models from the earlier pages without touching the views. Validation is passed to the view as a function, first a small hand-written one, later marshmallow and Pydantic, to show that the layers are independent.
class BookRepository:
"""In-memory stand-in for the database layer of the earlier pages."""
def __init__(self):
self._books, self._ids, self._lock = {}, itertools.count(1), Lock()
def all(self):
return list(self._books.values())
def get(self, book_id):
return self._books.get(book_id)
def find_isbn(self, isbn):
return next((b for b in self._books.values() if b["isbn"] == isbn), None)
def add(self, data):
with self._lock:
book = {"id": next(self._ids), **data}
self._books[book["id"]] = book
return book
def replace(self, book_id, data):
self._books[book_id] = {"id": book_id, **data}
return self._books[book_id]
def delete(self, book_id):
return self._books.pop(book_id, None)
FIELDS = {"title": str, "author": str, "isbn": str, "year": int}
REQUIRED = {"title", "author", "isbn"}
def parse_book(payload, partial=False):
"""Hand-written validation; the real projects below use a library."""
if not isinstance(payload, dict):
raise ApiError(422, "Validation failed", errors={"_body": ["Expected a JSON object."]})
errors = {name: ["Unknown field."] for name in payload.keys() - FIELDS.keys()}
if not partial:
for name in REQUIRED - payload.keys():
errors[name] = ["Missing data for required field."]
for name, kind in FIELDS.items():
if name in payload:
value = payload[name]
if kind is int:
valid = isinstance(value, int) and not isinstance(value, bool)
else:
valid = isinstance(value, str) and value.strip() != ""
if not valid:
errors[name] = ["Must be an integer." if kind is int else "Must be a non-empty string."]
if errors:
raise ApiError(422, "Validation failed", "The request body is not valid.", errors=errors)
return {name: payload[name] for name in FIELDS if name in payload}
Source: Flask API: Request.get_json
Now the views. as_view() passes its extra arguments to the class constructor, which is how the repository and the
validation function reach the views without globals. The item view implements the full method set; the comments mark the
decisions a reviewer should check.
from werkzeug.http import generate_etag
def book_resource(book):
"""Representation with hypermedia links (a step towards Richardson level 3)."""
return {**book, "links": {"self": url_for("api_v1.book_item", book_id=book["id"]),
"collection": url_for("api_v1.book_collection")}}
def etag_for(book):
return generate_etag(json.dumps(book, sort_keys=True).encode())
class BookCollection(MethodView):
def __init__(self, repo, parse):
self.repo, self.parse = repo, parse
def get(self):
items, meta = query_books(self.repo.all(), request.args)
response = jsonify({"items": [book_resource(b) for b in items], "meta": meta})
response.headers["Link"] = link_header("api_v1.book_collection", meta, request.args)
response.headers["X-Total-Count"] = str(meta["total"])
return response
def post(self):
data = self.parse(request.get_json()) # 400 or 415 here, 422 in parse
if self.repo.find_isbn(data["isbn"]):
raise ApiError(409, "Duplicate ISBN", f"A book with ISBN {data['isbn']} already exists.")
book = self.repo.add(data)
location = url_for("api_v1.book_item", book_id=book["id"])
return jsonify(book_resource(book)), 201, {"Location": location}
class BookItem(MethodView):
def __init__(self, repo, parse):
self.repo, self.parse = repo, parse
def _book_or_404(self, book_id):
book = self.repo.get(book_id)
if book is None:
raise ApiError(404, "Book not found", f"There is no book with id {book_id}.")
return book
def _check_isbn(self, isbn, own_id):
other = self.repo.find_isbn(isbn)
if other and other["id"] != own_id:
raise ApiError(409, "Duplicate ISBN", f"A book with ISBN {isbn} already exists.")
def _check_if_match(self, book):
if request.if_match and not request.if_match.contains(etag_for(book)):
raise ApiError(412, "Precondition failed", "The book changed since you read it.")
def get(self, book_id):
book = self._book_or_404(book_id)
response = jsonify(book_resource(book))
response.set_etag(etag_for(book))
return response.make_conditional(request) # 304 when If-None-Match matches
def put(self, book_id): # replace: all required fields again
book = self._book_or_404(book_id)
self._check_if_match(book)
data = self.parse(request.get_json())
self._check_isbn(data["isbn"], book_id)
return jsonify(book_resource(self.repo.replace(book_id, data)))
def patch(self, book_id): # JSON Merge Patch (RFC 7396)
book = self._book_or_404(book_id)
self._check_if_match(book)
changes = self.parse(request.get_json(), partial=True)
if "isbn" in changes:
self._check_isbn(changes["isbn"], book_id)
merged = {**{k: v for k, v in book.items() if k != "id"}, **changes}
return jsonify(book_resource(self.repo.replace(book_id, merged)))
def delete(self, book_id):
self._book_or_404(book_id)
self.repo.delete(book_id)
return "", 204
def create_app(repo=None, parse=parse_book):
app = Flask(__name__)
repo = repo if repo is not None else BookRepository()
api = Blueprint("api_v1", __name__, url_prefix="/api/v1")
api.add_url_rule("/books", view_func=BookCollection.as_view("book_collection", repo, parse))
api.add_url_rule("/books/<int:book_id>", view_func=BookItem.as_view("book_item", repo, parse))
app.register_blueprint(api)
register_error_handlers(app)
return app
Source: Flask: Method-based Dispatching
The decisions in that code are worth spelling out.
-
POSTreturns201and aLocationheader with the URL of the new item, so the client never has to build it. The header is relative here; Werkzeug 2.1 and later no longer rewriteLocationto an absolute URL, and RFC 9110 allows a relative reference. Useurl_for(..., _external=True)if a client demands an absolute one. -
PUTreplaces,PATCHmerges. APUTbody must be a complete representation, so the required fields are required again and anything omitted is gone; it is idempotent. APATCHbody lists only the fields to change. The format used here is JSON Merge Patch (RFC 7396, media typeapplication/merge-patch+json): the body is a partial object and anullvalue removes a field. Its alternative, JSON Patch (RFC 6902), is a list of explicit operations ({"op": "replace", "path": "/year", "value": 1969}) and suits arrays and atomic multi-field edits. Whichever format you choose, document it, becausePATCHhas no default body format. Flask’srequest.get_json()accepts anyapplication/*+jsonmedia type, so the merge-patch type needs no special handling. -
DELETEreturns204with an empty body. Returning202 Acceptedwith a body, as some tutorials do, promises asynchronous processing that did not happen; use200with a body only if the client needs the deleted representation. -
409for state conflicts and404for missing items are raised asApiError, never returned as200with{"error": ...}. -
ETagandIf-Matchimplement optimistic concurrency: the client reads a book, sends theETagback inIf-Matchwith its update, and gets412if someone else changed the book in between; without that, two editors silently overwrite each other (the "lost update").make_conditional()turns a matchingIf-None-Matchinto a304with no body. -
Unknown fields are rejected (
422), so a typo such astitelis reported instead of being ignored.
Exercise the API with the test client. Every assertion here is what a pytest test of the API would contain; fixtures and test layout are described in the testing page of this section.
repo = BookRepository()
app = create_app(repo)
client = app.test_client()
created = client.post("/api/v1/books", json={"title": "Dune", "author": "Frank Herbert",
"isbn": "9780000000001", "year": 1965})
assert created.status_code == 201
location = created.headers["Location"]
assert location == "/api/v1/books/1"
for i, (title, author) in enumerate([("Emma", "Jane Austen"), ("Persuasion", "Jane Austen"),
("Dune Messiah", "Frank Herbert")], start=2):
client.post("/api/v1/books", json={"title": title, "author": author, "isbn": f"978000000000{i}"})
assert client.post("/api/v1/books", json={"title": "Dune", "author": "F", "isbn": "9780000000001"}).status_code == 409
invalid = client.post("/api/v1/books", json={"title": "", "titel": "typo"})
assert invalid.status_code == 422 and invalid.mimetype == "application/problem+json"
assert client.post("/api/v1/books", data="{broken", content_type="application/json").status_code == 400
assert client.post("/api/v1/books", data="title=Dune", content_type="text/plain").status_code == 415
assert client.get("/api/v1/books/99").status_code == 404
assert client.patch("/api/v1/books").status_code == 405
item = client.get(location)
etag = item.headers["ETag"]
assert client.get(location, headers={"If-None-Match": etag}).status_code == 304
patched = client.patch(location, data=json.dumps({"year": 1966}), content_type="application/merge-patch+json")
assert patched.json["year"] == 1966 and patched.json["title"] == "Dune"
assert client.put(location, json={"title": "Dune"}, headers={"If-Match": etag}).status_code == 412
assert client.put(location, json={"title": "Dune", "author": "Frank Herbert", "isbn": "9780000000001"}).status_code == 200
assert "year" not in client.get(location).json # PUT replaced the whole book
page = client.get("/api/v1/books?author=jane austen&sort=-title&per_page=1&page=2")
assert [b["title"] for b in page.json["items"]] == ["Emma"]
assert page.headers["X-Total-Count"] == "2"
assert client.get("/api/v1/books?per_page=1000").status_code == 400
assert client.get("/api/v1/books?sort=password").status_code == 400
assert client.delete(location).status_code == 204 and client.get(location).status_code == 404
The same requests with curl (the -i flag prints the status line and headers) and the responses the code above
produces:
curl -i -X POST http://localhost:5000/api/v1/books \
-H 'Content-Type: application/json' \
-d '{"title": "Dune", "author": "Frank Herbert", "isbn": "9780000000001", "year": 1965}'
HTTP/1.1 201 CREATED
Content-Type: application/json
Location: /api/v1/books/1
{"author":"Frank Herbert","id":1,"isbn":"9780000000001","links":{"collection":"/api/v1/books","self":"/api/v1/books/1"},"title":"Dune","year":1965}
curl -i -X POST http://localhost:5000/api/v1/books \
-H 'Content-Type: application/json' -d '{"title": "", "titel": "typo"}'
HTTP/1.1 422 UNPROCESSABLE ENTITY
Content-Type: application/problem+json
{"detail":"The request body is not valid.","errors":{"author":["Missing data for required field."],"isbn":["Missing data for required field."],"titel":["Unknown field."],"title":["Must be a non-empty string."]},"status":422,"title":"Validation failed","type":"about:blank"}
Werkzeug 3.1 still prints the older reason phrase UNPROCESSABLE ENTITY for 422; the status code is what clients must
test. A view that builds JSON by hand with json.dumps and returns the string sends it as text/html; use jsonify or
return a dictionary, as described in Responses & JSON.
Validation and Serialization
Hand-written validation does not scale: every field needs type checks, ranges, formats, nested objects and clear error messages, and the same rules must be repeated for the documentation. Two libraries dominate Python APIs, and Flask works with both because validation happens in your view (or in a decorator), not in the framework.
marshmallow describes data with Schema classes of fields; the same schema loads (validates and deserializes
input) and dumps (serializes output). Pydantic describes data with annotated classes (BaseModel) and validates on
construction; it is the library behind FastAPI. The two are interchangeable for the API-building purpose of this page; the
usual rule is to follow the extension: flask-smorest and APIFlask’s default are marshmallow, flask-openapi3 and
APIFlask’s Pydantic mode use Pydantic.
marshmallow
from marshmallow import (EXCLUDE, INCLUDE, RAISE, Schema, ValidationError, fields, validate)
class PublisherSchema(Schema):
name = fields.Str(required=True)
country = fields.Str(validate=validate.Length(equal=2))
class BookSchema(Schema):
id = fields.Int(dump_only=True) # never accepted from the client
title = fields.Str(required=True, validate=validate.Length(min=1, max=200))
author = fields.Str(required=True, validate=validate.Length(min=1, max=120))
isbn = fields.Str(required=True, validate=validate.Regexp(r"^\d{13}$"))
year = fields.Int(validate=validate.Range(min=1450, max=2100))
class BookDetailSchema(BookSchema): # schema inheritance
publisher = fields.Nested(PublisherSchema) # nested object
def parse_book_marshmallow(payload, partial=False):
try:
return BookSchema().load(payload, partial=partial)
except ValidationError as exc:
raise ApiError(422, "Validation failed", "The request body is not valid.", errors=exc.messages)
marshmallow_client = create_app(parse=parse_book_marshmallow).test_client()
bad = marshmallow_client.post("/api/v1/books", json={"title": "Dune", "isbn": "12", "id": 7})
assert bad.status_code == 422
assert set(bad.json["errors"]) == {"author", "isbn", "id"} # id is dump_only: unknown on load
detail = BookDetailSchema().load({"title": "Dune", "author": "Frank Herbert", "isbn": "9780000000001",
"publisher": {"name": "Chilton", "country": "US"}})
assert detail["publisher"]["country"] == "US"
Source: marshmallow: Quickstart
load() returns the validated dictionary or raises ValidationError whose messages attribute is a dictionary of
field name to list of messages, which fits the errors member of the Problem Details body directly. The fields most APIs
need:
| Feature | Notes |
|---|---|
|
Required input and defaults for missing input or output. The older |
|
Server-generated fields ( |
|
|
|
Field-level and cross-field validation. |
|
Nested objects and lists ( |
|
The JSON name differs from the attribute name ( |
|
Skip required checks for |
|
What to do with fields the schema does not declare: |
|
Refuse |
The unknown-field policy is the setting most often misread, so here are the three values side by side:
payload = {"title": "Dune", "author": "Frank Herbert", "isbn": "9780000000001", "edition": 2}
try:
BookSchema(unknown=RAISE).load(payload) # the default
except ValidationError as exc:
assert exc.messages == {"edition": ["Unknown field."]}
assert "edition" not in BookSchema(unknown=EXCLUDE).load(payload) # silently dropped
assert BookSchema(unknown=INCLUDE).load(payload)["edition"] == 2 # kept, unvalidated
RAISE (the default) rejects the request, EXCLUDE silently discards the field and INCLUDE passes it through
unvalidated. A published book on microservice APIs states the opposite for EXCLUDE; trust the documentation. RAISE
is the right default for request bodies (typos are reported, and clients cannot smuggle extra attributes into a
mass-assignment). EXCLUDE is acceptable for query strings, where proxies and analytics tools append unrelated
parameters, and when you must stay tolerant of newer clients.
Pydantic
from pydantic import BaseModel, ConfigDict, Field
from pydantic import ValidationError as PydanticValidationError
class BookIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=120)
isbn: str = Field(pattern=r"^\d{13}$")
year: int | None = Field(default=None, ge=1450, le=2100)
class BookPatch(BaseModel):
"""Every field optional; a field that is present must still be valid (null is rejected)."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: str = Field(default=None, min_length=1, max_length=200)
author: str = Field(default=None, min_length=1, max_length=120)
isbn: str = Field(default=None, pattern=r"^\d{13}$")
year: int = Field(default=None, ge=1450, le=2100)
def parse_book_pydantic(payload, partial=False):
model = BookPatch if partial else BookIn
try:
return model.model_validate(payload).model_dump(exclude_unset=True)
except PydanticValidationError as exc:
errors = {}
for item in exc.errors(include_url=False, include_context=False, include_input=False):
name = ".".join(str(part) for part in item["loc"]) or "_body"
errors.setdefault(name, []).append(item["msg"])
raise ApiError(422, "Validation failed", "The request body is not valid.", errors=errors)
pydantic_client = create_app(parse=parse_book_pydantic).test_client()
bad = pydantic_client.post("/api/v1/books", json={"title": " ", "isbn": "x", "year": "soon", "edition": 2})
assert bad.status_code == 422
assert set(bad.json["errors"]) == {"title", "author", "isbn", "year", "edition"}
ok = pydantic_client.post("/api/v1/books", json={"title": " Dune ", "author": "Frank Herbert",
"isbn": "9780000000001", "year": "1965"})
assert ok.status_code == 201 and ok.json["title"] == "Dune" and ok.json["year"] == 1965 # lax coercion
assert pydantic_client.patch("/api/v1/books/1", json={"title": None}).status_code == 422
Source: Pydantic: Models
extra="forbid" is Pydantic’s RAISE: without it Pydantic ignores unknown keys. model_dump(exclude_unset=True) returns
only the fields the client actually sent, which is what a PATCH handler needs; it is easy to forget and to overwrite
fields with defaults. The errors() method returns a list of dictionaries (location, message, type); the include_*
arguments keep out the documentation URL, the context (which can hold non-serializable exception objects) and the
submitted input (which can echo secrets back to the client).
Both libraries are lax by default and convert compatible values ("1965" becomes 1965). Use strict=True on a
field or model when the API contract says that numbers are numbers. For output, serialization is a separate concern from
validation: marshmallow’s dump() and the output decorators of the extensions below serialize and do not validate
what your code returns. If you want guarantees about outgoing data, validate it explicitly with a Pydantic output model
or check it in tests against the OpenAPI document (API Contract & Property-Based Testing).
class BookOut(BaseModel):
id: int
title: str
author: str
isbn: str
year: int | None = None
assert BookOut.model_validate({"id": 1, "title": "Dune", "author": "Frank Herbert",
"isbn": "9780000000001"}).model_dump(mode="json")["year"] is None
assert BookSchema().dump({"id": 1, "title": "Dune", "author": "Frank Herbert", "isbn": "9780000000001"})["id"] == 1
Source: Pydantic: Serialization
Versioning and Deprecation
An API is a contract: once clients depend on a field, removing or renaming it breaks them. Versioning lets you ship an incompatible change next to the old behaviour and retire the old one on a schedule. Not every change needs a new version:
| Compatible (no new major version) | Breaking (new major version) |
|---|---|
Adding an endpoint |
Removing or renaming an endpoint |
Adding an optional request field or query parameter |
Making an optional field required |
Adding a field to a response |
Removing, renaming or retyping a response field |
Adding a new value to an extensible enumeration (if documented as extensible) |
Changing the meaning of a status code or of a field |
Making validation more lenient |
Making validation stricter |
Clients must therefore ignore response fields they do not know. The three places to put the version:
| Where | Example | Notes |
|---|---|---|
URL path |
|
The most common: visible, easy to route, cache and test with |
Header (media type) |
|
Keeps URLs stable and follows HTTP content negotiation; harder to try in a browser and to document. |
Query parameter |
|
Easy but easy to forget and awkward for caching; generally discouraged. |
In Flask, a URL version is a blueprint per major version with its own url_prefix (see
Blueprints), which also lets the two versions have different error handlers and
different schemas while sharing the service layer. Header-based versioning is a before_request function or a view that
inspects request.accept_mimetypes. Semantic versioning applies to the API as MAJOR.MINOR.PATCH, but only the major
number appears in URLs; the full number belongs in the OpenAPI info.version field and in release notes.
A version is retired in stages: announce, mark deprecated, set a retirement date, then remove (answering 410 Gone
for a while is kinder than a 404). Two response headers let machines read the status: Deprecation (RFC 9745,
published in March 2025) holds the date the endpoint became deprecated as an @-prefixed Unix timestamp, and Sunset
(RFC 8594) holds the HTTP date after which it may stop working. A Link header with the relation successor-version
points to the replacement, and deprecation points to the migration guide.
import datetime
from werkzeug.http import http_date
DEPRECATED_SINCE = datetime.datetime(2026, 10, 1, tzinfo=datetime.timezone.utc)
SUNSET = datetime.datetime(2027, 6, 30, tzinfo=datetime.timezone.utc)
BOOKS = {1: {"id": 1, "title": "Dune", "authors": ["Frank Herbert"]}}
def v1_book(book): # the old shape: one author string
return {"id": book["id"], "title": book["title"], "author": book["authors"][0]}
def v2_book(book): # the new shape: a list of authors
return {"id": book["id"], "title": book["title"], "authors": book["authors"]}
v1 = Blueprint("v1", __name__, url_prefix="/api/v1")
v2 = Blueprint("v2", __name__, url_prefix="/api/v2")
@v1.get("/books/<int:book_id>")
def v1_get(book_id):
return jsonify(v1_book(BOOKS[book_id]))
@v2.get("/books/<int:book_id>")
def v2_get(book_id):
return jsonify(v2_book(BOOKS[book_id]))
@v1.after_request
def mark_v1_deprecated(response):
response.headers["Deprecation"] = f"@{int(DEPRECATED_SINCE.timestamp())}"
response.headers["Sunset"] = http_date(SUNSET)
response.headers.add("Link", f'<{request.path.replace("/v1/", "/v2/", 1)}>; rel="successor-version"')
return response
negotiated = Flask(__name__)
negotiated.register_blueprint(v1)
negotiated.register_blueprint(v2)
@negotiated.get("/api/books/<int:book_id>") # version chosen by the Accept header
def negotiated_get(book_id):
media = request.accept_mimetypes.best_match(
["application/vnd.bookshelf.v2+json", "application/vnd.bookshelf.v1+json"])
if media is None:
raise ApiError(406, "Not acceptable", "Supported: application/vnd.bookshelf.v1+json and v2+json")
shape = v1_book if media.endswith("v1+json") else v2_book
response = jsonify(shape(BOOKS[book_id]))
response.mimetype = media
response.headers["Vary"] = "Accept"
return response
register_error_handlers(negotiated)
vc = negotiated.test_client()
old = vc.get("/api/v1/books/1")
assert old.json == {"id": 1, "title": "Dune", "author": "Frank Herbert"}
assert old.headers["Deprecation"] == "@1790812800" and old.headers["Sunset"] == "Wed, 30 Jun 2027 00:00:00 GMT"
assert old.headers["Link"] == '</api/v2/books/1>; rel="successor-version"'
assert "Deprecation" not in vc.get("/api/v2/books/1").headers
assert vc.get("/api/books/1", headers={"Accept": "application/vnd.bookshelf.v2+json"}).json["authors"]
assert vc.get("/api/books/1", headers={"Accept": "text/html"}).status_code == 406
The Vary: Accept header matters for header-based versioning: without it a shared cache may serve the version-1 body to a
version-2 client. A deprecated endpoint should keep working until the Sunset date; log which clients still call it so
that you know when the removal is safe.
Extensions That Build REST APIs
Plain Flask is enough for a small API; the extensions below remove the repetition (decorators that validate input and serialize output, error formatting, pagination helpers) and generate an OpenAPI document from the same code. They differ in which validation library they use, how actively they are maintained and how they treat the OpenAPI standard.
| Extension (checked 2026-10-07) | Validation and serialization | OpenAPI | Latest release | Notes |
|---|---|---|---|---|
flask-smorest 0.47.0 |
marshmallow (webargs underneath) |
OpenAPI 3.x (3.1 supported), Swagger UI, ReDoc, RapiDoc |
2026-03-22 |
Maintained under the marshmallow-code organization; |
APIFlask 3.1.2 |
marshmallow or Pydantic (schema adapters) |
OpenAPI 3.1 by default, built-in docs UIs |
2026-09-12 |
A |
Flask-RESTX 1.3.2 |
Its own |
Swagger 2.0 only |
2025-09-23 |
Community continuation of Flask-RESTPlus; |
flask-openapi3 4.3.2 |
Pydantic v2 models from type annotations |
OpenAPI 3.1 with Swagger, ReDoc, RapiDoc and others |
2026-05-09 |
|
Flask-RESTful 0.3.10 |
|
none |
2023-05-21 |
|
A related pair of libraries is useful with any of them: webargs parses and validates query strings and bodies with
marshmallow schemas (flask-smorest uses it internally) and Flask-Marshmallow integrates marshmallow with Flask and
SQLAlchemy models. The choice usually follows the question "marshmallow or Pydantic?": flask-smorest if you prefer
marshmallow and MethodView classes, flask-openapi3 for annotation-driven Pydantic, APIFlask if you want either behind a
single API with function views.
flask-smorest
flask-smorest builds on marshmallow, webargs and apispec. Its Api object wraps the Flask application, its Blueprint
subclass collects the routes, and the decorators arguments and response validate input and serialize output. The
configuration keys API_TITLE and API_VERSION are required to generate the OpenAPI document and OPENAPI_VERSION
selects the specification version (the keys that publish the document and the documentation UIs are explained in
OpenAPI & API Documentation). The example reuses the BookSchema defined above.
import flask_smorest
smorest_app = Flask(__name__)
smorest_app.config.update(API_TITLE="Bookshelf API", API_VERSION="v1", OPENAPI_VERSION="3.1.0")
smorest_api = flask_smorest.Api(smorest_app)
blp = flask_smorest.Blueprint("books", __name__, url_prefix="/books", description="Bookshelf books")
shelf = {1: {"id": 1, "title": "Dune", "author": "Frank Herbert", "isbn": "9780000000001"}}
class BookQuery(Schema):
q = fields.Str()
@blp.route("/") # route is always the outermost decorator
class Books(MethodView):
@blp.arguments(BookQuery, location="query") # query string -> first extra argument
@blp.response(200, BookSchema(many=True)) # the status code is the FIRST positional argument
@blp.paginate() # adds page and page_size, X-Pagination header
def get(self, args, pagination_parameters):
books = [b for b in shelf.values() if args.get("q", "").casefold() in b["title"].casefold()]
pagination_parameters.item_count = len(books)
return books[pagination_parameters.first_item:pagination_parameters.last_item + 1]
@blp.arguments(BookSchema) # JSON body, validated; 422 on error
@blp.response(201, BookSchema)
def post(self, new_book):
new_book["id"] = max(shelf) + 1
shelf[new_book["id"]] = new_book
return new_book, 201, {"Location": f"/books/{new_book['id']}"}
@blp.route("/<int:book_id>")
class BookById(MethodView):
@blp.response(200, BookSchema)
def get(self, book_id):
if book_id not in shelf:
flask_smorest.abort(404, message="Book not found") # formatted JSON error
return shelf[book_id]
@blp.arguments(BookSchema(partial=True))
@blp.response(200, BookSchema)
def patch(self, changes, book_id):
if book_id not in shelf:
flask_smorest.abort(404, message="Book not found")
shelf[book_id].update(changes)
return shelf[book_id]
smorest_api.register_blueprint(blp)
sc = smorest_app.test_client()
assert sc.get("/books/?q=dun&page_size=10").json[0]["title"] == "Dune"
assert json.loads(sc.get("/books/").headers["X-Pagination"])["total"] == 1
res = sc.post("/books/", json={"title": "Emma", "author": "Jane Austen", "isbn": "9780000000002"})
assert res.status_code == 201 and res.headers["Location"] == "/books/2"
assert sc.post("/books/", json={"title": "x", "foo": 1}).json["errors"]["json"]["foo"] == ["Unknown field."]
assert sc.get("/books/9").json == {"code": 404, "message": "Book not found", "status": "Not Found"}
assert sc.patch("/books/1", json={"year": 1966}).json["year"] == 1966
Source: flask-smorest: Quickstart
Points that older material gets wrong or that surprise people:
-
@blp.route()must be the outermost decorator, aboveargumentsandresponse; put it below and the route registers the undecorated function. -
Since flask-smorest 0.25 the first positional argument of
@blp.response()is the status code. Passing the schema first (@blp.response(BookSchema)) is the pre-0.25 signature and fails. -
The validated arguments are passed to the view as extra positional arguments, in the order of the decorators, before the URL variables.
-
Validation errors are raised by the
argumentsdecorator and formatted as a422with anerrorsmember keyed by location (json,query,headers). Raisingmarshmallow.ValidationErroryourself inside the view is not translated and gives a500; useflask_smorest.abort(422, ...)or validate through the decorator. -
Query-string schemas usually want
unknown = EXCLUDE; request bodies keep the defaultRAISE. -
The error shape of flask-smorest (
code,status,message,errors) is its own; to answer with Problem Details, override its error handler.
APIFlask
APIFlask is a thin layer over Flask that keeps ordinary function routes and adds the decorators app.input(),
app.output() and app.doc(). It documents itself with OpenAPI 3.1 by default and validates with marshmallow or, when
the schema is a BaseModel, with Pydantic.
import apiflask
from apiflask import fields as af_fields
from apiflask import validators as af_validators
apiflask_app = apiflask.APIFlask(__name__, title="Bookshelf API", version="1.0")
af_shelf = {1: {"id": 1, "title": "Dune", "year": 1965}}
class AfBookIn(apiflask.Schema):
title = af_fields.String(required=True, validate=af_validators.Length(1, 200))
year = af_fields.Integer(validate=af_validators.Range(min=1450))
class AfBookOut(apiflask.Schema):
id = af_fields.Integer()
title = af_fields.String()
year = af_fields.Integer()
@apiflask_app.get("/books/<int:book_id>")
@apiflask_app.output(AfBookOut)
def af_get(book_id):
if book_id not in af_shelf:
apiflask.abort(404, "Book not found")
return af_shelf[book_id]
@apiflask_app.post("/books")
@apiflask_app.input(AfBookIn) # the validated body arrives as json_data
@apiflask_app.output(AfBookOut, status_code=201)
@apiflask_app.doc(summary="Create a book")
def af_create(json_data):
book = {"id": max(af_shelf) + 1, **json_data}
af_shelf[book["id"]] = book
return book
ac = apiflask_app.test_client()
assert ac.post("/books", json={"title": "Emma"}).status_code == 201
assert ac.post("/books", json={"title": ""}).status_code == 422
assert ac.get("/books/5").json["message"] == "Book not found"
assert ac.get("/openapi.json").json["openapi"] == "3.1.0"
Source: APIFlask: Basic Usage
APIFlask’s error responses are also its own (message, detail); the error_processor hook lets you reshape them.
Because output serializes without validating, return values that do not match the schema are not rejected.
Flask-RESTX and Flask-RESTful
Flask-RESTX organizes an API into Namespace objects of Resource classes and describes payloads with its own fields
models; it generates a Swagger 2.0 document, not OpenAPI 3. Flask-RESTful, the project it descends from, is the library
the older tutorials use: Api, Resource and a reqparse.RequestParser for input. Its documentation says the request
parser is slated for removal in favour of marshmallow. Both are shown only so that you recognise the code.
from flask_restx import Api as RestxApi, Resource, fields as rx_fields
restx_app = Flask(__name__)
restx_api = RestxApi(restx_app, version="1.0", title="Bookshelf", doc=False)
books_ns = restx_api.namespace("books")
rx_book = restx_api.model("Book", {"id": rx_fields.Integer(readonly=True),
"title": rx_fields.String(required=True)})
rx_shelf = {1: {"id": 1, "title": "Dune"}}
@books_ns.route("/<int:book_id>")
class RxBook(Resource):
@books_ns.marshal_with(rx_book)
def get(self, book_id):
if book_id not in rx_shelf:
books_ns.abort(404, "Book not found")
return rx_shelf[book_id]
@books_ns.route("/")
class RxBooks(Resource):
@books_ns.expect(rx_book, validate=True)
@books_ns.marshal_with(rx_book, code=201)
def post(self):
book = {**restx_api.payload, "id": max(rx_shelf) + 1}
rx_shelf[book["id"]] = book
return book, 201
rc = restx_app.test_client()
assert rc.get("/books/1").json == {"id": 1, "title": "Dune"}
assert rc.post("/books/", json={}).status_code == 400 # RESTX validation answers 400
assert rc.post("/books/", json={"title": "Emma"}).status_code == 201
Source: Flask-RESTX: Quickstart
from flask_restful import Api as RestfulApi, Resource as RestfulResource, reqparse
class LegacySearch(RestfulResource):
def get(self):
parser = reqparse.RequestParser()
parser.add_argument("q", type=str, location="args") # without location it also reads the JSON body
return {"q": parser.parse_args()["q"]}
legacy_app = Flask(__name__)
RestfulApi(legacy_app).add_resource(LegacySearch, "/search")
assert legacy_app.test_client().get("/search?q=dune").json == {"q": "dune"}
Source: Flask-RESTful: Request Parsing
Two traps in old Flask-RESTful code: a parser argument without location="args" looks into the JSON body first, which
with current Flask answers 415 Unsupported Media Type for a plain GET without a JSON content type; and resources that
return json.dumps(...) strings are encoded twice and sent with the wrong content type, because the extension
serializes whatever the method returns. Return dictionaries.
flask-openapi3
flask-openapi3 reads the annotations of the view function: a Pydantic model for the path, one for the query string, one for the body. The OpenAPI document is generated from those models.
from flask_openapi3 import Info, OpenAPI
class BookPath(BaseModel):
book_id: int
class BookBody(BaseModel):
title: str = Field(min_length=1, max_length=200)
year: int | None = Field(None, ge=1450)
class OpenapiBookOut(BaseModel):
id: int
title: str
oa_app = OpenAPI(__name__, info=Info(title="Bookshelf API", version="1.0"))
oa_shelf = {1: {"id": 1, "title": "Dune"}}
@oa_app.get("/books/<int:book_id>", responses={200: OpenapiBookOut})
def oa_get(path: BookPath):
return oa_shelf[path.book_id]
@oa_app.post("/books", responses={201: OpenapiBookOut})
def oa_create(body: BookBody):
return {"id": 2, "title": body.title}, 201
oc = oa_app.test_client()
assert oc.get("/books/1").json == {"id": 1, "title": "Dune"}
assert oc.post("/books", json={"title": ""}).status_code == 422
assert oc.post("/books", json={"title": "Emma"}).status_code == 201
Source: flask-openapi3 documentation
Choosing
Start with plain Flask, MethodView and the validation library you already know when the API has a handful of
endpoints. Choose flask-smorest when you want marshmallow schemas, class-based views and mature OpenAPI generation,
APIFlask when you want function routes and the option of Pydantic, and flask-openapi3 when you want FastAPI-style
annotations on Flask. Treat Flask-RESTX as a maintenance-mode choice and Flask-RESTful as legacy. If the main reason for
choosing Flask is nothing but the API itself, compare it with FastAPI, which builds validation, documentation and
dependency injection into the framework (FastAPI: Introduction & Architecture).
Pitfalls and Security
-
Mass assignment. Never pass the whole request body to a model constructor (
Book(**request.json)). A schema with an allow-list of fields,dump_onlyfor identifiers and flags such asis_admin, andRAISEfor unknown fields closes that hole. -
Authentication and authorization on every endpoint. A missing check is the most common API vulnerability (OWASP calls it broken object level authorization): after loading a book by identifier, check that the caller may see it. Credentials belong in headers, not in URLs; see JWT & API Authentication and the authorization page of this section.
-
Limit sizes. Set
MAX_CONTENT_LENGTHso oversized bodies are answered with413, capper_page, limit nesting and list lengths in schemas, and add rate limiting (429withRetry-After) at the proxy or with Flask-Limiter. -
Do not leak. Error handlers must not return tracebacks, SQL, file paths or the submitted input; return a generic
500Problem Details body with an identifier that you can find in the logs. Run with debug mode off. -
Safe methods stay safe. A
GETthat changes state is triggered by link prefetchers, crawlers and CSRF. Cookie-based sessions combined with a JSON API need CSRF protection (see the security page); token-based APIs sent in theAuthorizationheader do not. -
Retries.
POSTis not idempotent, so a client that retries after a timeout can create two books. Provide an idempotency key (anIdempotency-Keyrequest header, currently an IETF draft rather than an RFC) or a natural unique constraint such as the ISBN, and answer duplicates with409. -
CORS applies as soon as a browser on another origin calls the API; see Single-Page Apps & CORS and the CORS page.
-
Content negotiation. Check the
Content-Typeof request bodies (Flask’sget_json()already answers415) and declare the response type.jsonifysetsapplication/json; the Problem Details responses setapplication/problem+json.
Recent Changes and Where Older Material Is Outdated
| Older material says | Current state |
|---|---|
Flask-RESTful with |
|
A resource method returns |
The result is encoded twice or sent as |
marshmallow |
|
|
The first positional argument has been the status code since flask-smorest 0.25, and |
A |
Only errors raised by the |
|
Removed in marshmallow 4; use |
The served OpenAPI document is changed by monkeypatching |
A workaround; flask-smorest and APIFlask provide supported ways to serve or customise the document (OpenAPI & API Documentation). |
OpenAPI 3.0.3 is the target version, and RFC 7231 defines HTTP semantics. |
OpenAPI 3.1 is current and flask-smorest, APIFlask and flask-openapi3 can emit it; HTTP semantics are in RFC 9110. |
Error bodies are free-form ( |
RFC 9457 Problem Details ( |
|
Use |
|
Since Flask 2.1 it raises |
A note on the Flask 3.2 changelog: it does not change anything used on this page, apart from the already announced switch
of flask.redirect() to status 303, which matters for the redirect-after-POST pattern of HTML forms and not for JSON
APIs.
Summary
-
Model resources as plural nouns with a collection and an item endpoint; let the method carry the verb and the status code carry the outcome:
201plusLocationon creation,204on deletion,404for missing items,409for conflicts,422for invalid bodies. -
PUTreplaces and is idempotent;PATCHmerges and needs a documented body format;ETagwithIf-Matchprevents lost updates. -
Return every error as RFC 9457 Problem Details through error handlers for
ApiErrorandHTTPException. -
Always filter, sort and page collections, validate each query parameter, and allow-list sort fields; use keyset pagination for large, changing data.
-
Validate with marshmallow or Pydantic, reject unknown fields in request bodies, and remember that output decorators serialize but do not validate.
-
Version by major number in the URL or the media type, add
Deprecation,SunsetandLinkheaders when retiring a version. -
Pick flask-smorest, APIFlask or flask-openapi3 for new documented APIs; treat Flask-RESTX as maintenance mode and Flask-RESTful as legacy.