# REST API Design in Flask: MethodView (Class-Based Views), Resource Design with Blueprints, API Versioning, Pagination, and HTTP Semantics

> A practical guide to designing a production-quality REST API in the Flask 3.1 line. From MethodView's item/collection two-class structure, as_view+add_url_rule and the register_api factory, resource splitting and /api/v1 versioning with Blueprints, HTTP semantics (idempotency, status codes), to conventions for pagination/filtering and the JSON error envelope — explained with real code faithful to the official docs.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, Flask, REST API, MethodView, Blueprint, アーキテクチャ設計, バックエンド
- URL: https://tomodahinata.com/en/blog/flask-rest-api-design-methodview-blueprint-versioning-guide
- Category: Flask in production
- Pillar guide: https://tomodahinata.com/en/blog/flask-production-guide

## Key points

- A good REST API is decided not by a pile of CRUD but by resource modeling, correct HTTP semantics (verbs/status codes/idempotency), and statelessness
- For MethodView, the current official version's item/collection two-class structure is the right answer. Register many resources DRY-ly with as_view+add_url_rule and the register_api factory
- If you reuse instances with init_every_request=False, put per-request data in g, not self. Apply auth via the decorators class attribute to the return value of as_view
- Express API versioning with a Blueprint's url_prefix (/api/v1, /api/v2), and migrate to v2 in stages while deprecating v1. Leave validation to the boundary (marshmallow) and auto-docs to flask-smorest
- Use offset/cursor pagination as appropriate, and stay consistent with a {data, meta} envelope or Link/X-Total-Count headers. Explained on the basis of the 221-endpoint design of an economic-ministry-award-winning B2B SaaS

---

## **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](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide). 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 in `Blueprint`), see [the Large-Scale App Structure Guide](/blog/flask-application-factory-blueprints-large-app-structure-guide); for the overall map, see [the Flask Production Operations Guide](/blog/flask-production-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 `ValidationError` into 422 is detailed in §6 of [the marshmallow × Flask article](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide). 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/5` twice 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.

```python
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())
```

- **`ItemAPI`** has `get` / `patch` / `delete`, all taking `id` as an argument (operations on an individual item).
- **`GroupAPI`** has `get` (list) and `post` (create new), taking no `id` (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, `GroupAPI` uses `generate_validator(model, create=True)` while `ItemAPI` uses `generate_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__`**.

```python
# 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**.

```python
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 `self` is not safe**: when you reuse the instance with `init_every_request = False`, that instance is **shared across multiple requests (multiple threads)**. The official docs state it plainly — "**writing to `self` is not safe. If you need to store data during a request, use `g` instead of `self`**." Holding a per-request value (the current user, a request ID, etc.) like `self.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.

```python
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](/blog/flask-production-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.

```python
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):
        ...
```

> 💡 **`decorators` is applied "bottom-up"**: with `decorators = [login_required, require_scope(...)]`, `require_scope` is applied first (inner) and `login_required` later (outer). Since a request passes from the outside in, **`login_required` runs first, then `require_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](/blog/flask-production-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.

```python
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](/blog/flask-application-factory-blueprints-large-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.

```python
# 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](/blog/flask-application-factory-blueprints-large-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`.

```python
@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 |

```python
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 `int` ID can be guessed — "next to `/orders/41` is `/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 the `uuid` converter 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=`.

```python
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**.

```python
# 本記事のスコープ：パラメータを取り出し、リソースを設計する
page = request.args.get("page", default=1, type=int)

# marshmallow 記事のスコープ：取り出した値を検証し、不正なら 422 に変換する
#   → load() で QueryArgsSchema を通し、ValidationError を errorhandler が 422 へ
```

Scattering validation `if`s 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](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide).

### 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(...)`.

```python
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.

```python
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](/blog/flask-sqlalchemy-flask-migrate-database-production-guide). The connection of replacing `to_json()` with marshmallow's `dump()` corresponds to §9 of [the marshmallow × Flask article](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide).

### 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.

```python
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 `total` to `meta` (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.

```json
{
  "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's `err.messages` here).

### 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 without `try/except` and 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](/blog/flask-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](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide).

---

## **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."

```python
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 the `Blueprint`, schema declaration, Swagger UI) are covered in [the Flask OpenAPI / Swagger (flask-smorest) Guide](/blog/flask-openapi-swagger-flask-smorest-api-documentation-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](/blog/flask-vs-fastapi-vs-django-comparison-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).

```python
# 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")
```

```python
# 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](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide). 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.

1. **A good API is beyond CRUD.** Resource modeling (noun URLs), correct HTTP semantics (verbs, status codes, idempotency), and statelessness are the foundation.
2. **`MethodView` is the item / collection two-class structure** — the right answer of the current official version. Consolidate `as_view` + `add_url_rule` into the `register_api` factory and register many resources DRY-ly in one line.
3. **With `init_every_request=False`, put request state in `g`** (writing to `self` is a race on the shared instance). Apply auth via the `decorators` class attribute in the order "authN → authZ."
4. **API versioning is the `Blueprint`'s `url_prefix="/api/v1"`**, and have, as a set, the discipline to fold up v1 in stages with `Deprecation` / `Sunset` headers.
5. **Use offset/cursor paging as appropriate**, stay consistent across all endpoints with a `{data, meta}` envelope or `Link`/`X-Total-Count` headers, and set an upper bound on `per_page`.
6. **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](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide); for auto-documentation, [the flask-smorest Guide](/blog/flask-openapi-swagger-flask-smorest-api-documentation-guide); for the overall map, [the Flask Production Operations Guide](/blog/flask-production-guide) — flesh out this article's skeleton with the particulars.
