Introduction: A REST API's Quality Isn't Decided by "CRUD Working"
Writing a REST API in Flask is easy. Line up @app.route, take request.get_json(), and return with jsonify — that alone gives you a "working API." But a working API and an API that withstands years of extension are different things. What separates the latter is neither the framework choice nor the ORM's speed, but how you model resources, how correctly you observe HTTP semantics, and how you design versions and structure.
I designed and built the backend of an economic-ministry-award-winning B2B SaaS in Python / Flask / SQLAlchemy / PostgreSQL, and ran a 221-endpoint REST API in production on API Gateway → ALB → ECS(Fargate). As the endpoints grew past 10, past 100, past 200, one thing was driven home: an API lives or dies not by "the implementation of individual endpoints" but by "the conventions of the whole." If the way URLs are formed, the way status codes are chosen, the shape of paging, the error envelope, and the way versions are cut are all over the place across 200 spots, it's already unmaintainable.
This article is a spoke for designing those conventions at production quality, faithful to the official docs of the Flask 3.1 line (the current stable). The scope is narrowed to the API's "structure and conventions" — resource design with MethodView, versioning with Blueprint, HTTP semantics, pagination, and the error envelope. It does not cover input validation and response shaping (serialization) themselves — those belong to the boundary, split into the paired Designing a Production REST API with marshmallow × Flask × SQLAlchemy. This article handles "the API's skeleton"; that article handles "the boundary's gatekeeper."
💡 Versions covered in this article: the Flask 3.1 line (the latest stable at the time of writing is 3.1.3). Flask 3.1 requires Python 3.9 or higher. The code is based on the official docs' patterns. For the prerequisite knowledge of app structure (
create_app/ avoiding circular imports inBlueprint), see the Large-Scale App Structure Guide; for the overall map, see the Flask Production Operations Guide.
1. What Is a Good REST API: The 4 Design Principles Beyond CRUD
Before getting into MethodView, let's first fix the yardstick of "what counts as good." A good REST API satisfies the following four as structure.
1.1 Resource Modeling: A URL Is a "Noun," Not a "Verb"
REST's central concept is the resource. A URL is a noun pointing at a resource, and operations on it are expressed by HTTP verbs. A design that "embeds a verb in the URL," like /getUsers or /createUser, is RPC, not REST.
| ❌ Anti-pattern (RPC-like) | ✅ RESTful |
|---|---|
GET /getUserList | GET /users |
POST /createUser | POST /users |
POST /deleteUser?id=5 | DELETE /users/5 |
POST /updateUserName | PATCH /users/5 |
Think of resources in two tiers: the collection (/users) and the individual item (/users/5). This two-tier structure maps directly onto the two-class design of MethodView discussed later (§2).
1.2 Correct HTTP Semantics: Verbs and Status Codes
Each HTTP verb has a meaning (semantics), and by following it the client can predict behavior.
| Verb | Meaning | Idempotency | Main statuses |
|---|---|---|---|
GET | Retrieve (no side effects, safe) | Idempotent | 200 / 404 |
POST | Create new in a collection | Non-idempotent | 201 (with Location) / 400 / 422 / 409 |
PUT | Full replacement (or idempotent creation) | Idempotent | 200 / 204 / 404 |
PATCH | Partial update | (implementation-dependent) | 200 / 404 / 422 |
DELETE | Delete | Idempotent | 204 / 404 |
Status codes shouldn't be settled with "200 or 500, more or less" — using them per their meaning is the core of the convention.
| Code | Name | When to return it |
|---|---|---|
200 OK | Success | Success of GET / PATCH / PUT (with body) |
201 Created | Created | Creating a new resource with POST (new URL in the Location header) |
204 No Content | Success, no body | DELETE success, an update that returns no body |
400 Bad Request | Bad request | "Syntax" problems like JSON syntax errors or missing required parameters |
404 Not Found | Absent | The resource with the given ID does not exist |
409 Conflict | Conflict | Unique-constraint violation, optimistic-lock collision, duplicate creation |
422 Unprocessable Entity | Validation failed | Syntactically correct but semantically unprocessable (validation failure) |
💡 400 vs. 422: "the JSON is broken / a required key is missing" is 400 (syntax level), "the JSON is correct but the email format is invalid / out of range" is 422 (semantic level) — that's the practical consensus split. The design that consolidates marshmallow's
ValidationErrorinto 422 is detailed in §6 of the marshmallow × Flask article. This article handles up to the convention of how to choose status codes, and leaves the validation logic itself to that one.
1.3 Idempotency: PUT / DELETE Are "The Same No Matter How Many Times You Call"
Idempotent is the property that "sending the same request once or N times leaves the server in the same state."
- Send
DELETE /users/5twice and the result is the same "user 5 doesn't exist" state → idempotent. - Send
PUT /users/5(full replacement) twice and the final state is the same → idempotent. - Send
POST /users(create new) twice and you get two users → non-idempotent.
This property is decisive when the network is unreliable. When the client retries on timeout, an idempotent operation can be safely re-sent. That's precisely why "making POST idempotent" (preventing duplicate creation) matters in situations like payments (the idempotency key covered in §7.4).
1.4 Stateless: The Server Holds No State Between Requests
REST is stateless. Each request contains, by itself, all the information needed to process it. The server holds no "continuation of the previous request" — this is the premise of horizontal scaling (handle it just by increasing the task count on ECS). For an API, the standard is to carry the credential per request as a token in the Authorization header, not in a session cookie.
⚠️ Anti-pattern: holding state in a server-side session (
session["user_id"] = ...) in an API. This breaks statelessness and creates the need to share a session store across workers. Make API auth token-based (Bearer / API key) and keep each request self-contained.
2. MethodView: Designing Resources with Classes
With @app.route's function-based style, loading multiple verbs onto one URL swells the if request.method == ... branching. Flask provides the class-based view MethodView, which maps HTTP methods to same-named methods of the class. In the official words, "each HTTP method corresponds to a method of the class with the same (lowercase) name." This meshes with resource-oriented design.
2.1 The Right Answer in the Current Official Version: The item / collection "Two-Class Structure"
This is the most important point, and where it diverges from common samples. Flask's current official docs show a structure that splits into two classes — "for individual items" and "for collections" — not a design that stuffs all verbs into one class. The reason is the resource structure of §1.1 itself — /users/<id> (item) and /users/ (collection) deal with different targets.
from flask.views import MethodView
class ItemAPI(MethodView):
init_every_request = False
def __init__(self, model):
self.model = model
self.validator = generate_validator(model)
def _get_item(self, id):
return self.model.query.get_or_404(id)
def get(self, id):
item = self._get_item(id)
return jsonify(item.to_json())
def patch(self, id):
item = self._get_item(id)
errors = self.validator.validate(item, request.json)
if errors:
return jsonify(errors), 400
item.update_from_json(request.json)
db.session.commit()
return jsonify(item.to_json())
def delete(self, id):
item = self._get_item(id)
db.session.delete(item)
db.session.commit()
return "", 204
class GroupAPI(MethodView):
init_every_request = False
def __init__(self, model):
self.model = model
self.validator = generate_validator(model, create=True)
def get(self):
items = self.model.query.all()
return jsonify([item.to_json() for item in items])
def post(self):
errors = self.validator.validate(request.json)
if errors:
return jsonify(errors), 400
db.session.add(self.model.from_json(request.json))
db.session.commit()
return jsonify(item.to_json())
ItemAPIhasget/patch/delete, all takingidas an argument (operations on an individual item).GroupAPIhasget(list) andpost(create new), taking noid(operations on the collection).
The methods class attribute is auto-set from the methods you define. Define get and GET (and HEAD) become enabled; define post and POST becomes enabled — without explicit declaration.
💡 Why two classes: "POST to a collection (create)" and "PATCH on an item (update)" have different validation rules. The fact that, in the code above,
GroupAPIusesgenerate_validator(model, create=True)whileItemAPIusesgenerate_validator(model)is the expression of that. Creation needs all required fields, but updates allow partial updates — try to express this difference with "the same validator on the same class" and it fills with branches. Two classes cleanly separate the resource structure (collection vs. item) and the validation difference at the class boundary.
2.2 as_view and add_url_rule: Binding a Class to a URL
A MethodView subclass is not, by itself, tied to a URL. Convert it to a view function with as_view(name, *ctor_args) and register it to a URL with add_url_rule. as_view's first argument is the endpoint name (used with url_for); the arguments after it are the constructor arguments passed to __init__.
# item は /users/<int:id> に、group は /users/ に束ねる
item = ItemAPI.as_view("user-item", User) # User が __init__(self, model) に渡る
group = GroupAPI.as_view("user-group", User)
app.add_url_rule("/users/<int:id>", view_func=item)
app.add_url_rule("/users/", view_func=group)
As a result, the routes are dispatched like this.
| Method + URL | Corresponding class & method |
|---|---|
GET /users/ | GroupAPI.get (list) |
POST /users/ | GroupAPI.post (create → 201) |
GET /users/<id> | ItemAPI.get (retrieve) |
PATCH /users/<id> | ItemAPI.patch (partial update) |
DELETE /users/<id> | ItemAPI.delete (delete → 204) |
Having passed User to as_view, you can reuse the same ItemAPI class for Post or Order too — just swap the model via the constructor argument. This leads into the next register_api factory.
2.3 The register_api Factory: Registering Many Resources DRY-ly
An API with 221 endpoints means there are dozens of resources. Hand-writing as_view twice and add_url_rule twice per resource is a repetition of the same knowledge (the resource-registration procedure) — a textbook DRY violation. The official docs show a form that consolidates this into a registration factory function.
def register_api(app, model, name):
item = ItemAPI.as_view(f"{name}-item", model)
group = GroupAPI.as_view(f"{name}-group", model)
app.add_url_rule(f"/{name}/<int:id>", view_func=item)
app.add_url_rule(f"/{name}/", view_func=group)
register_api(app, User, "users")
register_api(app, Order, "orders")
register_api(app, Product, "products")
The single line register_api(app, User, "users") generates the 5 routes of /users/ and /users/<id> all at once. Adding a new resource is one line. This is the registration pattern that doesn't break down even when endpoints reach the dozens or hundreds. If resource-specific behavior is needed, subclass ItemAPI / GroupAPI and swap them out along with the model passed to register_api.
💡 This "pattern" is what supported 221 endpoints: in my B2B SaaS, consolidating the resource-registration procedure into one place (the factory) was the biggest reason it withstood the scaling of endpoint count. When "how to grow a new resource" is fixed at one line, what you need to look at in review is only "does it follow the URL convention," and the need to review registration boilerplate disappears. When a convention becomes a pattern, cognitive load stops being proportional to the count.
2.4 init_every_request = False: Instance Reuse and the Iron Rule of "Don't Write to self"
init_every_request controls whether the view instance is recreated per request.
- Default (
True): create a new instance and call__init__on every request. False: create one instance and reuse it across all requests.__init__runs only once, at registration.
init_every_request = False is efficient for uses like the example above, "binding the model and validator once in the constructor." But there is a decisively important constraint.
⚠️ Writing to
selfis not safe: when you reuse the instance withinit_every_request = False, that instance is shared across multiple requests (multiple threads). The official docs state it plainly — "writing toselfis not safe. If you need to store data during a request, useginstead ofself." Holding a per-request value (the current user, a request ID, etc.) likeself.current_user = ...causes a race where it's overwritten by another request's value. Read-only configuration (model, validator) →self; per-request mutable state →g— observe this boundary strictly.
from flask import g
class ItemAPI(MethodView):
init_every_request = False
def __init__(self, model):
self.model = model # ✅ 読み取り専用。全リクエストで共有してよい
def get(self, id):
g.request_started = time.time() # ✅ リクエスト固有の状態は g に置く
# self.request_started = ... は ❌(共有インスタンスへの書き込みは競合)
item = self.model.query.get_or_404(id)
return jsonify(item.to_json())
Why g is independent per request (the context-local mechanism) is touched on in §5 of the Flask Production Operations Guide.
2.5 decorators: Applying Auth to the Whole Class
Most API resources need authentication. When you apply a decorator like login_required to a MethodView, writing the decorator on the class itself doesn't take effect. The decorator must be applied to the view function that as_view returns. Flask resolves this declaratively with the decorators class attribute.
from myapp.auth import login_required, require_scope
class OrderItemAPI(MethodView):
init_every_request = False
# as_view が返すビュー関数に、リスト順に適用される
decorators = [login_required, require_scope("orders:read")]
def __init__(self, model):
self.model = model
def get(self, id):
...
💡
decoratorsis applied "bottom-up": withdecorators = [login_required, require_scope(...)],require_scopeis applied first (inner) andlogin_requiredlater (outer). Since a request passes from the outside in,login_requiredruns first, thenrequire_scope— i.e., the order is "authentication → authorization." This order is meaningful for security: it rejects unauthenticated requests before the scope check. Reverse the list order and an unauthenticated request might reach the scope check. For the implementation of the authentication/authorization decorators themselves, see the security section of the Flask Production Operations Guide.
3. Bundle Resources with Blueprint and Version the API
If MethodView is "the design unit of one resource," then Blueprint is "the design unit of resource groups and versions." Here we generalize calling register_api against a Blueprint rather than app.
3.1 Blueprints by Resource / Domain Unit
In an API, the practical move is to cut Blueprints by "domain (concern)" or "version" unit. Just change register_api's first argument from app to a Blueprint and you close resource registration into the Blueprint.
from flask import Blueprint
api_v1 = Blueprint("api_v1", __name__)
def register_api(bp, view_item, view_group, model, name):
item = view_item.as_view(f"{name}-item", model)
group = view_group.as_view(f"{name}-group", model)
bp.add_url_rule(f"/{name}/<int:id>", view_func=item)
bp.add_url_rule(f"/{name}/", view_func=group)
register_api(api_v1, ItemAPI, GroupAPI, Order, "orders")
register_api(api_v1, ItemAPI, GroupAPI, Product, "products")
The important Flask spec that a Blueprint's name (api_v1) is the prefix of the endpoint, not the URL, is detailed in §4 of the Large-Scale App Structure Guide. The URL is decided by the next url_prefix.
3.2 Versioning by URL Path: url_prefix="/api/v1"
There are several styles of API versioning (URL path, header, media type), but the URL-path style (/api/v1/...) is the most explicit and the easiest to handle for debugging, caching, and documentation all at once — that's the mainstream consensus in practice. In Flask, this is realized just by giving the Blueprint a url_prefix at registration.
# create_app 内(アプリケーションファクトリ)
def create_app(config=None):
app = Flask(__name__)
# ...設定・拡張の init_app...
from .api.v1 import api_v1
from .api.v2 import api_v2
app.register_blueprint(api_v1, url_prefix="/api/v1")
app.register_blueprint(api_v2, url_prefix="/api/v2")
return app
| Blueprint | url_prefix | Example generated URLs |
|---|---|---|
api_v1 | /api/v1 | GET /api/v1/orders/, GET /api/v1/orders/5 |
api_v2 | /api/v2 | GET /api/v2/orders/, GET /api/v2/orders/5 |
How to assemble the factory and extensions in create_app (the bare extensions in extensions.py → init_app, avoiding circular imports) presupposes the Large-Scale App Structure Guide. This article layers "the API's version tier" on top of that.
3.3 The Staged Migration Strategy of Deprecation
The essence of versioning isn't "creating v2" but "folding up v1 safely." Since clients can't migrate all at once, you deprecate v1 while running both versions in parallel. The procedure I took in production is this.
| Phase | State | Signal to clients |
|---|---|---|
| 1. Publish v2 | v1 and v2 run in parallel. v1 keeps working | (nothing yet) |
| 2. Deprecate v1 | Add Deprecation / Sunset headers to v1 | Notify the planned retirement date via response headers |
| 3. Migration period | Identify v1-using clients from access logs and contact them individually | Migration guide in the docs |
| 4. Retire v1 | Unregister the v1 Blueprint (or return 410 Gone) | Stop on the pre-announced date |
The deprecation headers are DRY when added in bulk in the Blueprint's after_request.
@api_v1.after_request
def add_deprecation_headers(response):
# RFC 準拠の Deprecation / Sunset ヘッダで廃止予定をクライアントに伝える
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Wed, 31 Dec 2026 23:59:59 GMT"
response.headers["Link"] = '</api/v2/docs>; rel="successor-version"'
return response
💡 "More than the courage to cut a version, the discipline to fold one up": what fails in API versioning isn't being unable to create v2, but being unable to ever fold up v1 and maintaining both forever. Deciding the folding-up discipline first (deprecation header → migration period → retirement), and prioritizing backward-compatible changes that avoid cutting a version in the first place (adding a field is not breaking), is the real versioning strategy. Cut a new version only when a breaking change (field removal, type change, making something required) is unavoidable.
4. The Request Intake: Route Converters and Query Parameters
Once the resource's shape is decided, next is "how to extract information from the request." This, too, we align as a convention.
4.1 Type URLs with Route Converters
Flask's URL rules can make path variables typed with <converter:name>. By writing /users/<int:id> instead of /users/<id>, id arrives at the view as an int rather than a string, and /users/abc becomes a 404 automatically.
| Converter | What it accepts | Use |
|---|---|---|
string (default) | A string with no slash | Slugs, etc. |
int | An integer | Numeric ID (most frequent) |
float | A floating-point number | Prices, coordinates |
uuid | A UUID string | A design that uses UUID for public IDs |
path | A string including slashes | File-path-like resources |
app.add_url_rule("/orders/<int:id>", view_func=item) # 数値 ID
app.add_url_rule("/orders/<uuid:public_id>", view_func=item) # UUID を公開 ID に
💡 The choice not to use sequential integers for public IDs: a sequential
intID can be guessed — "next to/orders/41is/orders/42" — risking the leak of counts or another company's order numbers (information enumeration). For an externally public API, it's robust to make UUIDs the public ID with theuuidconverter and separate them from the internal sequential primary key. This is a design decision where security (minimal information disclosure) and resource modeling intersect.
Specify methods explicitly with methods=["GET", "POST"], or use the shortcuts @app.get / @app.post. As an official spec, defining GET auto-adds HEAD, and OPTIONS is auto-implemented. This is why you don't have to write CORS preflight (OPTIONS) yourself.
4.2 Query Parameters: Filter, Sort, Paging
Narrowing, sorting, and paging are expressed in query parameters, not the URL path (because they don't change the resource's identity, only its "appearance"). request.args.get handles type conversion and a default value at once with type=.
from flask import request
class OrderGroupAPI(MethodView):
init_every_request = False
def get(self):
# 型付きで取り出し、既定値を与える
status = request.args.get("status", type=str) # ?status=paid
sort = request.args.get("sort", default="-created_at") # ?sort=-created_at
page = request.args.get("page", default=1, type=int)
per_page = request.args.get("per_page", default=20, type=int)
per_page = min(per_page, 100) # 上限で DoS を防ぐ(§5.4)
stmt = build_order_query(status=status, sort=sort)
pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
return jsonify(paginated_envelope(pagination))
The sort convention sort=-created_at (leading - for descending) is widely used. Multiple keys are received comma-separated like sort=-created_at,name. For filters, it's intuitive to use the field name directly as the key, like ?status=paid&min_total=1000.
4.3 Where to Put Validation: This Article's "Seam"
This is the seam between this article and the marshmallow article. After you extract status or per_page in the code above, the responsibility of validating "is that value an allowed value" / "are the type and range correct" lies not in the view but at the boundary (the schema). This article handles up to extracting the query parameters and leaves the part that validates and converts to errors to marshmallow.
# 本記事のスコープ:パラメータを取り出し、リソースを設計する
page = request.args.get("page", default=1, type=int)
# marshmallow 記事のスコープ:取り出した値を検証し、不正なら 422 に変換する
# → load() で QueryArgsSchema を通し、ValidationError を errorhandler が 422 へ
Scattering validation ifs like if not 1 <= page <= 1000: through view functions is technical debt. Separate the responsibilities of extraction (this article) and validation (the marshmallow article) — this layering keeps 200 endpoints maintainable. For how to assemble validation schemas, see the marshmallow × Flask article.
4.4 Content Negotiation: Returning JSON
jsonify(...) returns a Response with Content-Type: application/json. Since Flask 2.2, just returning a dict or list from a view is auto-converted to a JSON response too. When you want fine control over headers or status, use make_response(...).
from flask import jsonify, make_response
# dict / list を返すだけで JSON 化される(201 など明示したいときはタプル)
def get(self, id):
return self.model.query.get_or_404(id).to_json() # → 200 + application/json
# Location ヘッダ付きの 201 を返す
def post(self):
order = create_order(request.json)
resp = make_response(jsonify(order.to_json()), 201)
resp.headers["Location"] = url_for("api_v1.orders-item", id=order.id)
return resp
When returning 201 from POST, the HTTP custom is to put the created resource's URL in the Location header (§1.2). Reverse-resolve the URL from the endpoint with url_for and you avoid hardcoding the path.
5. Conventions for Pagination and Filtering
A list API will inevitably need paging as the count grows. The problem is aligning "which method, in what shape to return" across all endpoints.
5.1 offset/page Method vs. cursor Method
There are roughly two paging methods, with a clear trade-off.
| Method | Mechanism | Pros | Cons |
|---|---|---|---|
| offset / page | ?page=3&per_page=20 (= OFFSET 40) | Simple to implement, jump to any page, can show total count | OFFSET is slow on deep pages, items shift on insertion |
| cursor (keyset) | ?after=<cursor> (= WHERE id > ?) | Fast even on deep pages, no shift on insertion | Can't jump to an arbitrary page, total count hard to show |
The criterion is simple. If you need "total count and page number," like an admin panel, use offset; for infinite scroll or sequential retrieval of large data, use cursor. In my B2B SaaS, the admin panel's lists were offset, the mobile-facing activity feed was cursor — used by purpose.
5.2 Implementing the offset Method: db.paginate
Flask-SQLAlchemy provides offset paging with db.paginate(). You can extract the count and page count from the Pagination object.
def get(self):
page = request.args.get("page", default=1, type=int)
per_page = min(request.args.get("per_page", default=20, type=int), 100)
stmt = db.select(Order).order_by(Order.created_at.desc())
pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
return jsonify({
"data": [o.to_json() for o in pagination.items],
"meta": {
"page": pagination.page,
"per_page": pagination.per_page,
"total": pagination.total,
"pages": pagination.pages,
},
})
error_out=False returns an empty array instead of throwing 404 even for a nonexistent page number (safe even past the end). For the details of db.paginate and N+1-free prefetching (selectinload), see the Flask-SQLAlchemy / Flask-Migrate Guide. The connection of replacing to_json() with marshmallow's dump() corresponds to §9 of the marshmallow × Flask article.
5.3 Response Shape: Envelope or Headers
There are two schools for carrying page info, and the key point of the convention is to unify on one of them.
(A) Envelope method: wrap the body in {"data": [...], "meta": {...}}. Page info rides in the body, so it's easy to handle and visible in the browser devtools. The example above is this.
(B) Header method: return the body as the array itself ([...]) and put page info in the Link / X-Total-Count headers. The method the GitHub API and others take.
def get(self):
pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
resp = make_response(jsonify([o.to_json() for o in pagination.items]))
resp.headers["X-Total-Count"] = str(pagination.total)
# 次/前ページへの URL を RFC 5988 の Link ヘッダで提供
links = []
if pagination.has_next:
links.append(f'<{url_for("api_v1.orders-group", page=page + 1, _external=True)}>; rel="next"')
if pagination.has_prev:
links.append(f'<{url_for("api_v1.orders-group", page=page - 1, _external=True)}>; rel="prev"')
if links:
resp.headers["Link"] = ", ".join(links)
return resp
💡 Why I favor the envelope: I favor the envelope method (A). For three reasons — (1) page info rides in the body, so it reliably reaches even clients that don't read headers, (2) you can later add info beyond
totaltometa(applied filters, server time, etc.), (3) you can unify the "envelope" of single-fetch and list-fetch responses on{"data": ...}, so the client's parse logic is one kind. The header method has the merit of "the body is a pure, beautiful array," but its weakness in practice is the scant room to extend meta info. What matters is consistency over beauty — whichever you choose, make it the same shape across all endpoints.
5.4 Guard Paging with an "Upper Bound"
An API with no upper bound on per_page can be made to exhaust the DB and memory with a single ?per_page=1000000 (DoS). Make an upper bound like per_page = min(requested, 100) a convention across all list endpoints. The principle of validating external input at the boundary (§4.3) applies equally to paging parameters.
6. Error Design: A Consistent JSON Envelope
If the error-response shape is all over the place across 200 spots, the client ends up investigating "which field holds the error reason" per URL. Unify errors into one envelope.
6.1 A Consistent Error Envelope
If the success response is {"data": ...}, aligning errors to {"error": {...}} is symmetric and readable.
{
"error": {
"code": "validation_error",
"message": "リクエストの検証に失敗しました。",
"details": {
"email": ["メールアドレスの形式が正しくありません。"],
"total": ["0 より大きい値である必要があります。"]
}
}
}
code: a machine-readable error kind (the client uses it for branching).message: a human-readable summary.details: per-field details (used in validation errors. For 422, put marshmallow'serr.messageshere).
6.2 Mapping Status Codes to Errors
Dropping §1.2's status-code convention into the error design gives this.
| Situation | Code | Example code |
|---|---|---|
| JSON syntax error, missing required parameter | 400 | bad_request |
| No auth / invalid token | 401 | unauthorized |
| Insufficient permission | 403 | forbidden |
| Resource absent | 404 | not_found |
| Unique-constraint violation, optimistic-lock collision, duplicate creation | 409 | conflict |
| Validation failure (range, format) | 422 | validation_error |
⚠️ Don't swallow a 409 Conflict as a 500: pass a unique-constraint violation (
UniqueViolation) or optimistic-lock collision through withouttry/exceptand the DB exception leaks as a 500. This isn't "a server bug" but "a conflict the client can resolve," so the correct design is to return 409 to convey that a retry is possible. Duplicate creation (signing up with the same email, etc.) is also appropriately 409. Combined with the idempotency key (§7.4), you can also make a retry "return the previous result" instead of a 409.
6.3 Handler Consolidation Goes to a Separate Article
The mechanism that registers this error envelope once for the whole app with @app.errorhandler — bulk handling of HTTPException, the resolution order of Blueprint and app handlers, integration with structured logging — is detailed in the Error Handling, Logging & Observability Guide. This article handles "the shape (convention) of the error envelope"; that article handles "the mechanism of the handler that generates the envelope." The concrete consolidation of marshmallow's ValidationError into 422 is §6 of the marshmallow × Flask article.
7. API Design Convention Table: A Rulebook for Not Getting Lost
Here we condense the judgments so far into a convention table you can reference in review. When designing an API as a team, making this table "the agreed default" makes per-endpoint debate disappear.
| Item | Convention | Reason |
|---|---|---|
| URL nouns | Plural (/orders, /users) | Consistently express collection and item with /orders / /orders/5 |
| Verbs | Don't put verbs in the URL (express with HTTP verbs) | REST, not RPC |
| Nesting depth | Up to 1 level (/orders/5/items). Avoid 2+ levels | Deep nesting makes URLs fragile. Consider /items?order_id=5 |
| Case | URLs kebab-case, JSON keys unified to snake_case or camelCase | Mixing is the biggest maintenance cost |
| Paging | Same method and same envelope across all lists, upper bound on per_page | Client parse stays one kind |
| Errors | Unify on {"error": {code, message, details}} | Mechanize client branching |
| Versions | /api/v{n} URL-path style | Explicit, kind to caching and docs |
| Datetime | ISO 8601, UTC (2026-06-26T09:00:00Z) | Eliminate timezone ambiguity |
7.1 A Realistic Stance on HATEOAS
HATEOAS (embedding links to next operations in the response), REST's theoretical completion, is ideal but overkill for many internal APIs. It reduces the client's effort of assembling URLs, but considering the cost of link generation and the reality that the client ends up hardcoding URLs anyway, the cost-benefit often doesn't add up.
My realistic stance is — the paging next / prev links (the Link header in §5.3) alone are worth including, but full HATEOAS growing _links on every resource is limited to when there's a clear requirement to loosely couple client implementations in a public API. It's a textbook case for applying YAGNI.
7.2 The Idempotency Key: Making POST Safely Retryable
Per §1.3, POST is non-idempotent. For POSTs where "double execution is fatal," like payments or order creation, introduce an Idempotency-Key. The client sends a unique key it generated in a header, and the server "processes a POST with the same key only once, returning the previous result from the second time on."
class OrderGroupAPI(MethodView):
init_every_request = False
def post(self):
key = request.headers.get("Idempotency-Key")
if key:
cached = get_idempotent_result(key) # 既存キーなら前回の結果を返す
if cached is not None:
return jsonify(cached), 200
order = create_order(request.json)
db.session.commit()
body = order.to_json()
if key:
store_idempotent_result(key, body) # キーと結果を紐付けて保存
return jsonify(body), 201
With this, even if the client retries on timeout, the order isn't created twice. On the premise that "the network will surely fail," make a non-idempotent operation idempotent — this is the heart of reliability design (retry safety), and was essential for a B2B SaaS handling payments. Idempotency is a theme running through this whole site.
8. When to Stop Hand-Writing and Move to a Framework
Up to here we've assembled the API by hand with MethodView and Blueprint. But as an API grows, an unavoidable requirement appears — auto-generating OpenAPI (Swagger) documentation.
Once endpoints exceed 200, keeping documentation hand-written and in sync is realistically impossible. Here the judgment splits.
| Approach | What you get | Cost |
|---|---|---|
Plain MethodView + marshmallow | Maximum control, minimum dependencies | Write OpenAPI yourself / generate with a separate tool |
flask-smorest (a MethodView extension) | Auto-generates OpenAPI/Swagger UI from schemas, also provides paging/error conventions | Conform conventions to the framework |
flask-smorest is an extension built on MethodView — it leverages the two-class structure, marshmallow schemas, and Blueprints you assembled in this article almost as-is, and when you declare schemas with the @blp.response(...) / @blp.arguments(...) decorators, it auto-generates the OpenAPI spec and Swagger UI. If the conventions you built in this article (resource design, status codes, paging, error envelope) are in your head, smorest becomes a shortcut to implementing them without boilerplate.
💡 The watershed of judgment: at a scale of a dozen-ish endpoints where a README suffices for docs, the transparency of plain
MethodView(no magic) wins. But once you're at a publicly exposed API, team development, or dozens-plus endpoints, the requirement of auto-syncing docs exceeds the cost of hand-writing. That's the watershed to move to smorest. The specifics of migration (swapping theBlueprint, schema declaration, Swagger UI) are covered in the Flask OpenAPI / Swagger (flask-smorest) Guide. The conventions of this article remain effective as "the design yardstick" even when using smorest.
The upstream technology selection of "hand-assemble in Flask, or choose FastAPI with auto-validation and auto-docs as standard" in the first place is gathered in the Flask vs. FastAPI vs. Django Technology Selection Guide. FastAPI has auto-validation and auto-OpenAPI from Pydantic type hints as standard, and much of the convention you tend by hand in this article is obtained as a framework default. If you choose Flask knowing that contrast, the discipline of this article comes alive.
9. A Worked Example: Versioned, Paginated /api/v1/orders
Finally, let's integrate the design so far into one resource. Taking orders (Order) as the theme, we assemble the two-class structure, Blueprint versioning, paging, and status codes end-to-end (validation is left to marshmallow; we show only the seam here).
# api/v1/orders.py
import time
from flask import Blueprint, g, jsonify, make_response, request, url_for
from flask.views import MethodView
from myapp.extensions import db
from myapp.models import Order
from myapp.auth import login_required, require_scope
api_v1 = Blueprint("api_v1", __name__)
class OrderItemAPI(MethodView):
init_every_request = False
decorators = [login_required]
def __init__(self, model):
self.model = model
def get(self, id):
# 不在なら 404(errorhandler が JSON エンベロープ化)
order = db.get_or_404(self.model, id)
return jsonify({"data": order.to_json()})
def patch(self, id):
order = db.get_or_404(self.model, id)
# 検証は境界(marshmallow)へ。失敗時の 422 化は errorhandler に集約
order.update_from(request.get_json())
db.session.commit()
return jsonify({"data": order.to_json()})
def delete(self, id):
order = db.get_or_404(self.model, id)
db.session.delete(order)
db.session.commit()
return "", 204 # 削除成功は本文なしの 204
class OrderGroupAPI(MethodView):
init_every_request = False
decorators = [login_required, require_scope("orders:write")]
def __init__(self, model):
self.model = model
def get(self):
g.t0 = time.time() # リクエスト固有の状態は self ではなく g(§2.4)
status = request.args.get("status", type=str)
page = request.args.get("page", default=1, type=int)
per_page = min(request.args.get("per_page", default=20, type=int), 100)
stmt = db.select(self.model).order_by(self.model.created_at.desc())
if status:
stmt = stmt.where(self.model.status == status)
pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
return jsonify({
"data": [o.to_json() for o in pagination.items],
"meta": {
"page": pagination.page,
"per_page": pagination.per_page,
"total": pagination.total,
"pages": pagination.pages,
},
})
def post(self):
# 冪等性キーで二重作成を防ぐ(§7.2)
key = request.headers.get("Idempotency-Key")
if key and (cached := get_idempotent_result(key)) is not None:
return jsonify({"data": cached}), 200
order = Order.create_from(request.get_json())
db.session.add(order)
db.session.commit()
body = order.to_json()
if key:
store_idempotent_result(key, body)
# 201 + Location ヘッダに新リソースの URL(§4.4)
resp = make_response(jsonify({"data": body}), 201)
resp.headers["Location"] = url_for("api_v1.orders-item", id=order.id)
return resp
def register_api(bp, view_item, view_group, model, name):
item = view_item.as_view(f"{name}-item", model)
group = view_group.as_view(f"{name}-group", model)
bp.add_url_rule(f"/{name}/<int:id>", view_func=item)
bp.add_url_rule(f"/{name}/", view_func=group)
register_api(api_v1, OrderItemAPI, OrderGroupAPI, Order, "orders")
# app/__init__.py(create_app 内で v1 をマウント)
from .api.v1.orders import api_v1
app.register_blueprint(api_v1, url_prefix="/api/v1")
The contract this resource answers is aligned per the conventions.
| Request | Success | Failure |
|---|---|---|
GET /api/v1/orders/?status=paid&page=2 | 200 + {data, meta} | 401 (unauthenticated) |
GET /api/v1/orders/5 | 200 + {data} | 404 / 401 |
POST /api/v1/orders/ (with Idempotency-Key) | 201 + Location / re-send is 200 | 422 (validation) / 409 (conflict) / 403 (insufficient scope) |
PATCH /api/v1/orders/5 | 200 + {data} | 404 / 422 |
DELETE /api/v1/orders/5 | 204 (no body) | 404 / 401 |
Replace to_json() with marshmallow's dump() and update_from / create_from with load(), and it connects directly to the boundary design of the marshmallow × Flask article. This article handles the resource's skeleton and contract; that article handles the boundary's contents — this division of labor is the design that kept 221 endpoints maintainable.
10. A REST API Design Checklist
A checklist condensing this article's conventions, to reference in review or code review (the Boy Scout Rule).
| Aspect | Check item | Section |
|---|---|---|
| Resource | The URL is a plural noun and contains no verb | §1.1, §7 |
| Resource | Express collection (/orders) and item (/orders/5) in two tiers | §1.1, §2.1 |
| Structure | It's the MethodView item / collection two-class structure | §2.1 |
| Structure | Many resources registered DRY-ly with the register_api factory | §2.3 |
| Structure | With init_every_request=False, request state is in g, not self | §2.4 |
| Auth | Authentication/authorization via the decorators class attribute (order: authN→authZ) | §2.5 |
| HTTP | Verb semantics and status codes are correct (201/204/409/422) | §1.2, §6.2 |
| HTTP | PUT / DELETE are idempotent. Prevent double execution of POST with an idempotency key | §1.3, §7.2 |
| HTTP | Add a Location header to a POST's 201 | §4.4 |
| Versioning | /api/v{n} URL-path style. There's a folding-up discipline (Deprecation/Sunset) | §3.2, §3.3 |
| Input | Path variables are typed converters (<int:id> / <uuid:>) | §4.1 |
| Input | Query filter/sort/paging unified by convention | §4.2 |
| Boundary | Put validation in marshmallow (the schema), not the view | §4.3 |
| Paging | Use offset/cursor as appropriate, upper bound on per_page | §5.1, §5.4 |
| Paging | Same envelope across all endpoints ({data, meta} or Link/X-Total-Count) | §5.3 |
| Errors | Unify on {"error": {code, message, details}} | §6.1 |
| Stateless | API auth is self-contained with tokens. Don't hold state in a server session | §1.4 |
| Docs | Once scale grows, move to OpenAPI auto-generation with flask-smorest | §8 |
Summary: A REST API Lives or Dies by "Consistency of Conventions"
The essence of designing a REST API in Flask isn't implementing individual endpoints cleverly, but deciding the conventions running through the whole into one and enforcing them as structure. Here are this article's key points, restated.
- A good API is beyond CRUD. Resource modeling (noun URLs), correct HTTP semantics (verbs, status codes, idempotency), and statelessness are the foundation.
MethodViewis the item / collection two-class structure — the right answer of the current official version. Consolidateas_view+add_url_ruleinto theregister_apifactory and register many resources DRY-ly in one line.- With
init_every_request=False, put request state ing(writing toselfis a race on the shared instance). Apply auth via thedecoratorsclass attribute in the order "authN → authZ." - API versioning is the
Blueprint'surl_prefix="/api/v1", and have, as a set, the discipline to fold up v1 in stages withDeprecation/Sunsetheaders. - Use offset/cursor paging as appropriate, stay consistent across all endpoints with a
{data, meta}envelope orLink/X-Total-Countheaders, and set an upper bound onper_page. - Validation goes to marshmallow, handler consolidation to the error-handling article, auto-docs to flask-smorest — this article narrows its responsibility to "the API's skeleton and conventions" and leaves the particulars to dedicated articles.
What separates a "working REST API" from a "REST API maintainable for years across 221 endpoints" is neither the framework nor the ORM, but how consistently you align the conventions and enforce them as structure (factory, two-class, Blueprint). Flask provides "the core only," and you design these conventions. The yardstick to take responsibility for that freedom is this article's checklist.
For the API's boundary (input validation, response shaping), go to Designing a Production REST API with marshmallow × Flask × SQLAlchemy; for auto-documentation, the flask-smorest Guide; for the overall map, the Flask Production Operations Guide — flesh out this article's skeleton with the particulars.