RESTful API Design: Best Practices and Common Pitfalls

· 9 min read · API Design

Master the art of designing intuitive, consistent, and developer-friendly REST APIs that teams actually enjoy using.

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, or userID? 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:

  1. Predictability — Once developers learn your conventions, they can guess how things work
  2. Self-documentation — Endpoints that communicate their purpose through naming
  3. Debuggability — Error responses that help developers fix issues quickly
  4. 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.