Loading learning content...
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:
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.
Before examining individual methods, we must understand two critical properties that define HTTP method behavior: safety and idempotency.
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:
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:
| 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 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:
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" }}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 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:
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 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:
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 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:
PATCH Format Options:
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 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:
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?
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.
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 is identical to GET except it returns only headers, no body. Use it to:
HEAD /files/large-video.mp4 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: video/mp4
Content-Length: 1073741824
Accept-Ranges: bytes
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 automaticallyWhen designing an endpoint, use this decision tree to select the appropriate HTTP method:
123456789101112131415161718192021222324
Is the operation read-only?├── YES: Does it return a response body?│ ├── YES → GET│ └── NO (headers only) → HEAD│└── NO: Does it create a new resource? ├── YES: Is the URI of the new resource known beforehand? │ ├── YES → PUT (to known URI) │ └── NO → POST (to collection) │ └── NO: Does it modify an existing resource? ├── YES: Is it a complete replacement? │ ├── YES → PUT │ └── NO (partial) → PATCH │ └── NO: Does it remove a resource? ├── YES → DELETE │ └── NO: Is it a process/action? ├── YES → POST (to action resource) │ └── Still unsure? → Default to POST (most permissive) → Consider if it should be modeled as a resource| Operation | Method | URI Pattern | Example |
|---|---|---|---|
| List resources | GET | /resources | /users?role=admin |
| Get one resource | GET | /resources/:id | /users/123 |
| Create resource | POST | /resources | POST /users with body |
| Replace resource | PUT | /resources/:id | PUT /users/123 with full user |
| Partial update | PATCH | /resources/:id | PATCH /users/123 with fields |
| Delete resource | DELETE | /resources/:id | DELETE /users/123 |
| Trigger action | POST | /resources/:id/action-noun | POST /orders/123/cancellations |
| Upload file | PUT | /files/:filename | PUT /files/report.pdf |
| Search (complex) | POST | /resources/search | POST with filter body |
HTTP methods are more than names for operations—they carry semantic meaning that enables caching, retry logic, and client behavior. Let's consolidate the key learnings:
What's Next:
Now that we understand how to use HTTP methods correctly, we'll explore HTTP Status Codes—how to communicate success, failure, and nuanced outcomes to API clients in a standardized, machine-readable way.
You now have a precise understanding of HTTP method semantics—safety, idempotency, and appropriate usage. You can choose the right method for any operation and implement them correctly. Next, we'll learn to communicate operation outcomes through status codes.