Intuition What this page is
The parent note taught the rules of REST design . This page runs the rules through every kind of situation you can meet: each HTTP method, each status-code class, both pagination styles, versioning, and the nasty edge cases (empty results, retries after timeout, live data shifting, deep offsets). If a real API can throw it at you, there's a worked cell for it below.
Before we start, one word we'll lean on constantly:
Definition "Case class" (why a matrix?)
A case class is one kind of situation, not one specific request. "Create something new" is a class; "create a user" and "create an order" are two instances of it. If we work one example per class, we've covered all instances by pattern. The matrix below is just a checklist so we never leave a class untested.
Every REST interaction lands in exactly one of these cells. Read it as: what is the client trying to do , and what unusual condition makes the answer non-obvious.
#
Case class
The tricky condition
Worked in
A
Create (server assigns id)
success path, Location header
Ex 1
B
Create — duplicate
resource already exists (conflict)
Ex 2
C
Partial update
change one field, don't wipe others
Ex 3
D
Full replace vs partial
omitted field gets defaulted
Ex 3
E
Delete, then retry
idempotency after a timeout
Ex 4
F
Auth failures
who-are-you vs you-can't (401 vs 403)
Ex 5
G
Validation
malformed (400) vs semantically wrong (422)
Ex 6
H
Pagination — offset, normal
page → offset arithmetic
Ex 7
I
Pagination — empty / last partial / invalid page
remainder rows, zero results, page 0
Ex 7
J
Pagination — live data
inserts shift rows → cursor fix
Ex 8
K
Pagination — deep offset
performance limit, jump vs stream
Ex 8
L
Versioning
breaking change forces /v2
Ex 9
The 9 examples below hit all 12 cells (some examples cover two related cells).
Worked example Ex 1 — Create a resource
(cell A)
Statement: A client sends POST /v1/users with body {"name":"Ada","email":"ada@x.com"} to a fresh database. What status, headers, and body come back?
Forecast: guess the status code and one header before reading on.
Pick the method. The client does not know the new id yet — the server will mint it. Why this step? POST is the "create where the server assigns identity" verb; PUT would need the client to already own the URI.
Server creates row, assigns id = 42. Why? Identity is the server's job here, so the URI /v1/users/42 only exists after creation.
Return 201 Created. Why not 200? 200 means "here's the thing you asked about"; 201 specifically means "a new resource now exists" — a different machine-readable fact.
Add header Location: /v1/users/42. Why? The client must learn the new address to fetch/update it later. Without it, the client is stuck.
Body = the full new resource including its id. Why return the body? The client just sent only name and email; the server filled in everything else (the id, plus any defaults like createdAt). Echoing the full resource saves the client a second round-trip GET to discover those server-generated fields.
Verify: Re-GET /v1/users/42 returns the same object → the Location really points somewhere. Status 201 is in the 2xx (success) class ✓. Offset arithmetic not involved.
Worked example Ex 2 — Create when it already exists
(cell B)
Statement: Same POST /v1/users with {"email":"ada@x.com"}, but a user with that email already exists (email is unique).
Forecast: which 4xx code? (400? 404? 409? 422?)
Is the request malformed? No — the JSON is valid and complete. Why this step? Rules out 400.
Is the URI missing? No — /v1/users exists. Why? Rules out 404.
The request clashes with current server state (a uniqueness rule). Why this matters: that is the exact definition of 409 Conflict — the request is fine in isolation but conflicts with what already exists.
Return 409 Conflict with a body explaining which field clashed.
Verify: 409 is 4xx → the client's fault, correct (they tried to duplicate). Retrying the identical POST still gives 409 — no double row is created, so state is consistent ✓.
Worked example Ex 3 — Partial update vs full replace
(cells C, D)
Statement: User 42 is {"name":"Ada","email":"ada@x.com"}. Client wants only to change the email. Compare PATCH vs PUT with body {"email":"ada@new.com"}.
Forecast: after each request, what does name become?
PATCH /v1/users/42 {"email":"ada@new.com"}. Why PATCH? PATCH merges — fields you omit stay untouched. Result: {"name":"Ada","email":"ada@new.com"}. Status 200 OK.
PUT /v1/users/42 {"email":"ada@new.com"}. Why the danger? PUT replaces the whole resource . Omitted name gets wiped/defaulted → {"name":null,"email":"ada@new.com"}. Status 200 OK.
So the rule: partial change → PATCH; wholesale replacement → PUT with every field present .
Verify: After PATCH, name == "Ada" (preserved). After the bad PUT, name == null (lost). Both return a 2xx. If ada@new.com already belongs to another user → 409 (same logic as Ex 2) ✓.
Worked example Ex 4 — Delete, then a retry after a timeout
(cell E)
Statement: DELETE /v1/users/42 succeeds but the response is lost to a network hiccup. The client, seeing no reply, retries the exact same DELETE.
Forecast: does the second call damage anything? What status does it return?
First DELETE → 204 No Content. Why 204? Deletion succeeded and there's nothing meaningful to send back.
Client never sees it (timeout), so it retries. Why is this safe? DELETE is idempotent — the final state ("user 42 gone") is identical whether we delete once or twice.
Second DELETE → return 204 No Content again (recommended) or 404 Not Found. Why prefer 204 here? Idempotency is about the client's experience of the outcome : the client asked for "user 42 gone" and it is gone, so reporting success (204) on the retry keeps a retried request from looking like a failure — the retry did exactly what was intended. Some teams instead return 404 because, strictly, the resource is no longer there; both are defensible, but 204 matches the idempotent-success story better. Crucially, either way it never deletes some other resource.
Verify: Number of users deleted = 1, no matter how many identical DELETEs arrive. Returning 200 {ok:false} would be wrong because monitoring can't branch on it ✓.
Worked example Ex 5 — Auth failures: 401 vs 403
(cell F)
Statement: Two requests to DELETE /v1/users/42. (a) No token at all. (b) A valid token, but the caller is a normal user trying to delete someone else's account.
Forecast: assign a code to each.
(a) No token. The server can't tell who is calling. Why this decides it: authentication is the "who are you?" check, and it failed → 401 Unauthorized. Fix: log in / send a token.
Attach the required header. A 401 response must include a WWW-Authenticate header (e.g. WWW-Authenticate: Bearer). Why is this mandatory? Per RFC 7235, 401 is a challenge : the header tells the client which authentication scheme to use to try again. Without it the client knows it's unauthenticated but not how to fix it, so a compliant 401 always carries this header.
(b) Valid token, wrong permissions. The server knows who you are, but you're not allowed. Why not 401? Identity is fine; it's authorization that failed → 403 Forbidden. Logging in again won't help, so 403 sends no WWW-Authenticate challenge.
Verify: 401 fixable by re-authenticating and carries WWW-Authenticate; 403 not fixable by re-auth and carries no challenge. Both are 4xx (client's fault). Returning 200 {error:...} for either would let a broken client believe the delete worked ✓.
Worked example Ex 6 — Validation: 400 vs 422
(cell G)
Statement: POST /v1/users. (a) Body is {"name":"Ada", "email": — truncated, invalid JSON. (b) Body is valid JSON {"name":"Ada","email":"not-an-email","age":-5}.
Forecast: one is 400, one is 422 — which is which?
(a) Broken JSON. The server can't even parse it. Why 400? 400 Bad Request = the request is malformed at the syntax level; there's nothing to process.
(b) Valid JSON, impossible values (email isn't an email, age negative). Why 422? 422 Unprocessable Entity = syntactically fine but semantically invalid — the server understood the request and rejects its meaning .
Rule of thumb: can't parse → 400; parsed but fails business rules → 422 (some APIs use 400 for both, but 422 is more precise).
Verify: Both are 4xx. 400 and 422 differ by whether the payload parsed; case (a) fails parse, case (b) passes parse then fails rules ✓.
Worked example Ex 7 — Offset pagination: normal, last, empty, and invalid pages
(cells H, I)
Statement: A collection has N = 23 users, page size L = 10 . Compute (a) the offset for page 3, (b) the rows page 3 returns, (c) the total number of pages, (d) what page 4 returns, (e) what to do with an invalid page like p = 0 or p = − 2 .
Forecast: guess how many rows page 3 has (10? or fewer?), whether page 4 exists, and what a request for "page 0" should get.
Offset for page p : pages are 1-indexed, page 1 starts at row 0, so
offset ( p ) = ( p − 1 ) ⋅ L .
Page 3: offset ( 3 ) = ( 3 − 1 ) × 10 = 20 . Why ( p − 1 ) ? Page 1 must start at 0, not L ; the "− 1 " shifts the count so the first page skips nothing.
Rows for page 3: indices [ 20 , 30 ) , but only rows 20 , 21 , 22 exist (N = 23 ). So page 3 returns 3 rows — a partial last page. Why this is a case class: the last page usually isn't full; clients must not assume "always L rows."
Total pages. Let pages be the total number of pages needed to cover all N items. We must round up to catch the leftover 3 rows:
pages = ⌈ L N ⌉ = ⌈ 10 23 ⌉ = 3.
Here ⌈ x ⌉ ("ceiling") means "round x up to the next whole number." Why ceil not floor? ⌊ 23/10 ⌋ = 2 would silently drop rows 20–22.
Page 4: offset = ( 4 − 1 ) × 10 = 30 ≥ N . No rows exist there → return 200 OK with an empty list [], not 404. Why 200 not 404? The collection endpoint exists; it just has no items in that window — an empty page is a valid success.
Invalid page (p ≤ 0 ): a request for page 0 or a negative page is not an empty window — it's a nonsensical input. Why 400 not 200? offset(0) = -10 is a garbage offset the DB can't honour; the request is malformed by the API's own rules, so return 400 Bad Request (some APIs instead clamp to page 1 or return 404, but 400 is the clearest "you asked for something impossible"). Distinguish this from cell (d): page 4 is valid but empty ; page 0 is invalid .
Figure — read the coloured grid below. Each little box is one row index. Blue boxes are page 1 (rows 0–9), orange is page 2 (rows 10–19), green is page 3 — notice green stops at row 22, so it holds only 3 boxes, our partial last page. The red dashed boxes on the right mark page 4's requested window (offset 30): they're empty outlines because no rows live there, which is exactly why page 4 returns 200 with []. The gray arrow shows how offset(3)=20 lands you at the start of the green block.
Verify: offset(3)=20, page-3 rows = {20,21,22} (count 3), pages=3, page 4 empty, page 0 → invalid offset − 10 . All checked in VERIFY ✓.
Worked example Ex 8 — Live data & deep offsets → cursor pagination
(cells J, K)
Statement: A feed sorted by ascending id. A client reads page 1 (offset=0, limit=3) and gets ids [10,11,12]. Before page 2, someone inserts a new row with a smaller sort position (imagine a new item with id=9 lands at the front). Then page 2 (offset=3, limit=3) is requested. What goes wrong, and what fixes it?
Forecast: will the client see a duplicate, or skip an item?
What offset does: it says "skip 3 rows, give the next 3" — but which 3 are "the first 3" is recomputed every request. Why this is fragile: an insert at the top pushes every later row down by one index.
The bug: with 9 now at the front the list is [9,10,11,12,13]; page 2 starts at index 3, which is now id=12 — an item the client already saw on page 1. So page 2 repeats 12. (An insert lower down would instead skip an item.)
The cursor fix: instead of "skip N", encode the last item seen . Page 1 ends at id=12, so page 2 asks GET /users?limit=3&after=12, translating to
WHERE id > 12 ORDER BY id LIMIT 3 .
Why this is stable: the query is anchored to a value , not a position. New inserts elsewhere can't shift what "greater than 12" means — page 2 returns [13], no duplicate.
Deep-offset performance (cell K): offset=1000000 forces the DB to scan and discard a million rows every time. The cursor's WHERE id > :cursor uses the index to jump straight to the spot — fast at any depth. Trade-off: cursors can't jump to "page 137" directly; offset can.
Figure — compare the two columns below. On the left (offset), the first column is the list before the insert with page 1 painted blue (10,11,12); the second column is the list after inserting 9, and the box the red arrow points to (12) is what page 2 now serves — a duplicate , shown in red. On the right (cursor), the orange dashed line marks the anchor after=12; only the box above the line (13, green) is returned, so no already-seen row can reappear no matter what got inserted below.
Verify: Simulate the offset path: base ids [10,11,12,13], page 1 = [10,11,12]; insert 9 at front → list [9,10,11,12,13], page 2 (offset 3) = [12,13], so 12 is a duplicate of page 1 ✓ (bug reproduced). Now the cursor path: after=12 → [x for x in sorted(list) if x>12][:3] = [13], so no id the client already saw can reappear ✓ (bug fixed). Deep-offset check: offset=1_000_000 must scan 1,000,000 discarded rows, whereas WHERE id > cursor on an index touches only the 3 rows returned — a scan cost of 1 0 6 vs 3 , so cursor wins on performance ✓.
Worked example Ex 9 — A breaking change forces a new version
(cell L)
Statement: GET /v1/users/42 returns {"id":42,"name":"Ada Lovelace"}. Product wants to split name into firstName and lastName and remove name. Old mobile apps still read name. What's the correct API move?
Forecast: additive change or breaking? New version needed?
Classify the change. Removing/renaming a field = a breaking change . Why it matters: thousands of old clients read .name; deleting it makes them crash. You can't force-upgrade them overnight.
Additive escape hatch first. Could you add firstName/lastName while keeping name? If yes, that's non-breaking → no version bump. Why prefer this: additive changes let old and new clients coexist on /v1. (See API versioning and backward compatibility .)
If you must remove name, introduce /v2/users/42 returning the new shape; keep /v1 serving the old shape for old clients. Why URI versioning here: it's the most visible and easiest to route/cache (Caching and ETags keys stay clean).
Deprecate /v1 on a timeline once traffic drains.
Verify: /v1 response still contains name (old clients OK); /v2 contains firstName+lastName and no name. The two shapes differ exactly on the removed/renamed field — the definition of breaking ✓.
Recall Quick self-test
Page size 10, 23 items — how many pages? ::: ⌈ 23/10 ⌉ = 3 (the last page holds only 3 rows).
Offset for page 5 at size 20? ::: ( 5 − 1 ) × 20 = 80 .
A request for page 0 — what status? ::: 400 Bad Request (invalid input; offset would be negative), not an empty 200.
Timed-out DELETE retried — safe? ::: Yes, DELETE is idempotent; final state is identical, and 204 on the retry reports that success.
Which header must a 401 carry? ::: WWW-Authenticate (RFC 7235) — it tells the client which auth scheme to use.
Valid JSON but age=-5 — which code? ::: 422 Unprocessable Entity (parsed, but semantically invalid).
No token vs wrong permission? ::: 401 (unauthenticated) vs 403 (forbidden).
Empty last page — 404 or 200? ::: 200 with an empty list; the collection exists.
Mnemonic Status-code by first digit
2 = "yes", 3 = "go elsewhere", 4 = "you messed up", 5 = "I messed up".