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 flask.session for API clients; credentials travel in a header on each request (JWT & API Authentication).

Cacheable

Responses say whether and how long they may be reused.

Cache-Control, ETag, Last-Modified, make_conditional(); see Responses & JSON.

Uniform interface

Resources identified by URLs, manipulated through representations, with standard methods and self-describing messages.

@app.route, MethodView, jsonify, the Content-Type header, url_for().

Layered system

Proxies, gateways and caches may sit between client and server without the client knowing.

WSGI servers behind reverse proxies; ProxyFix (Deployment pages).

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 POST); the body says what to do. JSON-RPC and SOAP live here.

1

Resources

Many URLs, one per resource, but still mostly POST.

2

HTTP verbs

Methods carry the semantics (GET reads, PUT replaces, DELETE removes) and status codes carry the outcome.

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 (createBook(...)) over one endpoint

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 curl

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/loans creates 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 fetch and requests follow redirects but may turn a POST into a GET on 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

GET /books

List (filtered, sorted, paged) books

yes

yes

200

POST /books

Create a book; the server chooses the identifier

no

no

201 with Location

GET /books/42

Read one book

yes

yes

200 (or 304 for a conditional request)

PUT /books/42

Replace the whole book (or create it at a client-chosen URL)

no

yes

200 (or 204); 201 if it created the resource

PATCH /books/42

Change some fields of the book

no

not guaranteed

200

DELETE /books/42

Remove the book

no

yes

204 (or 200 with a body)

HEAD, OPTIONS

Headers only; allowed methods (Flask adds both automatically)

yes

yes

200

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

200

OK

Successful GET, PUT or PATCH with a body.

201

Created

Successful POST; the Location header holds the URL of the new resource.

204

No Content

Successful DELETE, or a PUT that returns nothing. A 204 response must not have a body.

304

Not Modified

A conditional GET whose ETag still matches.

400

Bad Request

The request cannot be parsed: malformed JSON, a query parameter that is not a number.

401, 403

Unauthorized, Forbidden

No or invalid credentials, or valid credentials without permission (see the authentication and authorization pages).

404

Not Found

The resource does not exist (or the caller must not know that it exists).

405

Method Not Allowed

The URL exists but not for this method; Flask sends an Allow header.

409

Conflict

The request conflicts with the current state, for example a duplicate ISBN.

412

Precondition Failed

An If-Match header no longer matches the stored version.

415

Unsupported Media Type

The body is not application/json; request.get_json() raises it for you.

422

Unprocessable Content

The JSON is well formed but violates the rules: a missing field, a value out of range.

429

Too Many Requests

Rate limit exceeded; add Retry-After.

500, 503

Server errors

A bug (500) or a dependency that is down (503). Never leak internals.

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 to order_by is 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)

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

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.

flowchart LR C["client"] --> R["routing and MethodView dispatch"] R --> P["get_json: parse body"] P --> V["validate and deserialize"] V --> S["service and repository"] S --> Z["serialize with jsonify"] Z --> O["status code, Location, ETag, Link"] O --> C P -. "400 or 415" .-> E["Problem Details response"] V -. "422" .-> E S -. "404 or 409 or 412" .-> E E --> C
Figure 1. A request through parsing, validation, the service and serialization

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}

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

The decisions in that code are worth spelling out.

  • POST returns 201 and a Location header 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 rewrite Location to an absolute URL, and RFC 9110 allows a relative reference. Use url_for(..., _external=True) if a client demands an absolute one.

  • PUT replaces, PATCH merges. A PUT body must be a complete representation, so the required fields are required again and anything omitted is gone; it is idempotent. A PATCH body lists only the fields to change. The format used here is JSON Merge Patch (RFC 7396, media type application/merge-patch+json): the body is a partial object and a null value 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, because PATCH has no default body format. Flask’s request.get_json() accepts any application/*+json media type, so the merge-patch type needs no special handling.

  • DELETE returns 204 with an empty body. Returning 202 Accepted with a body, as some tutorials do, promises asynchronous processing that did not happen; use 200 with a body only if the client needs the deleted representation.

  • 409 for state conflicts and 404 for missing items are raised as ApiError, never returned as 200 with {"error": ...}.

  • ETag and If-Match implement optimistic concurrency: the client reads a book, sends the ETag back in If-Match with its update, and gets 412 if someone else changed the book in between; without that, two editors silently overwrite each other (the "lost update"). make_conditional() turns a matching If-None-Match into a 304 with no body.

  • Unknown fields are rejected (422), so a typo such as titel is 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"

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=True, load_default=, dump_default=

Required input and defaults for missing input or output. The older missing= and default= arguments were removed in marshmallow 4 and raise TypeError.

dump_only=True, load_only=True

Server-generated fields (id, timestamps) are dump_only; secrets such as a password are load_only and never serialized.

validate=

validate.Length, Range, OneOf, Regexp, Email, or any callable that raises ValidationError.

@validates("field"), @validates_schema

Field-level and cross-field validation.

fields.Nested, many=True

Nested objects and lists (BookSchema(many=True) serializes a list).

data_key=

The JSON name differs from the attribute name (isbn13 in JSON, isbn in Python).

partial=True

Skip required checks for PATCH bodies.

Meta.unknown or unknown=

What to do with fields the schema does not declare: RAISE, EXCLUDE or INCLUDE (below).

fields.Int(strict=True)

Refuse "1965" for an integer; the default lax mode coerces strings.

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

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

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

/api/v1/books

The most common: visible, easy to route, cache and test with curl; the version is part of the resource identity.

Header (media type)

Accept: application/vnd.bookshelf.v2+json

Keeps URLs stable and follows HTTP content negotiation; harder to try in a browser and to document.

Query parameter

/api/books?version=2

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; Blueprint and MethodView based; built-in pagination and ETag helpers.

APIFlask 3.1.2

marshmallow or Pydantic (schema adapters)

OpenAPI 3.1 by default, built-in docs UIs

2026-09-12

A Flask subclass (APIFlask) with app.input, app.output, app.doc decorators; plain function routes.

Flask-RESTX 1.3.2

Its own fields models and JSON Schema validation (expect(..., validate=True)); marshal_with

Swagger 2.0 only

2025-09-23

Community continuation of Flask-RESTPlus; Api, Namespace, Resource. Releases are infrequent; fine for existing code, weigh it for new projects.

flask-openapi3 4.3.2

Pydantic v2 models from type annotations

OpenAPI 3.1 with Swagger, ReDoc, RapiDoc and others

2026-05-09

OpenAPI and APIBlueprint classes; path, query, header and body models are typed function parameters, similar to FastAPI.

Flask-RESTful 0.3.10

reqparse (slated for removal) and marshal_with

none

2023-05-21

Api, Resource. Barely maintained; you will meet it in existing code and old books.

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

Points that older material gets wrong or that surprise people:

  • @blp.route() must be the outermost decorator, above arguments and response; 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 arguments decorator and formatted as a 422 with an errors member keyed by location (json, query, headers). Raising marshmallow.ValidationError yourself inside the view is not translated and gives a 500; use flask_smorest.abort(422, ...) or validate through the decorator.

  • Query-string schemas usually want unknown = EXCLUDE; request bodies keep the default RAISE.

  • 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"

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
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"}

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

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_only for identifiers and flags such as is_admin, and RAISE for 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_LENGTH so oversized bodies are answered with 413, cap per_page, limit nesting and list lengths in schemas, and add rate limiting (429 with Retry-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 500 Problem Details body with an identifier that you can find in the logs. Run with debug mode off.

  • Safe methods stay safe. A GET that 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 the Authorization header do not.

  • Retries. POST is not idempotent, so a client that retries after a timeout can create two books. Provide an idempotency key (an Idempotency-Key request header, currently an IETF draft rather than an RFC) or a natural unique constraint such as the ISBN, and answer duplicates with 409.

  • 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-Type of request bodies (Flask’s get_json() already answers 415) and declare the response type. jsonify sets application/json; the Problem Details responses set application/problem+json.

Recent Changes and Where Older Material Is Outdated

Older material says Current state

Flask-RESTful with reqparse is the way to build a Flask API.

reqparse is slated for removal and Flask-RESTful’s last release is from May 2023. Use plain MethodView, flask-smorest, APIFlask, flask-openapi3 or Flask-RESTX.

A resource method returns json.dumps(...).

The result is encoded twice or sent as text/html. Return a dictionary or use jsonify.

marshmallow Meta.unknown = EXCLUDE rejects unknown fields.

RAISE (the default) rejects them; EXCLUDE silently drops them; INCLUDE keeps them.

@blp.response(Schema) takes the schema first, and @blp.response may sit above @blp.route.

The first positional argument has been the status code since flask-smorest 0.25, and route is the outermost decorator.

A marshmallow.ValidationError raised in the view becomes a formatted validation response.

Only errors raised by the arguments decorator are formatted; an error raised in your own code is a 500.

missing= and default= field arguments.

Removed in marshmallow 4; use load_default= and dump_default=.

The served OpenAPI document is changed by monkeypatching APISpec.to_dict.

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 ({"error": "..."}).

RFC 9457 Problem Details (application/problem+json) is the standard shape; neither Flask nor the books above provide it for you.

DELETE returns 202 Accepted with a body.

Use 204 with no body, or 200 with a body; 202 means the work was queued, not done.

request.get_json() returns None for a wrong content type.

Since Flask 2.1 it raises 415 Unsupported Media Type (and 400 Bad Request for malformed JSON); silent=True or force=True restore the lenient behaviour.

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: 201 plus Location on creation, 204 on deletion, 404 for missing items, 409 for conflicts, 422 for invalid bodies.

  • PUT replaces and is idempotent; PATCH merges and needs a documented body format; ETag with If-Match prevents lost updates.

  • Return every error as RFC 9457 Problem Details through error handlers for ApiError and HTTPException.

  • 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, Sunset and Link headers 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.