REST API Design Best Practices That Actually Matter
Learn REST API design best practices: URL structure, HTTP methods, status codes, versioning, and error handling. Build APIs developers love to use.
A good API is a pleasure to use. A bad one generates confused Slack messages and angry Stack Overflow questions.
After building APIs in Python and Go for 6 years, here are the practices that actually make a difference.
Use Nouns for Resources, Not Verbs
The URL should identify what you're working with, not what you're doing. The HTTP method (GET, POST, PUT, DELETE) tells you the action.
# Bad — verbs in URLs
GET /getUser/123
POST /createOrder
POST /deleteProduct/456
# Good — nouns in URLs, methods for actions
GET /users/123
POST /orders
DELETE /products/456Resources are nouns. Always.
Use Plural Nouns for Collections
Consistency matters. If /users returns a list of users, then /users/123 returns one user from that collection. Always plural.
GET /users → list of users
GET /users/123 → user with ID 123
POST /users → create a new user
PUT /users/123 → update user 123
DELETE /users/123 → delete user 123Use the Right HTTP Method
| Method | Use for | Idempotent? |
|---|---|---|
| GET | Fetch data | Yes |
| POST | Create new resource | No |
| PUT | Replace entire resource | Yes |
| PATCH | Partial update | Usually yes |
| DELETE | Remove resource | Yes |
Idempotent means calling it multiple times has the same effect as calling it once. PUT /users/123 with the same data should always produce the same result. POST /users creates a new user each time.
Return the Right HTTP Status Codes
This is where many APIs fail. Generic 200 OK for everything, including errors, is a red flag.
200 OK — successful GET, PUT, PATCH
201 Created — successful POST (return the created resource)
204 No Content — successful DELETE (nothing to return)
400 Bad Request — invalid input from client
401 Unauthorized — missing or invalid auth token
403 Forbidden — authenticated but not allowed
404 Not Found — resource doesn't exist
409 Conflict — resource already exists (duplicate email)
422 Unprocessable — validation errors
429 Too Many Requests — rate limited
500 Internal Error — something broke on your sideUse status codes correctly. It lets clients handle errors without parsing response bodies.
Return Useful Error Messages
Don't just return 400 Bad Request. Tell the client what's wrong.
// Bad error response
{
"error": "Bad Request"
}
// Good error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
},
{
"field": "age",
"message": "Must be at least 18"
}
]
}
}# FastAPI example with proper validation errors
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
age: int
@app.post("/users", status_code=201)
async def create_user(request: CreateUserRequest):
# FastAPI + Pydantic automatically returns 422 with details on validation failure
user = await db.create_user(request.dict())
return userVersion Your API
When you need to make breaking changes, you need versioning. Otherwise you break existing clients.
# URL versioning — most common, explicit
/v1/users
/v2/users
# Header versioning — cleaner URLs, harder to test in browser
Accept: application/vnd.example.v2+jsonURL versioning is simpler and more visible. Use it.
Keep v1 running while you migrate clients to v2. Don't delete old versions until no clients are using them.
Design Consistent Pagination
Never return unlimited results. Paginate collections.
// Cursor-based pagination (better for large datasets)
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
{
"data": [...],
"pagination": {
"cursor": "eyJpZCI6MTQzfQ",
"has_more": true,
"limit": 20
}
}
// Offset pagination (simpler, but slower on large tables)
GET /users?page=2&limit=20
{
"data": [...],
"pagination": {
"page": 2,
"total": 1540,
"total_pages": 77,
"limit": 20
}
}For small datasets (under 10,000 rows), offset pagination is fine. For large or real-time datasets, use cursor-based — it's faster and handles concurrent inserts correctly.
Filtering, Sorting, and Searching
Let clients filter and sort without requiring new endpoints.
GET /users?status=active&country=IN
GET /users?sort=created_at&order=desc
GET /users?search=akash
GET /products?min_price=100&max_price=500&category=electronicsKeep filter parameters consistent. If status is a filter on /users, it should work the same on /orders.
Nest Resources Sparingly
Nested URLs represent relationships.
GET /users/123/orders → orders belonging to user 123
GET /users/123/orders/456 → specific order by user 123Don't go more than 2 levels deep. /companies/1/departments/2/teams/3/members/4 is hard to read, remember, and maintain.
For deeper relationships, use query parameters instead:
GET /members?team_id=3Key Takeaways
- Use nouns in URLs, HTTP methods for actions
- Always return the correct HTTP status code
- Error responses should tell the client exactly what went wrong
- Version your API from the start —
/v1/... - Paginate all collection endpoints
- Keep nesting to 2 levels max; use query params for deeper relationships
- Be consistent — if something works one way on
/users, it works the same on/orders
The best API is the one developers can use without reading the documentation.
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
Rate Limiting: Protect Your API From Being Overwhelmed
Learn how to implement rate limiting with Redis. Covers token bucket, fixed window, and sliding window algorithms with Python and Go examples.
Distributed Tracing: Debug Requests Across Services
Learn how distributed tracing works and how to implement it. Covers trace IDs, spans, OpenTelemetry, Jaeger, and how to find performance bottlenecks in microservices.
Saga Pattern: Distributed Transactions Without 2PC
Learn how the saga pattern handles distributed transactions in microservices. Covers choreography vs orchestration, compensating transactions, and real examples.