REST API Design Best Practices: The Complete Guide (2026)
REST API design best practices covering URL naming, HTTP status codes, versioning, pagination, error responses, security, and request/response design. With real HTTP examples.
REST API design best practices are a set of conventions that make your API consistent, predictable, and easy to use. The core rules: use nouns not verbs in URLs, use HTTP methods to express actions, return correct status codes, version from day one, paginate all collections, and always return structured error responses. Everything else is detail.
After building APIs in Python and Go for 6 years — some that developers loved, some that generated a lot of confused Slack messages — here is everything that actually makes a difference.
URL Design and Resource Naming
The URL identifies what you're working with. The HTTP method tells you what action to take. This split is the foundation of REST.
Nouns, Not Verbs
# Bad — verbs leak into URLs
GET /getUser/123
POST /createOrder
POST /deleteProduct/456
POST /sendEmailToUser/123
# Good — nouns in URLs, HTTP methods express the action
GET /users/123
POST /orders
DELETE /products/456
POST /users/123/email-notificationsAlways Use Plural Nouns for Collections
Consistency beats cleverness. If /users returns a list, then /users/123 returns one member of that list. Always plural.
GET /users → list all users
GET /users/123 → get user 123
POST /users → create a new user
PUT /users/123 → replace user 123
PATCH /users/123 → partial update of user 123
DELETE /users/123 → delete user 123Nesting Depth: Maximum Two Levels
Nested URLs express relationships between resources.
GET /users/123/orders → orders belonging to user 123
GET /users/123/orders/456 → specific order by user 123Stop at two levels. /companies/1/departments/2/teams/3/members/4 is hard to read, hard to remember, and hard to maintain. For deeper relationships, use query parameters:
# Instead of /companies/1/departments/2/teams/3/members/4
GET /members?company_id=1&department_id=2&team_id=3Query Params vs Path Params
Use path parameters for identifying a specific resource:
GET /users/123 ← 123 identifies which user
GET /orders/abc-456 ← abc-456 identifies which orderUse query parameters for filtering, sorting, searching, pagination, and optional modifiers:
GET /users?status=active&country=IN
GET /users?sort=created_at&order=desc
GET /users?search=akash&limit=20&cursor=xyz
GET /products?min_price=100&max_price=500&category=electronicsA quick rule: if removing the parameter returns a different resource, it's a path param. If it filters or shapes the result, it's a query param.
Use the Right HTTP Method
| Method | Use for | Idempotent? | Body? |
|---|---|---|---|
| GET | Fetch data | Yes | No |
| POST | Create new resource | No | Yes |
| PUT | Replace entire resource | Yes | Yes |
| PATCH | Partial update | Usually yes | Yes |
| DELETE | Remove resource | Yes | Rarely |
Idempotent means calling it multiple times produces the same result. PUT /users/123 with the same payload always ends with the same state. POST /users creates a new user on every call.
HTTP Status Codes: The Complete Guide
Status codes are the first thing a client reads. Using them correctly lets clients handle errors without parsing your response body. Using them wrong breaks every HTTP client, SDK, and monitoring tool pointed at your API.
2xx — Success
200 OK → successful GET, PUT, PATCH
201 Created → successful POST — return the created resource
202 Accepted → request accepted, processing async (jobs, exports)
204 No Content → successful DELETE or action with nothing to return3xx — Redirection
301 Moved Permanently → resource has a new permanent URL (update your bookmarks)
302 Found → temporary redirect
304 Not Modified → ETag/conditional GET — client cache is still valid4xx — Client Errors
400 Bad Request → malformed request, invalid JSON, missing required field
401 Unauthorized → no auth token, or token is invalid/expired
403 Forbidden → authenticated but not allowed to do this
404 Not Found → resource doesn't exist (or you're hiding it intentionally)
405 Method Not Allowed → tried DELETE on a read-only endpoint
409 Conflict → duplicate resource (duplicate email on signup)
410 Gone → resource existed but was permanently deleted
422 Unprocessable Entity → valid JSON, but fails business validation rules
429 Too Many Requests → rate limited — include Retry-After header5xx — Server Errors
500 Internal Server Error → something broke on your side
502 Bad Gateway → upstream service returned invalid response
503 Service Unavailable → server is down or overloaded
504 Gateway Timeout → upstream service timed outCommon Mistakes to Avoid
Never return 200 for errors:
# Bad — client thinks the request succeeded
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": false,
"error": "User not found"
}
# Good
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": {
"code": "USER_NOT_FOUND",
"message": "No user found with ID 123"
}
}Use 401 vs 403 correctly:
401 Unauthorized— the client is not authenticated (no token, expired token)403 Forbidden— the client is authenticated but lacks permission
Use 422 for validation errors, not 400:
400 Bad Request— the request structure is broken (invalid JSON, missing Content-Type)422 Unprocessable Entity— valid request format, but the values fail validation
API Versioning Strategies
Breaking changes are inevitable. Versioning lets you evolve the API without breaking existing clients.
URL Versioning (Recommended)
/v1/users
/v2/usersExplicit, easy to test in a browser or curl, easy to route in a load balancer. This is the standard used by Stripe, Twilio, and most production APIs.
GET /v1/users/123 HTTP/1.1
Host: api.example.comHeader Versioning
GET /users/123 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+jsonKeeps URLs clean. Harder to test in a browser, harder to cache, and confusing when you need to share a URL with a specific version. Avoid unless you have a strong reason.
How to Deprecate Old Versions
- Add a
Deprecationheader to all v1 responses as a warning:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"- Document the migration path in your API changelog.
- Email all developers who called v1 in the last 90 days.
- Keep v1 running until usage drops to zero (or your SLA expires).
- Return
410 Goneon the sunset date, with a pointer to v2.
What counts as a breaking change (requires a new version):
- Removing a field from a response
- Renaming a field
- Changing a field type (
string→integer) - Removing an endpoint
- Changing authentication scheme
What does not require a new version:
- Adding new optional fields to responses
- Adding new optional query parameters
- Adding new endpoints
Pagination, Filtering, Sorting
Never return an unbounded collection. Always paginate.
Offset Pagination
Simple to implement and understand. Gets slow on large tables because the database still scans all skipped rows.
GET /users?page=2&limit=20 HTTP/1.1{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 1540,
"total_pages": 77
}
}Use for small datasets (under ~50,000 rows) or when users need to jump to a specific page number.
Cursor-Based Pagination
Faster on large datasets. No skipped row scan. Handles concurrent inserts correctly (no duplicates or skipped items when new records are added mid-pagination).
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20 HTTP/1.1{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTQzfQ",
"has_more": true,
"limit": 20
}
}The cursor is usually a base64-encoded pointer to the last item (e.g., the id or a (created_at, id) tuple). Clients cannot jump to page 5 directly — they must walk forward. This is fine for feeds and infinite scroll, not fine for admin UIs with page number navigation.
Link Headers (RFC 5988)
The HTTP standard way to communicate pagination links, used by the GitHub API:
HTTP/1.1 200 OK
Link: <https://api.example.com/users?cursor=abc&limit=20>; rel="next",
<https://api.example.com/users?cursor=xyz&limit=20>; rel="prev"Filtering and Sorting
# Filtering — use query params
GET /users?status=active&role=admin&country=IN
# Sorting
GET /users?sort=created_at&order=desc
GET /products?sort=price&order=asc
# Multiple sort fields
GET /users?sort=last_name,first_name&order=asc,asc
# Search
GET /users?search=akash
GET /products?q=bluetooth+headphonesKeep filter parameter names consistent across endpoints. If status filters on /users, it should mean the same thing on /orders.
Error Response Design
Consistent error responses are as important as consistent success responses. Every client — mobile, frontend, internal service — needs to parse your errors. If every endpoint returns errors differently, every client needs custom parsing logic for every endpoint.
Consistent Error Schema
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed for 2 fields",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be at least 18",
"value": 15
}
]
}
}code— machine-readable string your clients canswitchon. Never changes, even if the message text changes.message— human-readable explanation.details— array of field-level errors for validation failures.
// Not found
{
"error": {
"code": "USER_NOT_FOUND",
"message": "No user found with ID 123"
}
}
// Rate limited
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Try again in 30 seconds.",
"retry_after": 30
}
}RFC 7807 Problem Details
The IETF standard for HTTP error bodies. Gaining adoption in newer APIs (used by many Java Spring and ASP.NET APIs):
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contained 2 validation errors",
"instance": "/users",
"errors": [
{ "field": "email", "message": "Invalid email format" }
]
}RFC 7807 is worth adopting if you're building a new API — it's becoming the standard for OpenAPI-based tooling. For existing APIs, using a consistent custom schema is better than migrating mid-stream.
API Security Basics
Authentication
Bearer Token (most common):
GET /users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...API Key (for service-to-service):
GET /data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_abc123xyzNever put API keys in query parameters (/data?api_key=secret) — they end up in server logs, browser history, and referrer headers.
HTTPS Only
Always enforce HTTPS. Redirect HTTP to HTTPS at the load balancer level. Return 301 Moved Permanently — not a soft redirect that allows HTTP to work. Never transmit tokens over plain HTTP.
Rate Limiting
Include rate limit information in response headers so clients can self-throttle:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1750000000When the limit is exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1750000000CORS
Set CORS headers explicitly. Do not use wildcard * in production for authenticated APIs:
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400For public read-only APIs (weather, public data), * is fine. For anything with authentication, pin to specific origins.
Request and Response Design
When to Use Request Body vs Query Params
| Use case | How |
|---|---|
| Creating/updating a resource | Request body (JSON) |
| Filtering, searching, sorting | Query params |
| Pagination | Query params |
| Single identifier | Path param |
| Authentication | Header (Authorization) |
Never put sensitive data in query params. Never put large datasets in query params.
Envelope vs Direct Response
Direct response — return the resource itself:
GET /users/123
{
"id": 123,
"name": "Akash Sharma",
"email": "akash@example.com"
}Envelope — wrap in a data key:
GET /users/123
{
"data": {
"id": 123,
"name": "Akash Sharma",
"email": "akash@example.com"
}
}Envelopes are useful when you need to attach metadata (pagination, request ID, rate limit info) alongside the resource. Direct responses are simpler for single-resource endpoints. Pick one pattern and use it consistently across all endpoints.
Partial Responses (Fields Param)
For mobile clients or bandwidth-sensitive use cases, let the client request only the fields they need:
GET /users/123?fields=id,name,email{
"id": 123,
"name": "Akash Sharma",
"email": "akash@example.com"
}This pattern is used by Google APIs and the Facebook Graph API. It reduces payload size and avoids over-fetching without requiring GraphQL.
Key Takeaways
- Use nouns in URLs, HTTP methods for actions. Never put verbs in URLs.
- Always return the correct HTTP status code — never 200 for errors.
- Error responses need a consistent schema:
code,message,details. - Version from day one — URL versioning (
/v1/) is the safest default. - Paginate all collections — cursor-based for large datasets, offset for small.
- Nest at most 2 levels deep — use query params for deeper relationships.
- Put API keys and tokens in headers, never in query params.
- Include rate limit headers on every response so clients can self-throttle.
- Be consistent — if filtering works one way on
/users, it works the same on/orders.
The best API is the one developers can use without reading the documentation.
FAQ
What is REST and what makes a good REST API?
REST (Representational State Transfer) is an architectural style for building HTTP APIs. It uses standard HTTP methods (GET, POST, PUT, PATCH, DELETE) to operate on resources identified by URLs. A good REST API is predictable: given the URL and method, a developer can guess what it does. Concretely, that means consistent URL structure, correct HTTP status codes, structured error responses, versioning, and proper authentication. If a developer can use your API without referencing the docs every 5 minutes, it's a good REST API.
Should REST API URLs use nouns or verbs?
Always nouns. The HTTP method is the verb. GET /users/123 reads as "GET the user with ID 123" — GET is the verb, /users/123 is the noun. Writing /getUser/123 duplicates the verb concept and breaks HTTP semantics. The only exception is action-oriented endpoints that don't map cleanly to a resource, like /users/123/activate or /payments/123/refund — even then, treat these as sub-resources rather than verbs.
What HTTP status code should I return for validation errors?
422 Unprocessable Entity. Reserve 400 Bad Request for structurally broken requests (malformed JSON, wrong Content-Type). Use 422 when the request is well-formed but the values fail validation (invalid email format, age below minimum, required field missing). Always include a response body that lists which fields failed and why — a bare 422 with no body is as useless as 400 Bad Request.
How should I version my REST API?
URL versioning (/v1/users, /v2/users) is the most practical approach. It's explicit, easy to test with curl, easy to route in nginx or a load balancer, and immediately visible in logs. Start with /v1/ on day one, even before you have any breaking changes planned — retrofitting versioning later is painful. When you do introduce v2, keep v1 running, add a Deprecation + Sunset header to v1 responses, and give clients a documented migration window before switching off.
What is the best pagination strategy for a REST API?
Cursor-based pagination for large or frequently-updated datasets; offset pagination for small datasets or when users need to jump to a specific page. Cursor-based is more complex to implement but is faster at scale (no full table scan), handles concurrent writes without gaps or duplicates, and is what large-scale APIs like Twitter, Slack, and Stripe use. For admin dashboards with page numbers or small tables under 50,000 rows, offset pagination is simpler and completely fine.
How do I design consistent error responses in a REST API?
Define one error schema and use it everywhere. At minimum: a machine-readable code string (e.g. "VALIDATION_ERROR", "NOT_FOUND"), a human-readable message, and an optional details array for field-level errors. The code field is what clients actually switch on in code — it must never change between versions. If you want to follow an industry standard, look at RFC 7807 Problem Details (application/problem+json), which is gaining adoption in OpenAPI-based tooling. The important rule: every 4xx and 5xx response must have a body. A bare status code with no body forces clients to hard-code magic numbers.
What is HATEOAS and do I need it?
HATEOAS (Hypermedia As The Engine Of Application State) is a REST constraint where API responses include links to related actions, so clients discover what they can do next from the response itself rather than from documentation. In practice, almost no production REST API fully implements HATEOAS. It adds response payload size, complicates client code, and the benefits (discoverability, loose coupling) are largely theoretical for most use cases. The GitHub API and a few others include _links objects in responses as a partial HATEOAS implementation. For most APIs: skip it. Focus on consistent URLs, correct status codes, and good documentation instead.
Related reading: Rate Limiting Your API · API Gateway Pattern
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
GraphQL vs REST: Which API Style Should You Use?
GraphQL lets clients request exactly the data they need from a single endpoint, while REST exposes fixed resources over multiple URLs with standard HTTP verbs. Learn when each wins — with real N+1 examples, schema design, performance tradeoffs, tooling, and a decision matrix.
Rate Limiting: How It Works and How to Protect Your API
Rate limiting controls how many API requests a client can make in a time window. Learn the algorithms, Redis implementation, distributed patterns, and HTTP headers — with complete production code.
ACID Properties Explained: Database Transactions
ACID properties (Atomicity, Consistency, Isolation, Durability) guarantee reliable database transactions. Learn how they work, PostgreSQL examples, isolation levels, ACID vs BASE, and common pitfalls.