Filter concepts by levelShowing all levels.

Python · API Development

API Development

Designing production APIs — resources, validation, errors, pagination, versioning, idempotency, auth, and docs generated from the same models

  • Model nouns, not verbsa non-CRUD action becomes a sub-resource, not a bolted-on verb
  • Validate at the boundaryfield-level errors before business logic ever runs
  • Object-level authdoes THIS user own THIS resource — the #1 missed API check
  • Docs from modelsgenerated from the same schema that validates, never drifts

1.REST and resource modeling

REST models an API as nouns (resources — /orders, /users/42) manipulated by a small, uniform set of HTTP methods, instead of one URL per action (/getOrder, /createOrder) — resource modeling is the design skill of choosing what counts as a resource and how they nest.

Key features

  • A resource is a noun with a stable identity — /orders/4821, not an action like /getOrderById
  • Collections are plural (/orders); a specific member is the collection plus an identifier (/orders/4821)
  • Nesting expresses ownership: /users/42/orders is "orders belonging to user 42" — do not nest more than 2 levels deep, it gets unreadable
  • An action that genuinely is not CRUD (like "cancel an order") is still modeled as a resource: POST /orders/4821/cancellation, not POST /cancelOrder
  • HTTP semantics (which method, which status code) is the mechanism resource design relies on — see Web and HTTP Fundamentals for the method/status vocabulary itself

Action-style vs. resource-style URLs

Action-style vs. resource-style URLs
Action style (avoid)Resource style (REST)
GET /getUser?id=42GET /users/42
POST /createOrderPOST /orders
POST /deleteOrder?id=4821DELETE /orders/4821
POST /cancelOrder?id=4821POST /orders/4821/cancellation
GET /getUserOrders?user_id=42GET /users/42/orders
Action-style vs. resource-style URLs

Action style (avoid)

  • POST /createOrder
  • POST /cancelOrder?id=4821
  • A new endpoint per action

Resource style (REST)

  • POST /orders
  • POST /orders/4821/cancellation
  • The same uniform verbs, every noun
  • Action style (avoid)
    • POST /createOrder
    • POST /cancelOrder?id=4821
    • A new endpoint per action
  • Resource style (REST)
    • POST /orders
    • POST /orders/4821/cancellation
    • The same uniform verbs, every noun

Command

python
# resource-style route table, framework-agnostic
routes = {
    ('GET', '/orders'): list_orders,
    ('POST', '/orders'): create_order,
    ('GET', '/orders/{id}'): get_order,
    ('PUT', '/orders/{id}'): replace_order,
    ('DELETE', '/orders/{id}'): delete_order,
    ('GET', '/users/{user_id}/orders'): list_orders_for_user,   # nested = ownership
}

Model nouns (resources), not verbs — a genuinely non-CRUD action still becomes a sub-resource (POST .../cancellation), and nesting should stay at most 2 levels deep.

2.Request validation and response serialization

Request validation rejects a malformed or out-of-range request BEFORE any business logic runs, with a precise error explaining what was wrong; response serialization is the reverse direction — turning a Python object into the exact JSON shape the API contract promises, not just whatever str()/repr() would produce.

Key features

  • A validation library like Pydantic defines the expected shape as a class, and raises with a field-by-field error list on mismatch
  • Validate at the boundary (as requests arrive) — the rest of the codebase can then trust the shape without repeated defensive checks
  • Serialization should be an explicit, controlled step — never return an ORM/database object directly, which risks leaking a column the response contract does not promise
  • A validation error should name the EXACT field and reason (quantity: Input should be greater than 0), not a generic "bad request"
  • Optional fields need an explicit default (None, or a real default value) — a required field with no value present should fail validation, not silently become None
Validate on the way in, serialize on the way out

incoming JSON

CreateOrderRequest(...)

raises ValidationError on bad input

business logic

trusts the shape — no defensive checks

UserResponse.model_validate(...)

an explicit allowlist, never the raw ORM object

response JSON

  • incoming JSON
    • leads to CreateOrderRequest(...)
  • CreateOrderRequest(...) — raises ValidationError on bad input
    • leads to business logic
  • business logic — trusts the shape — no defensive checks
    • leads to UserResponse.model_validate(...)
  • UserResponse.model_validate(...) — an explicit allowlist, never the raw ORM object
    • leads to response JSON
  • response JSON

Command

python
from pydantic import BaseModel, Field
from typing import Optional

class CreateOrderRequest(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)
    note: Optional[str] = None

request = CreateOrderRequest(**incoming_json)   # raises ValidationError on bad input
response_body = request.model_dump()             # controlled, explicit serialization

Validate strictly at the boundary with field-level errors, and serialize through an explicit response model — never return an internal object's full shape directly.

3.Error responses

A good error response is itself a small, consistent, machine-parseable contract — a status code, a stable machine-readable error code/type, and a human-readable message — not a stack trace, an inconsistent ad-hoc shape, or a 200 with an error hidden inside.

Key features

  • Every error response in an API should share ONE consistent shape — a client should never have to special-case each endpoint's error format
  • Include a stable, machine-readable code/type (order_not_found) alongside the human-readable message, so client code can branch on the code, not string-match the message
  • Never return a raw stack trace or internal exception message to the client — it leaks implementation details and is not even guaranteed to be accurate to the real cause
  • A validation error (422) should list EVERY field that failed at once, not one at a time across repeated round trips
  • RFC 9457 (Problem Details) standardizes a shape: type, title, status, detail, instance — worth adopting even informally for consistency
One consistent error envelope, for every failure

{ 'error': { 'code': 'validation_failed', 'message': 'One or more fields failed validation.', 'details': {'quantity': 'must be greater than 0'}, } }

'code': 'validation_failed'

code — stable, machine-readable — client code branches on this

'message': 'One or more fields failed validation.'

message — human-readable, safe to display

'details': {'quantity': 'must be greater than 0'}

details — optional — structured, per-field information

  • Whole: { 'error': { 'code': 'validation_failed', 'message': 'One or more fields failed validation.', 'details': {'quantity': 'must be greater than 0'}, } }
  • 'code': 'validation_failed' — code: stable, machine-readable — client code branches on this
  • 'message': 'One or more fields failed validation.' — message: human-readable, safe to display
  • 'details': {'quantity': 'must be greater than 0'} — details: optional — structured, per-field information

Command

python
def error_response(status: int, code: str, message: str, details: dict | None = None) -> tuple[dict, int]:
    body = {'error': {'code': code, 'message': message}}
    if details:
        body['error']['details'] = details
    return body, status

error_response(404, 'order_not_found', 'No order with that ID exists.')

One consistent error shape across every endpoint, with a stable machine-readable code plus a human message — full internal detail goes to logs, never to the client.

A collection endpoint (GET /orders) needs a way to return a manageable slice (pagination), narrow results by a field's value (filtering), control result order (sorting), and match free-text (search) — all expressed as query parameters, never as separate endpoints per combination.

Key features

  • Offset pagination (?page=2&page_size=20) is simple but can skip/repeat rows if data changes between page requests
  • Cursor pagination (?after=<opaque_id>) stays stable under concurrent writes — the standard choice for large or frequently-changing collections
  • Filtering uses query params matching field names (?status=paid) — document the allowed operators (?price_gte=10) rather than inventing ad-hoc syntax per field
  • Sorting convention: ?sort=field for ascending, ?sort=-field for descending — support multiple comma-separated sort keys only if genuinely needed
  • Search (?q=dragon) is fundamentally different from filtering — it typically needs a text index, not just a WHERE field = value clause

Query parameter conventions

Query parameter conventions
PurposeExampleNotes
Pagination (offset)?page=2&page_size=20Simple; can skip/repeat under concurrent writes
Pagination (cursor)?after=eyJpZCI6NDgyMX0Stable under writes; opaque cursor, not a raw offset
Filtering?status=paid&price_gte=10One param per field; document supported operators
Sorting?sort=-created_atLeading "-" for descending is a common, readable convention
Search?q=dragonFree text; usually backed by a dedicated text index, not exact match
Offset vs. cursor pagination under concurrent writes

Offset (?page=2&page_size=20)

  • Defined by POSITION — skip N, take M
  • An insert before the page shifts everything
  • Items silently skipped or duplicated

Cursor (?after=<opaque_id>)

  • Anchored to a row's identity
  • Immune to inserts/deletes elsewhere
  • The standard choice for large/changing collections
  • Offset (?page=2&page_size=20)
    • Defined by POSITION — skip N, take M
    • An insert before the page shifts everything
    • Items silently skipped or duplicated
  • Cursor (?after=<opaque_id>)
    • Anchored to a row's identity
    • Immune to inserts/deletes elsewhere
    • The standard choice for large/changing collections

Command

python
def paginate(total_items: int, page: int, page_size: int) -> dict:
    total_pages = (total_items + page_size - 1) // page_size    # ceiling division
    offset = (page - 1) * page_size
    return {
        'total_items': total_items,
        'total_pages': total_pages,
        'offset': offset,
        'has_next': page < total_pages,
    }

Offset pagination is simple but can skip/duplicate rows under concurrent writes — cursor pagination anchors to a row's identity instead of a position.

5.API versioning

API versioning is a deliberate strategy for shipping a breaking change without breaking every existing client at once — the three common mechanisms are a version in the URL path (/v2/orders), a version header, or content negotiation via the Accept header, each with a different visibility/caching tradeoff.

Key features

  • URL path versioning (/v1/orders, /v2/orders) is the most visible and cache-friendly — different versions are trivially different URLs
  • Header versioning (Accept: application/vnd.api+json;version=2, or a custom X-API-Version) keeps URLs stable but is less discoverable and can complicate caching
  • A breaking change is: removing a field, renaming a field, changing a field's type/meaning, or changing default behavior — adding a new optional field is NOT breaking
  • A new version does not mean rewriting everything — most endpoints stay identical; only the ones with an actual breaking change need a new version
  • A version needs a documented, communicated deprecation window (e.g. "v1 will stop being served after 2027-01-01") — an undocumented shutdown breaks clients with no warning

Versioning strategies

Versioning strategies
StrategyExampleTradeoff
URL path/v2/ordersMost visible/cacheable; some see it as "polluting" the resource URL
HeaderX-API-Version: 2Clean URLs; less discoverable, harder to test by hand
Content negotiationAccept: application/vnd.api.v2+jsonRESTfully "correct"; the least common in practice
Two entrances instead of rebuilding the one door everyone uses

existing client

/v1/orders

total: 42.50 — unchanged forever

  • existing client
    • leads to /v1/orders
  • /v1/orders — total: 42.50 — unchanged forever
  • new client
    • leads to /v2/orders
  • /v2/orders — total_cents: 4250 — the breaking change

Command

python
def get_order_v1(order_id: int) -> dict:
    return {'id': order_id, 'total': 42.50}

def get_order_v2(order_id: int) -> dict:
    return {'id': order_id, 'total_cents': 4250}   # breaking: type/meaning changed

VERSIONED_HANDLERS = {'v1': get_order_v1, 'v2': get_order_v2}

A breaking change gets a new version, not a silent change to the existing one — and every version needs a documented, communicated deprecation window before it is retired.

6.Idempotency keys and rate limiting

An idempotency key is a client-generated ID sent with a non-idempotent request (like POST) so the server can recognize and safely dedupe a retried request instead of repeating its effect; rate limiting caps how many requests a client can make in a window, protecting the API from being overwhelmed by any one source.

Key features

  • The client generates the idempotency key (a UUID, typically) BEFORE the first attempt, and reuses the SAME key on every retry of that same logical operation
  • The server stores (key → response) for a bounded time window and returns the STORED response for a repeated key, without re-running the operation
  • A token bucket rate limiter has a capacity and a refill rate — it allows bursts up to capacity, then throttles to the steady refill rate
  • A rate-limited response should be 429 Too Many Requests, ideally with a Retry-After header telling the client exactly when to try again
  • Rate limits are commonly scoped per API key/user, not globally — one noisy client should not exhaust the budget shared by every other client
A token bucket: burst up to capacity, then throttle

Bucket starts full

capacity=3 tokens

5 rapid requests

each costs 1 token

First 3 allowed

the burst capacity

Rest denied (429)

until the bucket refills

  1. Bucket starts full — capacity=3 tokens
  2. 5 rapid requests — each costs 1 token
  3. First 3 allowed — the burst capacity
  4. Rest denied (429) — until the bucket refills

Command

python
import time

class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity, self.tokens, self.refill_rate = capacity, capacity, refill_rate
        self.last = time.monotonic()

    def allow(self) -> bool:
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill_rate)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

A retried non-idempotent request needs a client-generated idempotency key the server dedupes on — a token bucket allows a burst up to capacity, then throttles to the refill rate.

7.Authentication and authorization in APIs

Authentication answers "who is making this request" (checked once, usually via a token); authorization answers "is THIS identity allowed to do THIS specific thing" (checked per-action) — an API needs both, and conflating them is a common source of real access-control bugs.

Key features

  • Authentication happens once per request, typically as middleware — verify the token/credential, attach the resulting identity to the request context
  • Authorization happens PER ACTION, close to the operation itself — "is this user allowed to delete THIS specific order" needs the order's owner, not just the user's identity
  • A 401 Unauthorized means authentication failed (no/bad credentials); a 403 Forbidden means authentication succeeded but authorization denied the action — the two are not interchangeable
  • Object-level authorization (can THIS user access THIS specific resource) is a distinct, commonly-missed check from endpoint-level authorization (can this role call this endpoint at all)
  • Full mechanism depth (JWT structure, OAuth flows, session cookies, RBAC/ABAC) is the roadmap's own separate Authentication and Authorization section — this concept is the design-level distinction, not the mechanism catalog
Two separate checks, at two separate moments

Request + token

Authentication

who is this? — once, as middleware

Authorization

can THIS user do THIS to THIS resource? — per action

200 — allowed

401 — no valid identity

403 — identity known, not allowed

  • Request + token
    • leads to Authentication
  • Authentication — who is this? — once, as middleware
    • leads to Authorization
    • on error, leads to 401 — no valid identity
  • Authorization — can THIS user do THIS to THIS resource? — per action
    • leads to 200 — allowed
    • on error, leads to 403 — identity known, not allowed
  • 200 — allowed
  • 401 — no valid identity
  • 403 — identity known, not allowed

Command

python
def authenticate(request) -> 'User | None':
    token = request.headers.get('Authorization', '').removeprefix('Bearer ')
    return verify_token_and_get_user(token)   # None if invalid/missing

def can_delete_order(user: 'User', order: 'Order') -> bool:
    return order.owner_id == user.id or user.is_admin   # object-level check

401 means "I don't know who you are," 403 means "I know who you are, and no" — object-level authorization (does THIS user own THIS resource) is the check most often missing.

8.OpenAPI, Swagger, and API documentation

OpenAPI is a standard, machine-readable specification format (usually YAML/JSON) describing every endpoint, parameter, request/response shape, and status code an API exposes; Swagger UI is one of several tools that render an OpenAPI document as interactive, browsable documentation — the two are not the same thing.

Key features

  • OpenAPI describes paths, parameters, request/response schemas (usually as JSON Schema), status codes, and auth requirements — all in one machine-readable document
  • Swagger UI, ReDoc, and Stoplight Elements are all just RENDERERS of an OpenAPI document — different visual tools reading the same underlying spec
  • A validation library (like Pydantic) can generate the JSON Schema portion of an OpenAPI document directly from the same models used to validate requests — one source of truth, not hand-written docs that drift
  • Generated documentation only stays accurate if it is generated FROM the actual validation/response models — hand-maintained docs reliably drift out of sync with real behavior
  • An OpenAPI document also enables tooling beyond docs: generating a typed client SDK, contract testing, and mock servers for frontend development before a backend endpoint is finished
One model, two consumers — validation and docs stay in sync

CreateOrderRequest

the Pydantic model

Runtime validation

rejects quantity=0

model_json_schema()

OpenAPI-compatible schema

Swagger UI / ReDoc

renders the same rules as docs

  • CreateOrderRequest — the Pydantic model
    • leads to Runtime validation
    • leads to model_json_schema()
  • Runtime validation — rejects quantity=0
  • model_json_schema() — OpenAPI-compatible schema
    • leads to Swagger UI / ReDoc
  • Swagger UI / ReDoc — renders the same rules as docs

Command

python
from pydantic import BaseModel, Field
from typing import Optional

class CreateOrderRequest(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)
    note: Optional[str] = None

schema = CreateOrderRequest.model_json_schema()   # the OpenAPI-compatible schema, generated

Generate API documentation FROM the same models used for validation, not as a separately hand-maintained document — that is what keeps docs and real behavior from silently drifting apart.

9.Backward compatibility

A backward-compatible change is one an existing, unmodified client survives without breaking — adding a new optional field, adding a new endpoint, or relaxing a validation rule are all safe; removing a field, renaming one, changing a type, or tightening validation are not, and belong behind a new API version instead.

Key features

  • Safe (backward-compatible): adding a new optional response field, adding a new endpoint, adding a new optional request parameter, relaxing a validation rule (widening an allowed range)
  • Breaking: removing a field, renaming a field, changing a field's type or unit, making an optional field required, tightening validation (narrowing an allowed range), changing a status code for an existing case
  • A client that only reads fields it recognizes and ignores unknown ones ("tolerant reader") survives many additive changes automatically — this is a client-side discipline the API cannot enforce alone
  • Even a "harmless-looking" default value change can break a client that depended on the old default — defaults are part of the contract too
  • A deprecation should be signaled (a Deprecation header, a documented sunset date) BEFORE removal, giving clients a real window to migrate

Safe vs. breaking changes

Safe vs. breaking changes
ChangeSafe or breaking?
Add a new optional response fieldSafe
Add a new optional request parameterSafe
Widen a validation range (max 100 → max 200)Safe
Remove a response fieldBreaking
Rename a fieldBreaking
Change a field's type or unitBreaking
Narrow a validation range (max 200 → max 100)Breaking
Make an optional request field requiredBreaking
Additive vs. breaking

Safe

  • New optional response field
  • New optional request parameter
  • A new endpoint
  • Widening a validation range

Breaking

  • Removing or renaming a field
  • Changing a field's type or unit
  • Narrowing a validation range
  • Making an optional field required
  • Safe
    • New optional response field
    • New optional request parameter
    • A new endpoint
    • Widening a validation range
  • Breaking
    • Removing or renaming a field
    • Changing a field's type or unit
    • Narrowing a validation range
    • Making an optional field required

Command

python
# safe: adding a new OPTIONAL field to an existing response model
class OrderResponseV1(BaseModel):
    id: int
    total: float
    estimated_delivery: str | None = None   # new, optional -- old clients ignore it fine

# breaking: narrowing an existing field's allowed range
class UpdateQuantityV1(BaseModel):
    quantity: int = Field(gt=0, le=50)   # was le=100 -- an existing client sending 75 now fails

Additive changes (new optional field/endpoint/parameter) are safe; anything that removes, renames, retypes, or narrows an existing part of the contract is breaking and needs a deprecation window or a new version.

Remember

  • Model nouns, not verbs — a non-CRUD action becomes a sub-resource (POST .../cancellation), not a bolted-on verb.
  • Validate strictly at the boundary with field-level errors; serialize through an explicit response model, never a raw object.
  • One consistent error shape everywhere, with a stable machine-readable code — full detail goes to logs, not the client.
  • Additive changes are safe; removing, renaming, retyping, or narrowing anything existing is breaking and needs a new version.

Pro tip

  • Offset pagination can skip/duplicate rows under concurrent writes — cursor pagination anchors to identity instead.
  • A retried non-idempotent request needs a client-generated Idempotency-Key the server dedupes on.
  • 401 means "I don't know who you are"; 403 means "I know who you are, and no" — never use them interchangeably.
  • Generate API docs (OpenAPI/JSON Schema) FROM the same validation models — hand-maintained docs reliably drift.

Next up

  • FastAPI

Sources

  • Pydantic — docs.pydantic.dev
  • OWASP API Security Top 10 — owasp.org/API-Security
  • RFC 9457 (Problem Details) — rfc-editor.org/rfc/rfc9457
  • Every example run for real with Pydantic 2.13.4 on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.