Filter concepts by levelShowing all levels.

Django · Section 52

API Design

Level
advanced
Read
40 min
Concepts
5

REST organises an API around resources rather than actions: the URL names a thing and the method says what to do with it, which is why `POST /orders/create/` states the verb twice and `POST /orders/` does not. Collections are plural, identifiers live in the path, query parameters only narrow, and an operation that is not CRUD becomes either its own sub-resource (`POST /orders/57/refunds/`, which gains an id, a history and a list endpoint) or a named sub-action. HTTP methods then carry two contractual promises: safe means state does not change, and idempotent means twice is the same as once — and infrastructure you do not control acts on both, retrying `PUT` and `DELETE` without asking, which is why a repeated `DELETE` must return the same success code rather than a 404. Status codes are the machine-readable outcome and the body is decoration; a 200 wrapping an error is invisible to every cache, proxy, monitor and client library in the path. Three contracts govern the payloads — request validation (a serializer with `raise_exception=True`), the response schema (adding a field is safe, removing one is not), and the error envelope, which should be one shape everywhere with a stable `code` clients branch on and a correlation id instead of a traceback. Collections need the same four controls in a fixed order — filter, search, sort, paginate — with a total ordering so pages stay stable and one vocabulary across every endpoint. Versioning is the escape hatch when the add-is-safe asymmetry cannot save you: keep the boundary at the serializer, batch breaking changes, and deprecate by measurement — announce, instrument, `Sunset` header, contact, brownout, remove.

What is true here

  1. The URL names a resource and the method names the operation; non-CRUD actions become sub-resources, not verbs on the collection.
  2. Safe and idempotent are promises to infrastructure that retries on your behalf — a repeated DELETE returns success, not 404.
  3. The status code is the outcome; a 200 containing an error is invisible to caches, monitoring, and every HTTP client library.
  4. One error envelope everywhere, with a stable code to branch on and a correlation id instead of exception text.
  5. Adding a field is backward-compatible and removing one is not — version only when the asymmetry cannot save you.

What you will be able to do

  • Design a resource-oriented URL space, including the operations that are not CRUD
  • Choose methods and status codes that honour the contracts clients and proxies already rely on
  • Define request, response, and error contracts that a client can implement once
  • Give every collection the same four controls, in the order that keeps pages stable
  • Tell a breaking change from a compatible one, and run a deprecation as a measurement rather than an announcement
Every part of a well-formed API request, and what each one is for

GET /api/v1/orders/57/items/?status=paid&page=2

GET

the verb — safe and idempotent — The operation lives here, never in the path. Being safe is what lets a cache serve it and a client retry it freely.

/api/v1

the version boundary — Visible in logs, cacheable, trivially routable. Spend a version rarely and batch breaking changes into it.

/orders

the collection — plural noun — Names a thing, not an action. A DRF router generates this and the detail route from one ViewSet registration.

/57

the identifier, in the path — In the path, not the body, so get_object() and has_object_permission() both see it. Moving it to the body skips them.

/items/

one level of nesting, for containment — "The items of order 57" — a real thing. A second level produces URLs clients construct incorrectly.

?status=paid

a filter — narrows, never widens — Runs after get_queryset(), so it can only shrink what authorization already permitted. Declared in a FilterSet, not built from raw kwargs.

&page=2

pagination — the only bound on response size — Last in the pipeline. Which rows land here depends on the sort, which is why the ordering must be total.

  • Whole: GET /api/v1/orders/57/items/?status=paid&page=2
  • GET — the verb — safe and idempotent: The operation lives here, never in the path. Being safe is what lets a cache serve it and a client retry it freely.
  • /api/v1 — the version boundary: Visible in logs, cacheable, trivially routable. Spend a version rarely and batch breaking changes into it.
  • /orders — the collection — plural noun: Names a thing, not an action. A DRF router generates this and the detail route from one ViewSet registration.
  • /57 — the identifier, in the path: In the path, not the body, so get_object() and has_object_permission() both see it. Moving it to the body skips them.
  • /items/ — one level of nesting, for containment: "The items of order 57" — a real thing. A second level produces URLs clients construct incorrectly.
  • ?status=paid — a filter — narrows, never widens: Runs after get_queryset(), so it can only shrink what authorization already permitted. Declared in a FilterSet, not built from raw kwargs.
  • &page=2 — pagination — the only bound on response size: Last in the pipeline. Which rows land here depends on the sort, which is why the ordering must be total.

Resources and URLs

Organising an API around things rather than actions, including the operations that are not CRUD.

REST principles and resource-oriented URLs

coreintermediate

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.

Think of it as

The test for a good REST URL is whether you can read it out loud as a noun phrase. `/orders/57/items/` is "the items of order 57" — a thing that exists. `/getOrderItems?orderId=57` is a function call spelled with slashes; it works, but the client now has to learn a vocabulary of function names instead of a shape. That distinction is not aesthetic: a noun-shaped URL space is enumerable and cacheable, and a router can generate it, which is precisely why DRF ships routers and not an RPC dispatcher. The place people get stuck is the operation that genuinely is not CRUD — refund an order, publish an article, retry a job. Two honest answers exist, and inventing a verb in the collection URL is neither of them: either the action *is* a resource in its own right (`POST /orders/57/refunds/` creates a refund, which is a real thing with an id and a history), or it is a sub-action on the resource (`POST /orders/57/refund/`, DRF's `@action`). Prefer the first when the operation produces something you would want to list later; it turns "what happened to this order" from a log grep into a `GET`.

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

What we're doing: Model a refund as a sub-resource so it has its own identity, listing, and history — rather than as a verb.

orders/urls.py + orders/views.pypython
router = DefaultRouter()
router.register("orders", OrderViewSet, basename="order")

orders_router = NestedSimpleRouter(router, "orders", lookup="order")
orders_router.register("refunds", RefundViewSet, basename="order-refund")
# POST /orders/57/refunds/   -> create a refund for order 57
# GET  /orders/57/refunds/   -> every refund ever issued on order 57


class RefundViewSet(mixins.CreateModelMixin,
                    mixins.ListModelMixin,
                    viewsets.GenericViewSet):
    serializer_class = RefundSerializer
    permission_classes = [IsAuthenticated & IsAdminUser]

    def get_queryset(self):
        return Refund.objects.filter(order_id=self.kwargs["order_pk"])

    def perform_create(self, serializer):
        order = get_object_or_404(self.get_order_queryset(), pk=self.kwargs["order_pk"])
        serializer.save(order=order, issued_by=self.request.user)
4–5
Nesting one level expresses containment: a refund belongs to exactly one order and has no meaning without it. Going deeper would produce URLs clients get wrong.
10–12
Only create and list — a refund is not editable or deletable, and leaving those mixins out is how the URL space states that rather than a comment.
17
Scoping to the parent id from `kwargs` is what makes the nesting real: `/orders/57/refunds/` cannot return refunds belonging to order 58.
19–21
The order and the issuing user are set server-side in `perform_create()`, never accepted from the request body — the same rule as any server-controlled field.

Why this works: Modelling the operation as a resource gives it an id, a timestamp, an author, and a list endpoint for free. "Refund this order" as a verb would have produced the same database row and no way to ask what refunds exist.

Putting an action in the collection URL

Wrong

http
POST /orders/refund/
{"order_id": 57, "amount": "40.00"}

Better

http
POST /orders/57/refunds/
{"amount": "40.00"}

What you see: Routing conflicts appear first: `/orders/refund/` and `/orders/{pk}/` overlap, so a real order whose lookup value is `refund` — a slug, a reference code — becomes unreachable. Then the authorization gap: the object id arrives in the body, so `get_object()` and `has_object_permission()` never run.

Why: A collection URL addresses the whole collection, so anything appended to it is read by the router as an identifier. Beyond the routing collision, moving the id from the path into the body takes it out of the path where every permission, filter and object lookup expects it — which is why the second form is not just tidier but is the one where the framework's own object-level checks still apply.

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.

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/`

Together

http
GET    /api/v1/orders/
POST   /api/v1/orders/
GET    /api/v1/orders/57/
PATCH  /api/v1/orders/57/
DELETE /api/v1/orders/57/

Remember: 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.

See also: http semantics and status codes · versioning backward compatibility and deprecation · viewsets and routers · versioned and named conventions

Advertisement

HTTP semantics and status codes

Safe, idempotent, and the codes that tell a client what to do next without reading prose.

HTTP semantics, status codes, and method idempotency

coreintermediate

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.

Think of it as

Treat safe and idempotent as promises you make to infrastructure you will never see. A CDN caches `GET`s, a mobile HTTP client retries a `PUT` after a timeout, a service mesh retries a `DELETE` on a connection reset — none of them ask permission, because the method already granted it. So the question is never "is my handler idempotent?" but "what happens when this is delivered twice?", and for `PUT` and `DELETE` the answer must be "the same thing". The classic slip is `DELETE` returning 404 on the second call: the state after two deletes is identical to the state after one, so the *method* is idempotent, but the *response* is not — and a client that retries after a timeout now reports a failure for an operation that succeeded. Idempotency at this level is only about the methods, and it is why `POST` needs the separate, application-level mechanism of idempotency keys. On status codes, the discipline is that the code is the API and the message is decoration: a client branches on 409 versus 422, never on the wording of `detail`, so choosing a precise code is choosing the behaviour every client will implement.

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)

What we're doing: Honour the method contracts: an idempotent DELETE, a 201 that names what it created, and precise codes for the two failure shapes.

orders/views.pypython
class Conflict(APIException):
    status_code = 409                       # DRF ships no 409 exception — define one
    default_detail = "Conflicts with the current state of the resource."
    default_code = "conflict"


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)          # 400 with per-field errors
        order = serializer.save(customer=request.user)
        headers = {"Location": reverse("order-detail", args=[order.pk])}
        return Response(serializer.data, status=201, headers=headers)

    def destroy(self, request, *args, **kwargs):
        self.get_queryset().filter(pk=kwargs["pk"]).delete()
        # Idempotent: already gone is still success.
        return Response(status=204)

    @action(detail=True, methods=["post"])
    def cancel(self, request, pk=None):
        order = self.get_object()
        if order.status == Order.Status.SHIPPED:
            raise Conflict("Order has shipped and cannot be cancelled.")        # 409
        if order.total > request.user.cancellation_limit:
            raise ValidationError({"detail": "Above your cancellation limit."})  # 400
        order.cancel()
        return Response(OrderSerializer(order).data, status=200)
1–4
DRF ships no 409 exception, so the project defines one. Everything downstream — the exception handler, the error contract, the OpenAPI document — then treats it like any other `APIException`.
14–15
201 plus a `Location` header. A client that only wants the URL of what it created does not have to parse the body, and the pair is what the spec defines for creation.
18–20
Filtering and deleting, rather than `get_object()` then delete, keeps the second call a 204. The state is identical either way, so the response should be too.
25–26
409, because the request is perfectly valid and simply conflicts with the order's current state. A client can retry a 400 after fixing its input; a 409 tells it not to bother.
27–28
400, because this one is about the request's contents against a rule — a different remedy, so a different code.

Why this works: Each code here tells the client what to do next without reading prose: retry after fixing input (400), do not retry (409), the resource is at this URL (201 + Location), it is gone (204). That is the entire purpose of the status line.

`DELETE` returning 404 the second time

Wrong

python
def destroy(self, request, *args, **kwargs):
    order = self.get_object()      # raises Http404 if already deleted
    order.delete()
    return Response(status=204)

Better

python
def destroy(self, request, *args, **kwargs):
    self.get_queryset().filter(pk=kwargs["pk"]).delete()
    return Response(status=204)

What you see: A mobile client deletes an order, the response is lost to a dropped connection, the HTTP library retries as it is entitled to for an idempotent method — and the user sees "Delete failed" for an order that is already gone.

Why: RFC 9110 defines idempotence over the *effect on the server*, and two deletes leave the same state as one, so the method qualifies. But clients and proxies act on the promise by retrying, and they judge the outcome by the status code. Returning 404 on the retry turns a successful operation into a reported failure. Deleting through a filtered queryset rather than `get_object()` makes the response match the semantics the method already advertises.

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

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

Together

http
POST /orders/            201 Created   Location: /orders/57/
PUT /orders/57/          200 OK        (full replacement)
DELETE /orders/57/       204 No Content
DELETE /orders/57/       204 No Content   <- same code the second time

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

Together

python
raise ValidationError({"quantity": "Exceeds available stock."})   # 400
raise PermissionDenied("Not your order.")                        # 403
raise Throttled(wait=42)                                         # 429 + Retry-After

Remember: 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.

See also: rest principles and resource oriented urls · request validation response schemas and error contracts · idempotency keys and http semantics · the client error status codes

Advertisement

The three payload contracts

Request validation, response schemas, and the error envelope every failure shares.

Request validation, response schemas, and error contracts

coreintermediate

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.

Think of it as

A client can only be as reliable as the least predictable part of your response. If successes are a documented shape and errors are whatever exception happened to escape, then every client is one unhandled 500 body away from a crash in its own error handler. So design the error contract first and deliberately: pick the shape, make everything conform, and never let it vary by endpoint. The key field is a stable `code` — a short string like `out_of_stock` that never changes — because everything else is negotiable. Status codes are coarse (many things are 400), and `detail` messages get reworded, translated, and improved; a client that branches on wording breaks the day someone fixes a typo. The other half of the discipline is not leaking: an error body should say what the caller can act on and nothing about how the server is built. RFC 9457 ("Problem Details") is the standardised version of all this, and adopting it — or something shaped like it — costs one exception handler and removes an entire category of client-side special cases.

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

What we're doing: Give every error one envelope, with a stable code and a correlation id, without touching a single view.

common/exceptions.py + config/settings.pypython
def problem_detail_handler(exc, context):
    response = exception_handler(exc, context)          # DRF's default first
    correlation_id = context["request"].headers.get("X-Correlation-Id") or str(uuid4())

    if response is None:                                 # an unhandled exception
        logger.exception("unhandled", extra={"correlation_id": correlation_id})
        return Response(
            {"type": "internal_error",
             "detail": "An unexpected error occurred.",
             "correlation_id": correlation_id},
            status=500,
        )

    body = {
        "type": getattr(exc, "default_code", "error"),
        "detail": str(getattr(exc, "detail", response.data)),
        "correlation_id": correlation_id,
    }
    if isinstance(exc, ValidationError):
        body["type"] = "validation_error"
        body["detail"] = "The request body failed validation."
        body["errors"] = exc.detail                      # the per-field mapping

    response.data = body
    return response


REST_FRAMEWORK = {"EXCEPTION_HANDLER": "common.exceptions.problem_detail_handler"}
2
Delegating to DRF's handler first keeps every status code it already gets right — 401, 403, 404, 405, 429 — and reshapes only the body.
5–12
`None` from the default handler means DRF did not recognise the exception, so it would have become a 500 with a traceback. Logging it here with the correlation id is what makes it findable later.
15
`default_code` is the stable string clients branch on — `throttled`, `permission_denied`, `not_found` — and it survives every rewording of the message.
19–22
Validation errors keep their per-field mapping under `errors`, so a form can highlight the right input while the envelope stays identical to every other failure.
28
One setting, project-wide. Nothing in any view changes, and no endpoint can accidentally emit a different error shape.

Why this works: A client writes error handling once against a fixed envelope; support can find the exact log line from the id in a screenshot; and the traceback that used to reach the caller now exists only in the log.

Letting each endpoint invent its own error shape

Wrong

json
POST /orders/     400  {"quantity": ["Exceeds available stock."]}
POST /payments/   400  {"error": "card declined"}
POST /exports/    400  {"success": false, "messages": ["too many rows"]}

Better

json
POST /orders/     400  {"type": "validation_error", "detail": "...", "errors": {"quantity": [...]}}
POST /payments/   402  {"type": "card_declined", "detail": "..."}
POST /exports/    400  {"type": "export_too_large", "detail": "...", "max_rows": 100000}

What you see: The client accumulates a branch per endpoint — check `errors`, then `error`, then `messages` — and each new endpoint adds another. The first shape the client does not recognise surfaces to the user as "undefined".

Why: Error handling is the code paths clients exercise least and depend on most. Every extra shape multiplies the ways a client can fail while already failing. One envelope with a stable `code` collapses that to a single parser, and a `code` — rather than a message — is what survives translation and rewording.

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

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

Together

json
{"type": "validation_error",
 "detail": "The request body failed validation.",
 "errors": {"quantity": ["Exceeds available stock."]},
 "correlation_id": "01J9F2M0Q5X8"}

Remember: 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.

See also: http semantics and status codes · global exception handlers and stable error payloads · serializer validation

Advertisement

Collection controls

Pagination, filtering, sorting and search — one vocabulary, in one fixed order.

Advertisement

Evolving an API

Which changes break clients, when to spend a version number, and how to retire an endpoint safely.

Versioning, backward compatibility, and deprecation

coreadvanced

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.

Think of it as

The asymmetry that governs everything here: adding is safe, removing is not, because a client ignores fields it does not know about and crashes on ones that disappear. That single rule resolves most design arguments — an unwanted field can be documented as deprecated and left in place for a year at almost no cost, while removing it on a Tuesday breaks integrations you have never heard of. Version numbers are what you reach for when the asymmetry cannot save you: the shape has to change incompatibly, so both shapes exist for a while. Because that is genuinely expensive, treat a new version as a budget you spend rarely and deliberately — batch breaking changes into one v2 rather than trickling them out. And make deprecation a measured process rather than an announcement: you cannot know it is safe to remove something unless you are counting calls to it, per client, over time. The order is always announce → instrument → wait → remove, and the waiting is not politeness, it is the period during which your usage data tells you whether removal is safe.

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

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

What we're doing: Run two versions from one ViewSet, and mark the old one as sunsetting in a way clients can detect automatically.

config/settings.py + orders/views.pypython
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}

SUNSET_V1 = "Wed, 01 Jul 2026 00:00:00 GMT"


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

    def finalize_response(self, request, response, *args, **kwargs):
        response = super().finalize_response(request, response, *args, **kwargs)
        if request.version == "v1":
            response["Sunset"] = SUNSET_V1
            response["Deprecation"] = "true"
            response["Link"] = '</docs/migrating-to-v2>; rel="deprecation"'
        return response

    def get_queryset(self):
        return Order.objects.filter(customer=self.request.user)
4
`ALLOWED_VERSIONS` is what makes an unknown version a 404 instead of silently falling through to the default — without it, `/api/v9/` quietly serves v1.
12
One view, two serializers. The version selects the *shape*, and everything else — permissions, filtering, queryset — stays shared, which is what keeps two versions affordable.
17–19
`Sunset` is an RFC 8594 HTTP-date; `Deprecation` and a `Link` to the migration guide make the notice actionable by a machine rather than only by a human reading a changelog.
22–23
Business logic stays version-agnostic. The moment a version leaks into `get_queryset()` or a permission class, you have two applications rather than two serializers.

Why this works: Keeping the version boundary at the serializer means v1 and v2 share every behaviour that is not a response-shape difference — one bug fix, one authorization rule, one set of tests for the logic — which is the difference between maintaining two versions and maintaining two products.

Removing a field because "nobody uses it"

Wrong

diff
class OrderSerializer(ModelSerializer):
     class Meta:
         model = Order
-        fields = ["id", "reference", "legacy_code", "total", "status"]
+        fields = ["id", "reference", "total", "status"]
  # Shipped in a patch release. No version bump, no notice.

Better

python
# Keep it, and say so in the schema:
legacy_code = serializers.CharField(
    read_only=True,
    help_text="Deprecated; removed in v2. Use `reference`.",
)
# Then instrument, wait, and remove it in v2 on the sunset date.

What you see: A batch integration you did not know existed starts failing at 02:00, and the error is on the client side — a `KeyError` in someone else's code — so nothing in your monitoring shows it. You learn about it from a support ticket days later.

Why: "Nobody uses it" is a claim about traffic you have not measured. Response fields are read by clients you cannot enumerate: scripts, spreadsheets, partner integrations, a mobile app version still installed on old phones. Since keeping a field costs almost nothing and removing one is unrecoverable for the caller, the asymmetry says keep it, mark it deprecated in the schema, count reads if you can, and remove it at a version boundary you announced.

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.

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

Together

python
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}

Remember: 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.

See also: request validation response schemas and error contracts · rest principles and resource oriented urls · versioned and named conventions · openapi and schema generation

Advertisement