RESTful API Design: Best Practices and Common Pitfalls
Good API design is about empathy. Every decision you make affects developers who will integrate with your API—often under deadline pressure, often without direct access to you for questions. A well-designed API reduces their cognitive load and helps them succeed.
After designing and consuming dozens of APIs, I've identified patterns that consistently lead to better developer experience. This guide covers naming conventions, HTTP semantics, error handling, pagination, and the common pitfalls that frustrate API consumers.
Problem
Poorly designed APIs create friction at every integration point:
- Inconsistent naming — Is it
userId,user_id, oruserID? Developers waste time checking every endpoint - Unclear error responses — Generic "Bad Request" errors without actionable details
- Missing pagination — Endpoints that return 10,000 records and time out
- Semantic misuse — POST for fetching data, GET for mutations
- Version chaos — Breaking changes with no migration path
These issues compound across teams and time. Every hour a developer spends deciphering your API is an hour they're not building features.
Why This Matters
APIs are contracts. Unlike internal code that you can refactor easily, APIs have external dependencies that you can't control. Breaking changes break trust.
Good API design provides:
- Predictability — Once developers learn your conventions, they can guess how things work
- Self-documentation — Endpoints that communicate their purpose through naming
- Debuggability — Error responses that help developers fix issues quickly
- Scalability — Patterns that work for both small and large data sets
NOTE: REST isn't the only option. GraphQL, gRPC, and other paradigms have their places. But REST remains the most widely understood API style, making it a solid default choice.
Solution
Resource Naming
URLs should identify resources, not actions. Use nouns, not verbs.
# Wrong - action-based URLs
GET /getUsers
POST /createUser
PUT /updateUser/123
GET /searchProducts?name=widget
# Correct - resource-based URLs
GET /users # List users
POST /users # Create user
GET /users/123 # Get specific user
PUT /users/123 # Replace user
PATCH /users/123 # Partial update
DELETE /users/123 # Delete user
GET /products?name=widget # Search is filtering a collection
Nested Resources
Express relationships through URL hierarchy:
GET /users/123/orders # Orders belonging to user 123
GET /orders/456/items # Items in order 456
POST /users/123/addresses # Create address for user 123
# Avoid excessive nesting - 2 levels is usually enough
# Wrong
GET /users/123/orders/456/items/789/reviews
# Better - use direct resource access
GET /items/789/reviews
TIP: If you need more than two levels of nesting, consider flattening. Deep nesting makes URLs hard to construct and doesn't map well to database queries.
HTTP Methods and Status Codes
Use HTTP semantics correctly—they carry meaning that clients and infrastructure understand.
Methods
| Method | Purpose | Idempotent | Safe |
|---|---|---|---|
| GET | Retrieve resource(s) | Yes | Yes |
| POST | Create resource | No | No |
| PUT | Replace resource entirely | Yes | No |
| PATCH | Partial update | Yes | No |
| DELETE | Remove resource | Yes | No |
# Idempotent means calling multiple times has same effect as once
# PUT /users/123 with same body → same result each time
# POST /users → creates new user each time (not idempotent)
Status Codes
# Success responses
200 OK # Successful GET, PUT, PATCH
201 Created # Successful POST (include Location header)
204 No Content # Successful DELETE
# Client errors
400 Bad Request # Malformed request (invalid JSON, wrong types)
401 Unauthorized # Missing or invalid authentication
403 Forbidden # Authenticated but not authorized
404 Not Found # Resource doesn't exist
409 Conflict # State conflict (duplicate email, version mismatch)
422 Unprocessable # Valid request but semantic errors (validation failed)
429 Too Many Reqs # Rate limited
# Server errors
500 Internal Error # Unexpected server error
502 Bad Gateway # Upstream service failure
503 Unavailable # Service temporarily unavailable
WARNING: Don't use 200 OK for errors with an error message in the body. HTTP clients and monitoring tools rely on status codes.
Implementation: Error Responses
Error responses should be consistent and actionable:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid data",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email must be a valid email address"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Age must be between 18 and 120"
}
],
"request_id": "req_abc123xyz",
"documentation_url": "https://api.example.com/docs/errors#VALIDATION_ERROR"
}
}
Consistent structure in Python:
from fastapi import HTTPException
from pydantic import BaseModel
from typing import Optional
class ErrorDetail(BaseModel):
field: Optional[str] = None
code: str
message: str
class ErrorBody(BaseModel):
code: str
message: str
details: Optional[list[ErrorDetail]] = None
request_id: Optional[str] = None
class APIError(HTTPException):
def __init__(
self,
status_code: int,
code: str,
message: str,
details: list[dict] | None = None
):
self.code = code
self.message = message
self.details = details
super().__init__(
status_code=status_code,
detail={"error": ErrorBody(
code=code,
message=message,
details=[ErrorDetail(**d) for d in details] if details else None
).model_dump(exclude_none=True)}
)
# Usage
raise APIError(
status_code=422,
code="VALIDATION_ERROR",
message="The request contains invalid data",
details=[
{"field": "email", "code": "INVALID_FORMAT", "message": "Invalid email format"}
]
)
Pagination
Always paginate list endpoints. Unpaginated endpoints become time bombs as data grows.
Cursor-Based (Recommended for Large Datasets)
// Request
GET /orders?cursor=eyJpZCI6MTIzfQ&limit=20
// Response
{
"data": [
{"id": "order_124", "total": 99.99},
{"id": "order_125", "total": 149.99}
],
"pagination": {
"next_cursor": "eyJpZCI6MTQzfQ",
"has_more": true
},
"meta": {
"limit": 20
}
}
Cursor-based pagination:
- ✓ Consistent results even when data changes
- ✓ Efficient for large datasets
- ✗ Can't jump to arbitrary page
Offset-Based (Simpler, Good for Smaller Datasets)
// Request
GET /products?page=2&per_page=20
// Response
{
"data": [...],
"pagination": {
"page": 2,
"per_page": 20,
"total_items": 357,
"total_pages": 18
}
}
Offset-based pagination:
- ✓ Familiar, easy to understand
- ✓ Jump to any page
- ✗ Inconsistent with concurrent writes
- ✗ Slow for large offsets (OFFSET 100000)
Filtering, Sorting, and Field Selection
# Filtering - use query parameters
GET /products?category=electronics&price_min=100&price_max=500&in_stock=true
# Sorting - prefix with - for descending
GET /products?sort=price # Ascending
GET /products?sort=-created_at # Descending
GET /products?sort=-rating,price # Multiple fields
# Field selection (sparse fieldsets)
GET /users/123?fields=id,name,email
# Combining
GET /products?category=electronics&sort=-rating&fields=id,name,price&limit=10
Implementation with Pydantic:
from fastapi import Query
from enum import Enum
from typing import Optional
class SortOrder(str, Enum):
price_asc = "price"
price_desc = "-price"
rating_desc = "-rating"
created_desc = "-created_at"
@router.get("/products")
async def list_products(
category: Optional[str] = None,
price_min: Optional[float] = Query(None, ge=0),
price_max: Optional[float] = Query(None, ge=0),
in_stock: Optional[bool] = None,
sort: SortOrder = SortOrder.created_desc,
fields: Optional[str] = None,
cursor: Optional[str] = None,
limit: int = Query(20, ge=1, le=100)
):
# Parse sort
sort_field = sort.value.lstrip("-")
sort_desc = sort.value.startswith("-")
# Build query...
Example: Versioning
Plan for breaking changes from day one.
URL Versioning (Recommended)
https://api.example.com/v1/users
https://api.example.com/v2/users
Advantages:
- Clear and explicit
- Easy to route at infrastructure level
- Simple to test different versions
Header Versioning
GET /users
Accept: application/vnd.myapp.v2+json
Advantages:
- Cleaner URLs
- Easier content negotiation
Whatever you choose, be consistent. Document your versioning policy and deprecation timeline.
Rate Limiting
Protect your API and communicate limits clearly:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1640995200
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
Common Mistakes
1. Using POST for Everything
# Wrong - using POST to fetch data
POST /api/getUser
{"user_id": "123"}
# Correct - GET for retrieval
GET /users/123
2. Returning 200 with Error Body
# Wrong - HTTP 200 but actually an error
HTTP/1.1 200 OK
{"success": false, "error": "User not found"}
# Correct - proper status code
HTTP/1.1 404 Not Found
{"error": {"code": "NOT_FOUND", "message": "User not found"}}
3. Inconsistent Response Shapes
# Wrong - different shapes for same resource
GET /users → {"users": [...]}
GET /users/123 → {"id": "123", "name": "..."}
# Correct - consistent envelope or no envelope
GET /users → {"data": [...], "pagination": {...}}
GET /users/123 → {"data": {"id": "123", "name": "..."}}
4. Ignoring Content-Type
# Always specify and respect Content-Type
Content-Type: application/json
Accept: application/json
# Return 415 Unsupported Media Type for unknown content types
# Return 406 Not Acceptable if you can't produce requested format
5. No Request/Response Examples in Docs
Documentation without examples is incomplete. Always include:
- Request example (with all required fields)
- Response example (success case)
- Error example (at least one error case)
Conclusion
Good API design compounds over time. Early decisions about naming, error handling, and pagination establish patterns that developers learn to trust and predict.
Design for the developer who will integrate with your API at 11 PM trying to ship a feature. Make errors clear, keep responses consistent, and respect HTTP semantics. Your API is a product—treat it like one.
The extra time spent on thoughtful design is repaid many times over in reduced support burden and faster integrations.