backend

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.

By Akash Sharma·5 min read
#api
#rest
#backend
#http
#system design
#web development
#best practices

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.

plaintext
# 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/456

Resources 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.

plaintext
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 123

Use the Right HTTP Method

MethodUse forIdempotent?
GETFetch dataYes
POSTCreate new resourceNo
PUTReplace entire resourceYes
PATCHPartial updateUsually yes
DELETERemove resourceYes

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.

plaintext
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 side

Use 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.

json
// 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"
      }
    ]
  }
}
python
# 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 user

Version Your API

When you need to make breaking changes, you need versioning. Otherwise you break existing clients.

plaintext
# URL versioning — most common, explicit
/v1/users
/v2/users
 
# Header versioning — cleaner URLs, harder to test in browser
Accept: application/vnd.example.v2+json

URL 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.

json
// 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.

plaintext
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=electronics

Keep filter parameters consistent. If status is a filter on /users, it should work the same on /orders.

Nest Resources Sparingly

Nested URLs represent relationships.

plaintext
GET /users/123/orders         → orders belonging to user 123
GET /users/123/orders/456     → specific order by user 123

Don'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:

plaintext
GET /members?team_id=3

Key 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.