REST API Design
HTTP Methods Semantics
Read aloud is unavailable in this browser.
The Verbs of the Web
If resources are the nouns of REST, then HTTP methods are the verbs. They tell the server what operation to perform on the resource identified by the URI. The combination of method and URI forms a complete semantic instruction: GET /users/123 means "retrieve user 123," while DELETE /users/123 means "remove user 123."
But HTTP methods carry far more meaning than just the operation name. Each method has carefully defined semantics around:
- Safety — Does the method cause side effects?
- Idempotency — Can the method be repeated safely?
- Cacheability — Can responses be stored and reused?
- Request body — Does the method accept a body?
- Success semantics — What does a successful response mean?
Understanding these semantics is crucial. When you use the right method, clients, proxies, and browsers can make intelligent decisions—caching GETs, retrying PUTs on network failure, warning before destructive DELETEs. When you misuse methods, these optimizations break, and subtle bugs emerge.
By the end of this page, you will have a precise understanding of every standard HTTP method—GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS—including their safety, idempotency, appropriate use cases, and common anti-patterns. You'll also learn about less common methods like TRACE and CONNECT.
Safety and Idempotency: The Foundation
Before examining individual methods, we must understand two critical properties that define HTTP method behavior: safety and idempotency.
Safety
A method is safe if it does not modify server-side state. Safe methods are read-only. Calling a safe method should never change any data, trigger side effects, or have consequences beyond the request itself.
Why it matters:
- Search engine crawlers can follow GET links without fear of changing data
- Browsers can prefetch safe resources to improve performance
- Users can refresh safe requests without warnings
- Proxies can cache safe responses aggressively
Idempotency
A method is idempotent if calling it once has the same effect as calling it multiple times. The result is identical whether you execute the request 1, 2, or 100 times.
Mathematically: f(f(x)) = f(x)
Why it matters:
- Clients can safely retry failed requests without fear of duplicate effects
- Load balancers can retry on timeout without coordination
- Network failures don't require complex recovery logic
| Method | Safe? | Idempotent? | Has Request Body? | Cacheable? |
|---|---|---|---|---|
| GET | ✅ Yes | ✅ Yes | ❌ No (by spec) | ✅ Yes |
| HEAD | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| OPTIONS | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
| POST | ❌ No | ❌ No | ✅ Yes | Rarely |
| PUT | ❌ No | ✅ Yes | ✅ Yes | ❌ No |
| PATCH | ❌ No | ❌ No* | ✅ Yes | ❌ No |
| DELETE | ❌ No | ✅ Yes | Optional | ❌ No |
PATCH is not idempotent by default because patch operations can have cumulative effects (e.g., 'increment counter by 1'). However, you CAN design idempotent PATCH operations, and you SHOULD when possible. For example, 'set counter to 5' is idempotent; 'increment counter' is not.
GET: Retrieve a Resource
GET is the most fundamental HTTP method—the one browsers use for every page load, every image, every CSS file. It retrieves a representation of the resource identified by the URI.
Semantics:
- Purpose: Retrieve the current representation of a resource
- Safe: ✅ Yes — must not cause side effects
- Idempotent: ✅ Yes — multiple identical requests return the same result
- Request body: ❌ Should not have one (ignored by many servers)
- Cacheable: ✅ Yes — responses can be cached based on headers
Success Responses:
| Status Code | Meaning |
|---|---|
| 200 OK | Resource found, representation in response body |
| 204 No Content | Resource exists but has no representation |
| 304 Not Modified | Conditional request; cached version is still valid |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# Basic GET requestGET /users/12345 HTTP/1.1Host: api.example.comAccept: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1... # ResponseHTTP/1.1 200 OKContent-Type: application/jsonCache-Control: private, max-age=300ETag: "a1b2c3d4" { "id": "12345", "name": "Alice Chen", "email": "alice@example.com"} # Conditional GET (bandwidth efficient)GET /users/12345 HTTP/1.1Host: api.example.comIf-None-Match: "a1b2c3d4" # Response when unchangedHTTP/1.1 304 Not ModifiedETag: "a1b2c3d4" # GET collection with query parametersGET /users?role=admin&status=active&sort=-created&page=1&limit=20 HTTP/1.1Accept: application/json # Response with paginationHTTP/1.1 200 OKContent-Type: application/jsonX-Total-Count: 145 { "data": [...], "_links": { "self": "/users?page=1&limit=20", "next": "/users?page=2&limit=20" }}- •Use for all read-only operations
- •Include proper caching headers
- •Support conditional requests (ETag/Last-Modified)
- •Keep URIs bookmarkable
- •Paginate large collections
- •Support content negotiation
- •Using GET to trigger side effects
- •Sending request body with GET
- •Long query strings beyond URL limits
- •Returning different data for same URI
- •No caching headers on static data
- •Ignoring Accept headers
If you implement GET /users/123/delete or GET /logout, you've violated HTTP semantics. Crawlers may follow these links, browsers may prefetch them, and users may trigger them accidentally. Any GET that modifies state is a security vulnerability and an API design failure.
POST: Create or Submit
POST is the most versatile—and most misused—HTTP method. Its RFC-defined purpose is to submit data to be processed by the resource identified by the URI. In REST, this typically means creating a new resource as a subordinate of the target collection.
Semantics:
- Purpose: Create a new resource or submit data for processing
- Safe: ❌ No — causes side effects (resource creation)
- Idempotent: ❌ No — each POST typically creates a new resource
- Request body: ✅ Yes — contains the data to process
- Cacheable: Rarely (only when explicitly marked)
Success Responses:
| Status Code | Meaning | When to Use |
|---|---|---|
| 201 Created | New resource created | Include Location header with URI |
| 200 OK | Request processed, result in body | When returning processed data |
| 202 Accepted | Request accepted for async processing | Long-running operations |
| 204 No Content | Processed successfully, no body | Fire-and-forget actions |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// POST /users - Create a new userapp.post('/users', validateBody(createUserSchema), async (req, res) => { // Validate unique constraints const existing = await userRepository.findByEmail(req.body.email); if (existing) { return res.status(409).json({ error: 'CONFLICT', message: 'A user with this email already exists', field: 'email' }); } // Create the resource const user = await userRepository.create({ name: req.body.name, email: req.body.email, role: req.body.role || 'user' }); // 201 Created with Location header res.status(201) .header('Location', `/users/${user.id}`) .json({ id: user.id, name: user.name, email: user.email, role: user.role, createdAt: user.createdAt, _links: { self: { href: `/users/${user.id}` } } });}); // POST for async processing (202 Accepted)app.post('/reports/generate', async (req, res) => { const job = await jobQueue.enqueue({ type: 'report_generation', params: req.body }); // Return immediately with job reference res.status(202).json({ jobId: job.id, status: 'queued', estimatedCompletion: job.estimatedCompletion, _links: { status: { href: `/jobs/${job.id}` }, cancel: { href: `/jobs/${job.id}`, method: 'DELETE' } } });}); // POST with idempotency key (client-provided)app.post('/payments', async (req, res) => { const idempotencyKey = req.headers['idempotency-key']; if (idempotencyKey) { const existing = await paymentRepository.findByIdempotencyKey(idempotencyKey); if (existing) { // Return the same response as before (idempotent behavior) return res.status(200).json(existing); } } const payment = await paymentService.process({ ...req.body, idempotencyKey }); res.status(201).json(payment);});Although POST is not inherently idempotent, you can make it so using idempotency keys. The client generates a unique key (UUID) and sends it with the request. If the server sees the same key again, it returns the original response instead of creating a duplicate. This is essential for payment systems and anything where network retries could cause duplicate actions.
PUT: Replace a Resource
PUT replaces the entire state of the resource at the target URI with the representation in the request body. The key word is replace—the resource after PUT is exactly what was sent, nothing more, nothing less.
Semantics:
- Purpose: Replace a resource completely (or create at known URI)
- Safe: ❌ No — modifies server state
- Idempotent: ✅ Yes — N identical PUTs have the same effect as one
- Request body: ✅ Yes — the complete new representation
- Cacheable: ❌ No — invalidates cached copies
Critical Distinction: PUT vs PATCH
PUT requires sending the complete resource. If you PUT a user without the email field, the email becomes null/deleted—you're not omitting an update, you're explicitly removing it.
Current state: {id: 1, name: "Alice", email: "alice@example.com", phone: "555-1234"}
PUT request: {id: 1, name: "Alice Chen", email: "alice@example.com"}
Result: {id: 1, name: "Alice Chen", email: "alice@example.com", phone: null}
This is why PUT is often impractical for updating—clients must fetch the current state, modify it, and send everything back.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// PUT /users/:id - Complete replacementapp.put('/users/:id', validateBody(fullUserSchema), async (req, res) => { // Validate all required fields are present (PUT requires completeness) const requiredFields = ['name', 'email', 'role', 'status']; const missing = requiredFields.filter(f => !(f in req.body)); if (missing.length > 0) { return res.status(400).json({ error: 'VALIDATION_ERROR', message: 'PUT requires all fields to be specified', missingFields: missing }); } const user = await userRepository.findById(req.params.id); if (!user) { // PUT can create at a known URI (201 Created) const newUser = await userRepository.create({ id: req.params.id, // Use client-specified ID ...req.body }); return res.status(201) .header('Location', `/users/${newUser.id}`) .json(newUser); } // Complete replacement (200 OK) const updatedUser = await userRepository.replace(req.params.id, req.body); res.json(updatedUser);}); // PUT with optimistic locking (avoiding lost updates)app.put('/documents/:id', async (req, res) => { const ifMatch = req.headers['if-match']; const document = await documentRepository.findById(req.params.id); if (!document) { return res.status(404).json({ error: 'NOT_FOUND' }); } // Check version matches (ETag) if (ifMatch && ifMatch !== `"${document.version}"`) { return res.status(412).json({ error: 'PRECONDITION_FAILED', message: 'Document has been modified since you last fetched it', currentVersion: document.version, yourVersion: ifMatch.replace(/"/g, '') }); } const updated = await documentRepository.replace(req.params.id, { ...req.body, version: document.version + 1 }); res.header('ETag', `"${updated.version}"`) .json(updated);}); // PUT for singleton resources (one per parent)app.put('/users/:userId/preferences', async (req, res) => { // Preferences is a singleton - always exists once user exists const user = await userRepository.findById(req.params.userId); if (!user) { return res.status(404).json({ error: 'User not found' }); } // Create or replace entirely const preferences = await preferencesRepository.upsert( req.params.userId, req.body ); res.json(preferences);});| Scenario | Status Code | Notes |
|---|---|---|
| Resource replaced successfully | 200 OK | Include updated representation in body |
| Resource created at specified URI | 201 Created | Include Location header |
| Replaced successfully, no body | 204 No Content | Client already knows the content |
| Resource not found (no upsert) | 404 Not Found | If PUT shouldn't create |
| ETag mismatch | 412 Precondition Failed | Optimistic locking failure |
PATCH: Partial Update
PATCH applies partial modifications to a resource. Unlike PUT, which replaces everything, PATCH only changes the fields specified in the request. This makes it the practical choice for most update operations.
Semantics:
- Purpose: Apply partial modifications to a resource
- Safe: ❌ No — modifies server state
- Idempotent: ❓ Can be, but not guaranteed (depends on patch format)
- Request body: ✅ Yes — describes the changes to apply
- Cacheable: ❌ No
PATCH Format Options:
- Merge Patch (application/merge-patch+json) — Simply send the fields to update
- JSON Patch (application/json-patch+json) — RFC 6902 operations array
- Custom format — Application-specific update structure
12345678910111213141516171819202122232425262728293031323334353637383940414243
# 1. MERGE PATCH (RFC 7396) - Simple and common# Content-Type: application/merge-patch+json# Send only the fields you want to update PATCH /users/12345 HTTP/1.1Content-Type: application/merge-patch+json { "email": "new.email@example.com", "phone": null} # Result: email changed, phone removed, other fields unchanged # 2. JSON PATCH (RFC 6902) - Precise operations# Content-Type: application/json-patch+json# Explicit operations: add, remove, replace, move, copy, test PATCH /users/12345 HTTP/1.1Content-Type: application/json-patch+json [ { "op": "replace", "path": "/email", "value": "new.email@example.com" }, { "op": "remove", "path": "/phone" }, { "op": "add", "path": "/tags/-", "value": "premium" }, { "op": "test", "path": "/status", "value": "active" }] # "test" operation makes the whole patch fail if condition not met# This enables atomic conditional updates # 3. Custom business-oriented patchPATCH /orders/ord-456 HTTP/1.1Content-Type: application/json { "operations": [ { "action": "updateShippingAddress", "address": {...} }, { "action": "applyDiscount", "code": "SUMMER20" } ]}12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// PATCH /users/:id - Merge patch implementationapp.patch('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { return res.status(404).json({ error: 'NOT_FOUND' }); } // Whitelist patchable fields (security!) const patchableFields = ['name', 'email', 'phone', 'bio', 'avatar']; const updates: Record<string, any> = {}; for (const field of patchableFields) { if (field in req.body) { updates[field] = req.body[field]; // null is valid (removes field) } } // Reject attempts to patch protected fields const protectedFields = ['id', 'createdAt', 'role', 'password']; const attemptedProtected = protectedFields.filter(f => f in req.body); if (attemptedProtected.length > 0) { return res.status(400).json({ error: 'VALIDATION_ERROR', message: 'Cannot update protected fields', protectedFields: attemptedProtected }); } const updatedUser = await userRepository.update(req.params.id, updates); res.json(updatedUser);}); // Making PATCH idempotent with absolute values// ❌ NOT IDEMPOTENTapp.patch('/counters/:id/increment', async (req, res) => { await db.query('UPDATE counters SET value = value + 1 WHERE id = ?', [req.params.id]); // Multiple requests = multiple increments}); // ✅ IDEMPOTENTapp.patch('/counters/:id', async (req, res) => { // Absolute value, not relative await db.query('UPDATE counters SET value = ? WHERE id = ?', [req.body.value, req.params.id]); // Multiple identical requests = same result}); // JSON Patch with the 'fast-json-patch' libraryimport * as jsonpatch from 'fast-json-patch'; app.patch('/documents/:id', async (req, res) => { const contentType = req.headers['content-type']; const document = await documentRepository.findById(req.params.id); if (!document) { return res.status(404).json({ error: 'NOT_FOUND' }); } let patchedDocument; if (contentType === 'application/json-patch+json') { // RFC 6902 JSON Patch const errors = jsonpatch.validate(req.body, document.content); if (errors) { return res.status(400).json({ error: 'INVALID_PATCH', details: errors }); } patchedDocument = jsonpatch.applyPatch( jsonpatch.deepClone(document.content), req.body ).newDocument; } else { // Default: merge patch patchedDocument = { ...document.content, ...req.body }; } const saved = await documentRepository.update(req.params.id, patchedDocument); res.json(saved);});Use PATCH when clients update specific fields and shouldn't need the full resource. Use PUT when you want to guarantee the resource is exactly as specified (no lingering old fields). In practice, PATCH is more common because it's more forgiving—clients don't need to know the full schema.
DELETE: Remove a Resource
DELETE removes the resource at the target URI. After a successful DELETE, subsequent GETs to that URI should return 404 (or 410 Gone if you want to indicate the resource once existed).
Semantics:
- Purpose: Remove the resource at the target URI
- Safe: ❌ No — modifies server state (deletes data)
- Idempotent: ✅ Yes — deleting once or 100 times has the same effect
- Request body: Optional (often not used)
- Cacheable: ❌ No
Idempotency Implications:
DELETE is idempotent, which has important implications for error handling. If a client sends DELETE /users/123 and the resource doesn't exist, what should you return?
- 404 Not Found — Technically correct; the resource isn't there
- 204 No Content — Also valid; the resource is (now) deleted, as requested
Both are acceptable. The 204 approach emphasizes idempotency (the end state is achieved), while 404 is more informative (the resource wasn't found). Choose one and be consistent.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// DELETE /users/:id - Simple hard deleteapp.delete('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); // Approach 1: Return 204 regardless (emphasize idempotency) if (!user) { return res.status(204).send(); } await userRepository.delete(req.params.id); res.status(204).send();}); // DELETE with soft delete (common in production)app.delete('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { return res.status(404).json({ error: 'NOT_FOUND' }); } // Instead of physical deletion, mark as deleted await userRepository.update(req.params.id, { deletedAt: new Date(), status: 'deleted' }); // Optionally return 200 with deletion details res.status(200).json({ id: req.params.id, deleted: true, deletedAt: new Date().toISOString(), recoverable: true, recoverUntil: addDays(new Date(), 30).toISOString() });}); // DELETE with cascade (document relationships)app.delete('/projects/:projectId', async (req, res) => { const project = await projectRepository.findById(req.params.projectId); if (!project) { return res.status(204).send(); } // Check for blocking constraints const activeMembers = await memberRepository.countByProject(req.params.projectId); if (activeMembers > 0 && !req.query.force) { return res.status(409).json({ error: 'CONFLICT', message: 'Cannot delete project with active members', memberCount: activeMembers, hint: 'Add ?force=true to delete with all members' }); } // Cascade delete in transaction await db.transaction(async (tx) => { await tx.delete('project_members').where('project_id', req.params.projectId); await tx.delete('projects').where('id', req.params.projectId); }); res.status(204).send();}); // DELETE collection subset (batch delete)// DELETE /notifications?read=true&older_than=2024-01-01app.delete('/notifications', async (req, res) => { const filter = { userId: req.user.id, read: req.query.read === 'true' ? true : undefined, createdBefore: req.query.older_than }; const deletedCount = await notificationRepository.deleteWhere(filter); res.status(200).json({ deleted: deletedCount, filter: filter });});Soft deletes (setting deletedAt instead of removing rows) are safer but complicate your API. Deleted resources shouldn't appear in lists but might need to be accessible for auditing. Consider: Should GET /users/123 return 404 or 410 for soft-deleted users? Should deleted resources appear in admin views? Document your approach clearly.
HEAD, OPTIONS, and Others
Beyond the CRUD methods, HTTP defines several other methods with specialized purposes. Understanding these helps you design more sophisticated APIs and support cross-origin requests properly.
HEAD: Metadata Only
HEAD is identical to GET except it returns only headers, no body. Use it to:
- Check if a resource exists (without downloading it)
- Get Content-Length before downloading large files
- Check Last-Modified/ETag without fetching content
HEAD /files/large-video.mp4 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: video/mp4
Content-Length: 1073741824
Accept-Ranges: bytes
OPTIONS: Capability Discovery
OPTIONS returns the communication options available for a resource. Critical for CORS preflight requests.
OPTIONS /users/123 HTTP/1.1
Origin: https://webapp.example.com
HTTP/1.1 204 No Content
Allow: GET, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Origin: https://webapp.example.com
Access-Control-Allow-Methods: GET, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
| Method | Purpose | Common Use |
|---|---|---|
| GET | Retrieve resource | Basic reads, searches |
| HEAD | Get headers only | Existence checks, metadata |
| POST | Submit data / create | Creation, actions, form submission |
| PUT | Replace resource | Full updates, uploads |
| PATCH | Partial update | Field-level edits |
| DELETE | Remove resource | Deletion |
| OPTIONS | Get allowed methods | CORS preflight, discovery |
| TRACE | Echo request | Debugging (usually disabled) |
| CONNECT | Establish tunnel | HTTPS proxying |
12345678910111213141516171819202122232425262728293031323334353637383940
// Proper CORS handling for REST APIs // Middleware for OPTIONS preflightapp.options('*', (req, res) => { const origin = req.headers.origin; const allowedOrigins = [ 'https://webapp.example.com', 'https://admin.example.com' ]; if (allowedOrigins.includes(origin)) { res.header('Access-Control-Allow-Origin', origin); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-ID'); res.header('Access-Control-Max-Age', '86400'); // Cache preflight for 24h res.header('Access-Control-Allow-Credentials', 'true'); } res.status(204).send();}); // HEAD handler exampleapp.head('/files/:fileId', async (req, res) => { const file = await fileRepository.findById(req.params.fileId); if (!file) { return res.status(404).send(); } // Return headers only, no body res.header('Content-Type', file.mimeType); res.header('Content-Length', file.size); res.header('ETag', `"${file.checksum}"`); res.header('Last-Modified', file.updatedAt.toUTCString()); res.header('Accept-Ranges', 'bytes'); res.status(200).send();}); // Express: HEAD is automatically handled for GET routes// If you define GET /users/:id, HEAD /users/:id works automaticallyKEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.