C.W.K.
Stream
Lesson 06 of 06 · published

Method Decision Table — Pick the Verb That Matches the Contract

~10 min · semantics, methods, decision, synthesis

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Stop translating English verbs into HTTP methods. 'Create' isn't always POST; 'update' isn't always PUT. The choice is driven by the three invariants — safety, idempotency, cacheability — not by the dictionary."

The Decision Tree

For any operation you're designing, walk this tree top-to-bottom:

1. Will this change any observable server state?
   NO  → use GET (or HEAD if you only need headers, OPTIONS to discover methods)
   YES → continue to 2

2. Does the client know the exact target URI?
   NO  → use POST (server assigns the URI; returns 201 + Location)
   YES → continue to 3

3. Are you sending the FULL representation of the resource?
   YES → use PUT (idempotent: same payload → same state)
   NO  → use PATCH (a partial update; carries a diff/delta)

4. Removing the resource entirely?
   → use DELETE

5. Is this an operation with no obvious target resource (a 'command' style)?
   → use POST on an action-named URI (e.g. POST /charges/42/refund)

The Method/Outcome Matrix

Most operations fit one of these patterns:

IntentMethodURI shapeStatus code on success
Read a resourceGET/users/42200 OK (or 304 if cached)
List resourcesGET/users?role=admin200 OK
Test existence cheaplyHEAD/users/42200 OK (no body)
Discover allowed methodsOPTIONS/users/42200 OK + Allow header
Create with server-assigned IDPOST/users (collection)201 Created + Location
Create with client-assigned IDPUT/users/client-picked-id201 Created
Replace existing resourcePUT/users/42200 OK (or 204)
Partial updatePATCH/users/42200 OK
RemoveDELETE/users/42204 No Content
Async / long-runningPOST/jobs (collection)202 Accepted + Location
Command / actionPOST/charges/42/refund200 OK or 202
Upload a filePOST/uploads201 Created

Three Worked Examples

"Send a welcome email to user 42." No obvious target resource for the email itself; the operation has side effects (the email goes out); not idempotent (sending twice sends two emails). POST on an action URI: POST /users/42/welcome-emails or POST /emails with a body specifying the recipient. Add an Idempotency-Key header if you want safe retries.

"Update user 42's email address." Has a target URI; the client knows the resource; this is a partial update. PATCH with {"email": "new@example.com"}. Or PUT with the full user payload if your API is PUT-everything-style (some are).

"Refund payment 42." No obvious natural resource for "a refund operation"; the operation has side effects; almost certainly NOT idempotent without coordination. POST on an action URI: POST /payments/42/refunds. Add Idempotency-Key. The response could be 201 (a Refund resource was created) or 200 (operation succeeded).

Pick the verb that matches the contract, not the closest English word. 'Update' doesn't mean PUT; 'create' doesn't mean POST; 'remove' doesn't mean DELETE. The method is chosen by the three invariants — what does the operation promise about state changes, retry safety, and cacheability? Walk the tree; the verb falls out.

The 'Tunnel Everything Through POST' Anti-Pattern

Some teams default every endpoint to POST. "It always works. We don't have to think." The costs are real:

  • CDNs can't cache the responses (POST is not cacheable by default).
  • Clients can't safely retry on transport failures (POST is not idempotent).
  • Browsers and HTTP intermediaries can't optimize (no speculative prefetch, no conditional GET).
  • Logs and dashboards lose the diagnostic signal — every operation looks like "POST something happened."
  • OpenAPI / Swagger docs become a wall of POSTs with no shape.

The protocol gives you seven verbs precisely so this information is in the wire. Throwing it away makes the system less observable, less cacheable, less retryable. Don't do it.

cwkPippa's Method Choices

Walk backend/routes/ and you'll see deliberate choices everywhere. POST /api/conversations creates with a server-assigned ID; GET /api/conversations/{id} reads (with the healing layer transparent to the client); PUT /api/conversations/{id}/title renames (idempotent — same name → same state); PATCH /api/folders/{id} changes color or display name; DELETE /api/conversations/{id} removes; POST /api/council/{id}/finalize is a command (not idempotent — finalizing twice would be a bug). Every one of these picks comes from walking the tree.

Code

A decision-helper function — use during design review·python
# A 'method picker' helper for code review or design discussions
from dataclasses import dataclass

@dataclass
class Operation:
    changes_state: bool
    target_uri_known: bool
    sends_full_resource: bool
    is_removal: bool
    has_natural_target: bool

def pick_method(op: Operation) -> str:
    if not op.changes_state:
        return 'GET (or HEAD for existence check, OPTIONS for discovery)'
    if op.is_removal:
        return 'DELETE'
    if not op.has_natural_target:
        return 'POST on action URI (e.g., POST /resource/{id}/action)'
    if not op.target_uri_known:
        return 'POST on collection (server assigns ID, returns 201 + Location)'
    if op.sends_full_resource:
        return 'PUT (idempotent — full replacement)'
    return 'PATCH (idempotent only if your patch format guarantees it)'

# Walk through the examples
print(pick_method(Operation(changes_state=False, target_uri_known=True,
                            sends_full_resource=False, is_removal=False,
                            has_natural_target=True)))
# GET (or HEAD for existence check, OPTIONS for discovery)

print(pick_method(Operation(changes_state=True, target_uri_known=False,
                            sends_full_resource=False, is_removal=False,
                            has_natural_target=True)))
# POST on collection (server assigns ID, returns 201 + Location)
cwkPippa-style routes: each method is a chosen contract·python
# cwkPippa-style FastAPI routes — each method choice is deliberate
from fastapi import FastAPI, APIRouter, status

app = FastAPI()
conversations = APIRouter(prefix='/api/conversations')

@conversations.get('/{cid}')
async def read_conversation(cid: str):
    # No state change, target URI known → GET
    return await load_from_db(cid)

@conversations.post('', status_code=status.HTTP_201_CREATED)
async def create_conversation():
    # Server assigns the ID; returns 201 + Location
    new_id = await create_in_db()
    return {'id': new_id, 'location': f'/api/conversations/{new_id}'}

@conversations.put('/{cid}/title')
async def rename_conversation(cid: str, payload: dict):
    # Idempotent: same title → same state. PUT is correct.
    await update_title(cid, payload['title'])
    return {'id': cid, 'title': payload['title']}

@conversations.patch('/{cid}')
async def update_conversation(cid: str, payload: dict):
    # Partial update: payload may have any subset of fields. PATCH.
    return await apply_patch(cid, payload)

@conversations.delete('/{cid}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_conversation(cid: str):
    await delete_from_db(cid)  # DELETE — idempotent

@conversations.post('/{cid}/finalize', status_code=status.HTTP_202_ACCEPTED)
async def finalize_council(cid: str):
    # Command/action with side effects, NOT idempotent — POST
    job_id = await enqueue_finalize(cid)
    return {'job_id': job_id, 'poll': f'/api/jobs/{job_id}'}

External links

Exercise

Take these ten operations and pick the right HTTP method for each. For each one, write a one-sentence justification using the three invariants (safe / idempotent / cacheable): (1) fetch user 42's profile, (2) update user 42's email field, (3) replace user 42's entire profile, (4) remove user 42, (5) check if user 42 exists without downloading the body, (6) send a password-reset email to user 42, (7) create a new conversation in a folder, (8) move conversation X to folder Y, (9) finalize a council round, (10) discover which methods are allowed on /users/42.
Hint
Use the decision tree from the lesson. (1) GET — safe + idempotent. (2) PATCH — partial update, not necessarily idempotent. (3) PUT — full replacement, idempotent. (4) DELETE. (5) HEAD. (6) POST + Idempotency-Key — side effect, not idempotent. (7) POST — server assigns conversation ID. (8) PUT on a 'parent_folder' field via PATCH (partial), or PUT on /conversations/{id}/folder. (9) POST + Idempotency-Key — command, side effect. (10) OPTIONS. Bonus: identify which of these would benefit most from a date-based versioning strategy.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.