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:
Intent
Method
URI shape
Status code on success
Read a resource
GET
/users/42
200 OK (or 304 if cached)
List resources
GET
/users?role=admin
200 OK
Test existence cheaply
HEAD
/users/42
200 OK (no body)
Discover allowed methods
OPTIONS
/users/42
200 OK + Allow header
Create with server-assigned ID
POST
/users (collection)
201 Created + Location
Create with client-assigned ID
PUT
/users/client-picked-id
201 Created
Replace existing resource
PUT
/users/42
200 OK (or 204)
Partial update
PATCH
/users/42
200 OK
Remove
DELETE
/users/42
204 No Content
Async / long-running
POST
/jobs (collection)
202 Accepted + Location
Command / action
POST
/charges/42/refund
200 OK or 202
Upload a file
POST
/uploads
201 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}'}
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.