Filter concepts by levelShowing all levels.

Django · API Design

API Design

Resource-oriented URLs, HTTP semantics and status codes, the three contracts, collection controls, and versioning

  • The URL names a thing; the method is the verbPOST /orders/create/ says "create" twice
  • Safe and idempotent are promisescaches and clients retry idempotent methods without asking permission
  • The status code is the APInever a 200 wrapping an error — nothing in the stack can see that
  • Filter → search → sort → paginatea fixed order, and only the last stage bounds response size
  • Adding is safe, removing is notclients ignore unknown fields and crash on missing ones

1.REST principles and resource-oriented URLs

REST organises an API around *things* rather than *actions*. A URL names a resource — `/orders/`, `/orders/57/`, `/orders/57/items/` — and the HTTP method says what to do with it. That is why `POST /orders/create/` is redundant and `POST /orders/` is not: the verb is already in the method. The other principles that matter day to day are statelessness (every request carries everything needed to serve it — no server-side conversation between calls) and a uniform interface (the same method means the same thing on every resource, so a client that understands one endpoint understands the rest). Collections are plural, identifiers go in the path, and anything that only *narrows* a collection goes in the query string.

Key features

  • The URL names the resource and the method names the operation — `POST /orders/create/` says "create" twice and `DELETE /orders/57/delete/` says "delete" twice
  • Collections are plural and identifiers sit in the path: `/orders/` and `/orders/57/`, never `/order/` or `/orders?id=57`
  • Nest only to express containment, and only one level: `/orders/57/items/` is fine, `/customers/3/orders/57/items/9/` is a URL nobody can construct correctly
  • Query parameters narrow or shape a collection (filter, sort, page); they never identify a single resource and never change what the request does
  • A non-CRUD operation is either its own sub-resource (`POST /orders/57/refunds/`) or a named sub-action (`POST /orders/57/refund/`) — not a verb in the collection URL
  • Trailing slashes must be consistent: DRF routers generate them by default, and `APPEND_SLASH` turns a mismatched POST into a 301 that drops the body

The same five operations, RPC-shaped and resource-shaped

The same five operations, RPC-shaped and resource-shaped
OperationRPC-shaped (avoid)Resource-oriented
List orders`GET /getOrders``GET /orders/`
Create an order`POST /orders/create/``POST /orders/`
Read one order`GET /orders/get?id=57``GET /orders/57/`
Update an order`POST /orders/update/57/``PATCH /orders/57/`
Refund an order`POST /refundOrder``POST /orders/57/refunds/`
Two ways to spell the same API

RPC-shaped — the verb is in the URL

  • Every operation is a new name the client must be told about.
  • The method carries no meaning — everything is POST.
  • Nothing is cacheable, because a GET and a mutation look alike.
  • No router can generate it; every path is hand-written.
  • Adding an operation means adding vocabulary, not reusing a shape.

Resource-oriented — the verb is the method

  • One noun, five methods — a client that learns the shape once reuses it everywhere.
  • GET is safe and cacheable; DELETE and PUT are idempotent. The method says so.
  • A DRF router generates the whole URL set from one ViewSet.
  • Non-CRUD operations become sub-resources with their own history.
  • New resources cost no new vocabulary.
  • RPC-shaped — the verb is in the URL
    • Every operation is a new name the client must be told about.
    • The method carries no meaning — everything is POST.
    • Nothing is cacheable, because a GET and a mutation look alike.
    • No router can generate it; every path is hand-written.
    • Adding an operation means adding vocabulary, not reusing a shape.
  • Resource-oriented — the verb is the method
    • One noun, five methods — a client that learns the shape once reuses it everywhere.
    • GET is safe and cacheable; DELETE and PUT are idempotent. The method says so.
    • A DRF router generates the whole URL set from one ViewSet.
    • Non-CRUD operations become sub-resources with their own history.
    • New resources cost no new vocabulary.

Command

python
router.register("orders", OrderViewSet, basename="order")
# generates /orders/ and /orders/{pk}/ with the standard method mapping

The URL names a thing and the method says what to do with it, so `POST /orders/create/` states the verb twice. Collections are plural, ids go in the path, query parameters only narrow. Nest one level to express containment and no more. A non-CRUD operation is either its own sub-resource — `POST /orders/57/refunds/`, which gains an id and a list endpoint — or a named sub-action, never a verb hanging off the collection URL, which collides with the detail route and moves the id out of the path where object permissions live.

2.HTTP semantics, status codes, and method idempotency

Each HTTP method carries two promises defined by the spec. **Safe** means the request does not change server state, so it can be prefetched, cached, or retried freely — `GET`, `HEAD` and `OPTIONS` are safe. **Idempotent** means sending the request twice leaves the server in the same state as sending it once — `GET`, `HEAD`, `OPTIONS`, `PUT` and `DELETE` are idempotent; `POST` and `PATCH` are not. These are contracts your handlers must honour, not descriptions of what they happen to do: proxies, browsers and client libraries retry idempotent methods automatically, so a `DELETE` that returns 404 on the second call has broken a promise something in the network is already relying on. Status codes are the other half of that contract — the code is the machine-readable outcome, and the body is only for humans.

Key features

  • Safe methods (`GET`, `HEAD`, `OPTIONS`) must not change state — a GET that mutates will eventually be triggered by a prefetcher or a link scanner
  • Idempotent methods are `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`; `POST` and `PATCH` are not, and `PATCH` is not because a patch body can be relative (`{"increment": 1}`)
  • `PUT` replaces the whole resource and `PATCH` applies a partial change — a `PUT` missing a field should clear it, which is why partial updates are `PATCH`
  • A repeated `DELETE` should return the same success code as the first, not 404 — otherwise an automatic retry reports a failure for work that succeeded
  • 201 Created carries a `Location` header pointing at the new resource; 202 Accepted means the work has not happened yet
  • 409 is a conflict with the current state (a duplicate, a stale version); 422 is a well-formed request whose contents fail business rules — DRF returns 400 for serializer errors by default

Method properties, and what relies on each

Method properties, and what relies on each
MethodSafeIdempotentTypical success codeWhat depends on the promise
`GET`yesyes200caches, prefetchers, link scanners
`HEAD`yesyes200 (no body)health checks, size probes
`OPTIONS`yesyes200CORS preflight
`POST`no**no**201 + `Location`nothing may retry it automatically
`PUT`noyes200 / 204client and proxy retry after a timeout
`PATCH`no**no**200nothing may retry it automatically
`DELETE`noyes204retry on connection reset

Choosing between the codes that get confused

Choosing between the codes that get confused
CodeMeansNot
400the request is malformed or fails validationnot "something went wrong"
401authenticate yourselfnot "you may not do this"
403identity known, action refusednot "not found" — it confirms existence
404no such resource, or you may not know it existsnot an empty list — that is 200 with `[]`
409conflicts with current state — duplicate, stale versionnot a validation error
422well-formed, but fails a business ruleDRF uses 400 for this unless you change it
429rate limited — includes `Retry-After`not 403
201created; `Location` names the new resourcenot 200 with an id in the body
Safe and idempotent — the two promises, and where each method sits
POST
creates something new each time — needs an idempotency key to be retry-safe
PATCH
not idempotent: a relative patch body applies twice
PUT
full replacement — the same body twice gives the same state
DELETE
idempotent only if the second call also succeeds, not 404
GET
safe and idempotent — cacheable, prefetchable, freely retried
HEAD / OPTIONS
safe; used by health checks and CORS preflight
  • POST: changes server state, NOT idempotent — a retry does more — creates something new each time — needs an idempotency key to be retry-safe
  • PATCH: changes server state, NOT idempotent — a retry does more — not idempotent: a relative patch body applies twice
  • PUT: changes server state, idempotent — a retry changes nothing — full replacement — the same body twice gives the same state
  • DELETE: changes server state, idempotent — a retry changes nothing — idempotent only if the second call also succeeds, not 404
  • GET: leaves state unchanged, idempotent — a retry changes nothing — safe and idempotent — cacheable, prefetchable, freely retried
  • HEAD / OPTIONS: leaves state unchanged, idempotent — a retry changes nothing — safe; used by health checks and CORS preflight

Command

python
def destroy(self, request, *args, **kwargs):
    Order.objects.filter(pk=kwargs["pk"]).delete()   # no 404 on the second call
    return Response(status=status.HTTP_204_NO_CONTENT)

Safe means "does not change state", idempotent means "twice is the same as once" — and infrastructure you do not control retries idempotent methods on your behalf, so both are promises, not descriptions. `GET`/`HEAD`/`OPTIONS` are safe; those plus `PUT`/`DELETE` are idempotent; `POST` and `PATCH` are not, which is why `POST` needs idempotency keys. A repeated `DELETE` returns the same success code, never 404. And the status code is the API: 401 versus 403, 400 versus 409, 201 with `Location` — never a 200 wrapping an error.

3.Request validation, response schemas, and error contracts

Three contracts sit between a client and your view. **Request validation** decides what a valid request body looks like — in DRF, a serializer, with `is_valid(raise_exception=True)` turning failures into a 400 carrying per-field messages. **Response schemas** decide what comes back: a fixed, documented shape rather than "whatever the serializer happened to produce today", which is what lets a client parse it without guessing. **The error contract** is the shape every failure takes, and it matters more than either, because it is the part clients handle least often and rely on most when something breaks. One shape for all errors — a stable machine-readable code, a human message, and per-field details where they apply — means a client writes error handling once.

Key features

  • `serializer.is_valid(raise_exception=True)` raises `ValidationError`, which DRF renders as 400 with a per-field mapping — the shape is `{"field": ["message", ...]}`
  • Validation runs in three stages — field validators, then `validate_<field>()`, then `validate()` — and a failure in an earlier stage means later ones do not run for that field
  • A stable machine-readable `code` per error is the field clients should branch on; status codes are too coarse and `detail` strings get reworded
  • RFC 9457 Problem Details defines `type`, `title`, `status`, `detail`, `instance` and a `application/problem+json` content type — a ready-made contract rather than an invented one
  • A response schema is a promise: adding a field is backward-compatible, removing or retyping one is not
  • Error bodies must never carry stack traces, SQL, settings values, or internal hostnames — those belong in the log, keyed by a correlation id the response echoes

The three contracts, and what breaks a client when each one drifts

The three contracts, and what breaks a client when each one drifts
ContractDefined bySafe changeBreaking change
Request bodythe serializer's fields and validatorsadding an optional fieldmaking a field required; narrowing a type
Response bodythe output serializeradding a fieldremoving, renaming, or retyping a field
Error bodythe exception handleradding a detail keychanging `code` values or the top-level shape
Status codesthe exceptions you raiseusing a more precise code for a new casechanging which code an existing case returns
One request, and every shape it can come back as
invalid JSONwrong type/ missingfails aper-field rulefails across-field ruleanythingunhandled

POST /orders/ with a JSON body

Parse

malformed JSON never reaches the serializer

Stage 1 · field validators

types, required, max_length, choices

Stage 2 · validate_<field>()

one field, cross-checked against the database

Stage 3 · validate()

rules spanning several fields

save() → the view

server-controlled fields set here, never accepted

201 · the response schema

a documented shape — adding a field is safe, removing one is not

400 · the error contract

same envelope every time: type, detail, errors, correlation_id

500 · still the error contract

a generic detail in the body, the traceback in the log only

  • POST /orders/ with a JSON body
    • leads to Parse
  • Parse — malformed JSON never reaches the serializer
    • on error, leads to 400 · the error contract (invalid JSON)
    • leads to Stage 1 · field validators
  • Stage 1 · field validators — types, required, max_length, choices
    • on error, leads to 400 · the error contract (wrong type / missing)
    • leads to Stage 2 · validate_<field>()
  • Stage 2 · validate_<field>() — one field, cross-checked against the database
    • on error, leads to 400 · the error contract (fails a per-field rule)
    • leads to Stage 3 · validate()
  • Stage 3 · validate() — rules spanning several fields
    • on error, leads to 400 · the error contract (fails a cross-field rule)
    • leads to save() → the view
  • save() → the view — server-controlled fields set here, never accepted
    • leads to 201 · the response schema
    • on error, leads to 500 · still the error contract (anything unhandled)
  • 201 · the response schema — a documented shape — adding a field is safe, removing one is not
  • 400 · the error contract — same envelope every time: type, detail, errors, correlation_id
  • 500 · still the error contract — a generic detail in the body, the traceback in the log only

Command

python
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)      # -> 400 with per-field errors
serializer.save(customer=request.user)         # server-controlled field, not client input

Validate with a serializer and `raise_exception=True`, so failures become a 400 with per-field messages rather than an exception. Treat the response body as a schema: adding a field is safe, removing or retyping one is not. Design the error contract deliberately and use it everywhere — one envelope, a stable machine-readable `code` for clients to branch on (never the message wording), and a correlation id. Log the exception; return a generic message.

Every collection endpoint needs the same four controls, and they should look the same on every collection in your API. **Pagination** is not optional — a list endpoint without it is a promise to return an unbounded response. **Filtering** narrows by attribute (`?status=paid`). **Sorting** orders the result (`?ordering=-created_at`). **Search** matches free text across several fields (`?search=blue`). The design work is choosing one vocabulary and applying it everywhere: if orders paginate with `?page=` and products paginate with `?offset=`, every client integrates twice. Consistency here is worth more than the individual choices.

Key features

  • The order is filter → search → sort → paginate; each stage sees only what the previous one returned
  • A list endpoint without pagination is unbounded — the cost of one request is decided by your data volume, not your code
  • Sorting must be a total order: append the primary key, or equal keys let rows drift between pages
  • One vocabulary across the API — `page`/`page_size` everywhere, or `limit`/`offset` everywhere, never a mix
  • Filtering narrows by attribute and search matches text; keeping them as separate parameters keeps both explainable and lets search be swapped for a real engine later
  • Document the default sort: without one, "the first page" means nothing, and clients depend on whatever the database happened to return

One vocabulary, applied to every collection

One vocabulary, applied to every collection
ControlParameterExampleFails when
Pagination`page`, `page_size``?page=2&page_size=50`absent — the response is unbounded
Filteringnamed per field`?status=paid&min_total=100`built from raw kwargs — every field becomes public
Sorting`ordering``?ordering=-created_at`not a total order — pages drift
Search`search``?search=blue+widget`unbounded `icontains` on a large table
The four controls, in the only order they can run

1 · Filter — narrow by attribute

Structured, declared parameters. Each one names a field and a lookup, so the accepted surface is a list you wrote.

2 · Search — match free text

One parameter across several fields. Kept separate from filtering so it can be replaced by a real search engine without changing the filter contract.

3 · Sort — impose a total order

Restricted to indexed columns, and always ending in the primary key. Without the tiebreak, equal keys let rows move between pages.

4 · Paginate — bound the response

The last stage, and the only one that limits size. count and the next link describe the filtered set, not the table.

  1. 1 · Filter — narrow by attribute — Structured, declared parameters. Each one names a field and a lookup, so the accepted surface is a list you wrote.
  2. 2 · Search — match free text — One parameter across several fields. Kept separate from filtering so it can be replaced by a real search engine without changing the filter contract.
  3. 3 · Sort — impose a total order — Restricted to indexed columns, and always ending in the primary key. Without the tiebreak, equal keys let rows move between pages.
  4. 4 · Paginate — bound the response — The last stage, and the only one that limits size. count and the next link describe the filtered set, not the table.

Command

python
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = OrderFilter
search_fields = ["reference", "customer__name"]
ordering_fields = ["created_at", "total"]
ordering = ["-created_at", "-id"]

Filter, then search, then sort, then paginate — each stage sees only what the last returned, which is why `count` describes the filtered set and why the sort decides what lands on page 2. Pagination is the only stage that bounds response size, so a list endpoint without it is unbounded by definition. Make the sort a total order by appending the primary key. And pick one vocabulary for all four and apply it to every collection: a client that has to ask which pagination an endpoint uses will get it wrong somewhere.

5.Versioning, backward compatibility, and deprecation

A public API is a promise you cannot take back unilaterally, because you do not control the clients. Versioning is how you make a breaking change anyway: `/api/v1/` and `/api/v2/` run side by side while clients migrate. DRF supports several schemes — URL path, query parameter, `Accept` header, hostname — and sets `request.version` so one view can serve both. But versioning is expensive (two code paths, two test suites, two sets of bugs), so the real skill is knowing which changes need it. Adding an optional field or a new endpoint is backward-compatible and needs nothing. Removing a field, renaming one, changing a type, or making an optional input required is breaking. Deprecation is the process in between: announce, mark responses with a `Sunset` header, measure who is still calling, and only then remove.

Key features

  • Backward-compatible: adding an optional request field, adding a response field, adding an endpoint, adding an enum value a client can ignore, relaxing a validation rule
  • Breaking: removing or renaming a response field, changing a type, making an optional input required, tightening validation, changing a status code for an existing case, changing the meaning of an existing value
  • DRF versioning schemes: `URLPathVersioning`, `NamespaceVersioning`, `QueryParameterVersioning`, `AcceptHeaderVersioning`, `HostNameVersioning` — all set `request.version`
  • `DEFAULT_VERSION` and `ALLOWED_VERSIONS` bound what clients may ask for — without `ALLOWED_VERSIONS`, any string is accepted as a version
  • RFC 8594 defines the `Sunset` header: an HTTP-date after which the endpoint may stop working, machine-readable so clients can alert on it
  • Instrument before removing — log the version and a client identifier per request, or removal is a guess

Is this change breaking?

Is this change breaking?
ChangeBreaking?Why
Add an optional request fieldnoexisting clients simply do not send it
Add a response fieldnoclients ignore fields they do not read
Remove a response field**yes**a client reading it gets `KeyError` or `undefined`
Rename a field**yes**a removal and an addition at once
Change `"total": "40.00"` to `"total": 40.0`**yes**a type change breaks parsing and rounding
Make an optional input required**yes**every existing caller starts getting 400s
Add a new enum valuedependssafe only if clients were told to ignore unknown values
Return 409 where you used to return 400**yes**clients branch on the status code
Retiring an endpoint — the order that makes removal a measurement, not a guess
  1. Day 0

    Ship v2 alongside v1

    Both versions serve traffic. Nothing is removed, so no client breaks on the day of the release.

  2. Day 0

    Announce and document

    The changelog names every breaking change and the migration for each. "Deprecated" appears in the OpenAPI document.

  3. Day 1

    Instrument

    Log the version and a client identifier on every request. Without this, every later step is guesswork.

  4. Week 1

    Add the Sunset header to v1

    Sunset: Wed, 01 Jul 2026 00:00:00 GMT — an RFC 8594 date clients can alert on automatically.

  5. Month 1–5

    Contact the clients still calling v1

    The usage data names them. This is the step that actually moves traffic; a changelog entry does not.

  6. Month 5

    Brownout

    Return 410 for a scheduled hour, twice. Anyone who missed every notice finds out while you are still watching.

  7. Sunset date

    Remove v1

    Traffic is near zero and every remaining caller is known by name. The removal is a formality.

  1. Day 0: Ship v2 alongside v1 — Both versions serve traffic. Nothing is removed, so no client breaks on the day of the release.
  2. Day 0: Announce and document — The changelog names every breaking change and the migration for each. "Deprecated" appears in the OpenAPI document.
  3. Day 1: Instrument — Log the version and a client identifier on every request. Without this, every later step is guesswork.
  4. Week 1: Add the Sunset header to v1 — Sunset: Wed, 01 Jul 2026 00:00:00 GMT — an RFC 8594 date clients can alert on automatically.
  5. Month 1–5: Contact the clients still calling v1 — The usage data names them. This is the step that actually moves traffic; a changelog entry does not.
  6. Month 5: Brownout — Return 410 for a scheduled hour, twice. Anyone who missed every notice finds out while you are still watching.
  7. Sunset date: Remove v1 — Traffic is near zero and every remaining caller is known by name. The removal is a formality.

Command

python
urlpatterns = [path("api/<version>/", include("orders.urls"))]

def get_serializer_class(self):
    return OrderSerializerV2 if self.request.version == "v2" else OrderSerializerV1

Adding is safe and removing is not, because clients ignore unknown fields and crash on missing ones — so most changes need no version at all. Spend a version number rarely and batch breaking changes into it; keep the boundary at the serializer so both versions share their logic. Deprecate as a measured process: announce, instrument per version and per client, send `Sunset` and `Deprecation` headers, contact the callers your data names, brownout, then remove. "Nobody uses it" is not a fact until you have counted.

Remember

  • A non-CRUD operation is either its own sub-resource (POST /orders/57/refunds/, which gains an id and a list endpoint) or a named sub-action — never a verb on the collection URL.
  • A repeated DELETE returns the same success code, not 404: the state is identical, so the response must be, or an automatic retry reports a failure for work that succeeded.
  • Design the error contract first and use it everywhere — one envelope, a stable machine-readable code clients branch on, and a correlation id instead of a traceback.
  • Sorting must be a total order, ending in the primary key, or two items with equal sort keys drift between pages.

Pro tip

  • Keep the version boundary at the serializer: v1 and v2 then share permissions, filtering, and business logic, which is the difference between two serializers and two products.
  • Instrument per version and per client before announcing a deprecation — "nobody uses it" is not a fact until you have counted, and the only other way to find the users is to break them.

Next up

  • API Idempotency
  • API Error Handling
  • OpenAPI and API Documentation

Sources

  • RFC 9110 HTTP Semantics — rfc-editor.org/rfc/rfc9110.html
  • DRF Versioning — django-rest-framework.org/api-guide/versioning/
  • Every claim checked against RFC 9110 and Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.