REST API Design Fundamentals

Learn the core principles of RESTful API design, resource modeling, and HTTP method semantics

Why REST Design Matters

REST (Representational State Transfer) is the architectural style that powers the modern web. Understanding RESTful design principles enables you to build APIs that are intuitive, scalable, and follow industry standards. Whether you're building internal microservices or public APIs, mastering REST design is essential for creating systems that developers love to use.

What is REST?

REST was introduced by Roy Fielding in his 2000 doctoral dissertation. It's an architectural style, not a protocol or standard. REST defines a set of constraints that, when applied to web services, create scalable, maintainable, and performant APIs.

Key Characteristics of REST APIs
  • Stateless: Each request contains all information needed to process it
  • Client-Server: Clear separation between client and server concerns
  • Cacheable: Responses explicitly indicate if they can be cached
  • Uniform Interface: Consistent way to interact with resources
  • Layered System: Client can't tell if connected directly to server or intermediary

The Six REST Constraints

To be considered RESTful, an API should adhere to these six architectural constraints:

1. Client-Server Architecture

Separation of concerns between the user interface (client) and data storage (server). This allows each to evolve independently.

❌ Tightly CoupledServer returns HTML pages with embedded data and logic
✅ REST ApproachServer returns pure data (JSON), client handles presentation
2. Statelessness

Each request from client to server must contain all information necessary to understand and process the request. The server does not store client context between requests.

# ❌ Stateful (Bad)
# Server maintains session data
GET /api/next-item
# Server remembers where you are in pagination

# ✅ Stateless (Good)
# Client sends all context
GET /api/items?page=2&limit=10
# Server doesn't need to remember anything
Note: Authentication tokens (JWT) are passed with each request, making the API stateless even for authenticated users.
3. Cacheability

Responses must define themselves as cacheable or non-cacheable to prevent clients from reusing stale data.

# HTTP Response Headers
HTTP/1.1 200 OK
Cache-Control: max-age=3600, public
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# This response can be cached for 1 hour
# ETag allows conditional requests to check if data changed
4. Uniform Interface

The most important REST constraint. Defines a standard way to interact with resources:

  • Resource identification: Each resource has a unique URI
  • Resource manipulation through representations: Use JSON, XML, etc.
  • Self-descriptive messages: Each message includes enough info to process it
  • Hypermedia (HATEOAS, Hypermedia as the Engine of Application State): Responses include links to related actions
5. Layered System

Client cannot tell whether it's connected directly to the end server or an intermediary. This allows for load balancers, caches, and security layers without client changes.

6. Code on Demand (Optional)

Servers can extend client functionality by transferring executable code (e.g., JavaScript). This is the only optional constraint.

Resource-Oriented Design

In REST, everything is a resource. A resource is any information that can be named: a document, image, collection of items, user, etc. Resources are identified by URIs and manipulated using HTTP methods.

URI Design Principles
❌ Poor URI Design
GET /getUser?id=123
POST /createUser
POST /deleteUser/123
Uses verbs in URLs, inconsistent structure
✅ RESTful URI Design
GET /users/123
POST /users
DELETE /users/123
Uses nouns for resources, HTTP methods for actions
ResourceCollection URISingle Item URINested Resource
Users/users/users/123/users/123/posts
Products/products/products/p-456/products/p-456/reviews
Orders/orders/orders/o-789/orders/o-789/items
URI Design Best Practices
  • Use plural nouns for collections: /users not /user
  • Use lowercase letters and hyphens: /order-items not /orderItems
  • Avoid file extensions: /users/123 not /users/123.json
  • Use query parameters for filtering: /users?role=admin&status=active
  • Keep URIs shallow (max 3 levels): /users/123/posts is good
  • Use path parameters for resource IDs: /users/{userId}

HTTP Methods and Semantics

HTTP methods (verbs) define the action to be performed on a resource. Understanding their semantics and properties is crucial for RESTful design.

GET - Retrieve Resources

SafeIdempotent

Retrieves a representation of a resource. GET requests should never modify data.

# Python FastAPI Example
from fastapi import FastAPI

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    # Retrieve user from database
    user = await db.get_user(user_id)
    return {
        "id": user.id,
        "name": user.name,
        "email": user.email
    }

# Usage:
# GET /users/123
# Returns: {"id": 123, "name": "Alice", "email": "alice@example.com"}
Result: Returns user data without modifying anything on the server.
POST - Create Resources

Not SafeNot Idempotent

Creates a new resource. The server assigns the resource identifier.

from fastapi import FastAPI
from pydantic import BaseModel

class UserCreate(BaseModel):
    name: str
    email: str

@app.post("/users", status_code=201)
async def create_user(user: UserCreate, response: Response):
    # Create new user in database
    new_user = await db.create_user(user.name, user.email)
    # Set Location header pointing to new resource
    response.headers["Location"] = f"/users/{new_user.id}"
    return {
        "id": new_user.id,
        "name": new_user.name,
        "email": new_user.email
    }

# Request:
# POST /users
# Body: {"name": "Bob", "email": "bob@example.com"}

# Response: 201 Created
# Body: {"id": 456, "name": "Bob", "email": "bob@example.com"}
# Header: Location: /users/456
Result: Creates a new user and returns 201 with the created resource.
PUT - Update/Replace Resources

Not SafeIdempotent

Replaces the entire resource. Client provides the full representation.

@app.put("/users/{user_id}")
async def update_user(user_id: int, user: UserCreate):
    # Replace entire user resource
    updated_user = await db.update_user(
        user_id,
        name=user.name,
        email=user.email
    )
    return updated_user

# Request:
# PUT /users/123
# Body: {"name": "Alice Updated", "email": "alice.new@example.com"}

# Response: 200 OK
# Body: {"id": 123, "name": "Alice Updated", "email": "alice.new@example.com"}
Result: Replaces the entire user resource. Calling it multiple times has the same effect.
PATCH - Partial Update

Not SafeNot Necessarily Idempotent

Applies partial modifications to a resource. Only updates specified fields.

from pydantic import BaseModel
from typing import Optional

class UserPatch(BaseModel):
    name: Optional[str] = None
    email: Optional[str] = None

@app.patch("/users/{user_id}")
async def patch_user(user_id: int, updates: UserPatch):
    # Update only provided fields
    user = await db.get_user(user_id)
    if updates.name is not None:
        user.name = updates.name
    if updates.email is not None:
        user.email = updates.email
    await db.save_user(user)
    return user

# Request:
# PATCH /users/123
# Body: {"email": "alice.patched@example.com"}

# Response: 200 OK
# Body: {"id": 123, "name": "Alice", "email": "alice.patched@example.com"}
Result: Only updates the email field, leaving name unchanged.
DELETE - Remove Resources

Not SafeIdempotent

Deletes a resource. Subsequent DELETE requests to the same URI should return 404.

@app.delete("/users/{user_id}", status_code=204)
async def delete_user(user_id: int):
    # Delete user from database
    await db.delete_user(user_id)
    return  # 204 No Content (empty response)

# Request:
# DELETE /users/123

# Response: 204 No Content
# (No response body)
Result: Deletes the user. Returns 204 (No Content). Second DELETE returns 404.
HEAD - Retrieve Headers Only

SafeIdempotent

Identical to GET, but returns only headers without the response body. Useful for checking if a resource exists, getting metadata, or checking cache validity without transferring the full content.

@app.head("/users/{user_id}")
async def head_user(user_id: int):
    # Check if user exists
    user = await db.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)

    # Return headers only (FastAPI handles this automatically)
    return Response(headers={
        "Content-Type": "application/json",
        "Content-Length": str(len(json.dumps(user))),
        "Last-Modified": user.updated_at.isoformat()
    })

# Request:
# HEAD /users/123

# Response: 200 OK
# Content-Type: application/json
# Content-Length: 245
# Last-Modified: 2024-01-15T10:30:00Z
# (No response body)
Result: Returns headers indicating the user exists and its metadata, without sending the actual user data.
OPTIONS - Describe Communication Options

SafeIdempotent

Returns the HTTP methods supported by a resource. Used for CORS preflight requests and API discovery. The server responds with an Allow header listing available methods.

@app.options("/users/{user_id}")
async def options_user(user_id: int):
    return Response(
        status_code=204,
        headers={
            "Allow": "GET, PUT, PATCH, DELETE, HEAD, OPTIONS",
            "Access-Control-Allow-Methods": "GET, PUT, PATCH, DELETE",
            "Access-Control-Allow-Headers": "Content-Type, Authorization"
        }
    )

# Request:
# OPTIONS /users/123

# Response: 204 No Content
# Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
# Access-Control-Allow-Methods: GET, PUT, PATCH, DELETE
# Access-Control-Allow-Headers: Content-Type, Authorization
Result: Informs the client which HTTP methods are allowed for this resource. Commonly used in CORS.
CONNECT - Establish Network Tunnel

Proxy MethodNot Idempotent

Establishes a tunnel to the server identified by the target resource. Primarily used for HTTPS connections through HTTP proxies. Not typically implemented in REST APIs.

# CONNECT is handled by proxy servers, not typically in REST APIs
# Example proxy usage:

# Client → Proxy:
CONNECT api.example.com:443 HTTP/1.1
Host: api.example.com:443

# Proxy → Client:
HTTP/1.1 200 Connection Established

# Then client sends HTTPS traffic through the tunnel
Result: Creates a bidirectional tunnel for secure communication. Rarely used in REST APIs.
TRACE - Message Loop-Back Test

SafeIdempotent

Performs a diagnostic loop-back test along the path to the target resource. The server echoes back the received request so the client can see what was received. Often disabled for security reasons.

# TRACE is typically disabled in production for security
# If enabled, it would look like this:

# Request:
TRACE /users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer token123

# Response: 200 OK
# Content-Type: message/http

TRACE /users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer token123

# Note: Most servers disable TRACE to prevent XST attacks
Result: Echoes the request for debugging. Usually disabled in production environments.
MethodSafeIdempotentCacheablePurpose
GET✅ Yes✅ Yes✅ YesRetrieve resource
POST❌ No❌ No⚠️ RarelyCreate resource
PUT❌ No✅ Yes❌ NoReplace resource
PATCH❌ No⚠️ Maybe❌ NoPartial update
DELETE❌ No✅ Yes❌ NoDelete resource
HEAD✅ Yes✅ Yes✅ YesRetrieve headers only
OPTIONS✅ Yes✅ Yes❌ NoDescribe communication options
CONNECT❌ No❌ No❌ NoEstablish network tunnel
TRACE✅ Yes✅ Yes❌ NoMessage loop-back test
Understanding Idempotency

Idempotent means making the same request multiple times has the same effect as making it once. The outcome is the same, even if the server response might differ.

  • DELETE /users/123 - First call deletes, subsequent calls get 404 (still idempotent)
  • PUT /users/123 - Sets user to same state every time
  • POST /users - Creates a new user each time (NOT idempotent)

HTTP Status Codes Guide

Status codes tell the client what happened with their request. Using the right status code makes your API intuitive and debuggable.

2xx - Success
200 OKStandard success response. Use for successful GET, PUT, PATCH, or DELETE with response body.
GET /users/123 → 200 OK (returns user)
201 CreatedResource successfully created. Include Location header with new resource URI.
POST /users → 201 Created + Location: /users/456
202 AcceptedRequest accepted for processing, but not yet completed. Used for async operations.
POST /reports/generate → 202 Accepted (processing in background)
204 No ContentSuccess but no response body. Common for DELETE operations.
DELETE /users/123 → 204 No Content
4xx - Client Errors
400 Bad RequestInvalid request syntax or malformed data.
POST /users with invalid JSON → 400
401 UnauthorizedAuthentication required or failed. (Note: Despite the name, this is about authentication, not authorization)
GET /users without auth token → 401
403 ForbiddenAuthenticated but not authorized to access this resource.
DELETE /users/999 (not your user) → 403
404 Not FoundResource doesn't exist.
GET /users/99999 → 404
422 Unprocessable EntityRequest is well-formed but has semantic errors (validation failures).
POST /users {email: "invalid"} → 422
429 Too Many RequestsRate limit exceeded. Include Retry-After header.
GET /api/* (100th request) → 429 + Retry-After: 60
5xx - Server Errors
500 Internal Server ErrorGeneric server error. Something went wrong on the server.
GET /users (database crash) → 500
503 Service UnavailableServer is temporarily unavailable (maintenance, overload).
GET /api/* (during maintenance) → 503
# FastAPI Status Code Example
from fastapi import FastAPI, HTTPException, status

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    user = await db.get_user(user_id)

    if not user:
        # Return 404 if user doesn't exist
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"User {user_id} not found"
        )

    # Return 200 with user data
    return user

# Result: GET /users/999 → 404 with error message

Richardson Maturity Model

Leonard Richardson created a maturity model to classify REST APIs based on how RESTful they are. It has 4 levels (0-3):

Level 0: The Swamp of POX (Plain Old XML)

Uses HTTP as a transport mechanism. Everything is POST to a single endpoint. Think SOAP or XML-RPC.

POST /api
{
  "method": "getUser",
  "params": {"id": 123}
}

POST /api
{
  "method": "createUser",
  "params": {"name": "Alice"}
}
Level 1: Resources

Introduces resource-based URIs. Different endpoints for different resources, but still uses only POST.

POST /users/123
{
  "action": "get"
}

POST /users
{
  "action": "create",
  "name": "Alice"
}
Level 2: HTTP Verbs

Uses HTTP methods correctly (GET, POST, PUT, DELETE) and proper status codes.

GET /users/123
→ 200 OK

POST /users
Body: {"name": "Alice"}
→ 201 Created

DELETE /users/123
→ 204 No Content
Level 3: Hypermedia Controls (HATEOAS)

Responses include links to related resources and available actions. The API is self-documenting.

GET /users/123
{
  "id": 123,
  "name": "Alice",
  "links": [
    {"rel": "self", "href": "/users/123"},
    {"rel": "posts", "href": "/users/123/posts"},
    {"rel": "delete", "href": "/users/123", "method": "DELETE"}
  ]
}
Note: Most APIs stop at Level 2. Level 3 is rare in practice.

Common REST Anti-Patterns to Avoid

❌ Verbs in URIs
POST /createUser
GET /getUserById/123
POST /updateUserEmail
URIs should identify resources (nouns), not actions (verbs)
✅ Use HTTP Methods
POST /users
GET /users/123
PATCH /users/123
HTTP methods express the action, URIs identify the resource
❌ Returning 200 for Errors
GET /users/999
200 OK
{"error": "User not found"}
Makes error handling difficult for clients
✅ Use Proper Status Codes
GET /users/999
404 Not Found
{"detail": "User not found"}
Status codes enable proper error handling
❌ Inconsistent Naming
/user (singular)
/Posts (capitalized)
/order_items (snake_case)
/productCategories (camelCase)
Inconsistent naming confuses API consumers
✅ Consistent Convention
/users (plural, lowercase)
/posts (plural, lowercase)
/order-items (kebab-case)
/product-categories (kebab-case)
Stick to one naming convention throughout
❌ Ignoring Caching
GET /users/123
200 OK
(no cache headers)
Misses performance optimization opportunities
✅ Include Cache Headers
GET /users/123
200 OK
Cache-Control: max-age=3600
ETag: "abc123"
Enables client-side caching for better performance

Putting It All Together: Complete FastAPI Example

Here's a complete RESTful API for managing blog posts, demonstrating all the principles we've covered.

from fastapi import FastAPI, HTTPException, status, Response
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime

app = FastAPI(title="Blog API", version="1.0.0")

# Data models
class PostCreate(BaseModel):
    title: str
    content: str
    author_email: EmailStr

class PostUpdate(BaseModel):
    title: Optional[str] = None
    content: Optional[str] = None

class Post(BaseModel):
    id: int
    title: str
    content: str
    author_email: str
    created_at: datetime
    updated_at: datetime

# In-memory database (use real database in production)
posts_db = {}
post_id_counter = 1

# GET /posts - List all posts
@app.get("/posts", response_model=List[Post], status_code=200)
async def list_posts(
    limit: int = 10,
    offset: int = 0,
    author: Optional[str] = None
):
    """
    Retrieve a list of blog posts with pagination.

    - **limit**: Number of posts to return (default: 10)
    - **offset**: Number of posts to skip (default: 0)
    - **author**: Filter by author email (optional)
    """
    all_posts = list(posts_db.values())

    # Filter by author if provided
    if author:
        all_posts = [p for p in all_posts if p["author_email"] == author]

    # Apply pagination
    paginated = all_posts[offset:offset + limit]
    return paginated

# POST /posts - Create a new post
@app.post("/posts", response_model=Post, status_code=201)
async def create_post(post: PostCreate, response: Response):
    """
    Create a new blog post.

    Returns 201 Created with Location header pointing to new resource.
    """
    global post_id_counter

    new_post = {
        "id": post_id_counter,
        "title": post.title,
        "content": post.content,
        "author_email": post.author_email,
        "created_at": datetime.now(),
        "updated_at": datetime.now()
    }

    posts_db[post_id_counter] = new_post

    # Set Location header
    response.headers["Location"] = f"/posts/{post_id_counter}"

    post_id_counter += 1
    return new_post

# GET /posts/{post_id} - Get single post
@app.get("/posts/{post_id}", response_model=Post, status_code=200)
async def get_post(post_id: int):
    """
    Retrieve a specific blog post by ID.

    Returns 404 if post doesn't exist.
    """
    if post_id not in posts_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Post {post_id} not found"
        )

    return posts_db[post_id]

# PUT /posts/{post_id} - Replace entire post
@app.put("/posts/{post_id}", response_model=Post, status_code=200)
async def replace_post(post_id: int, post: PostCreate):
    """
    Replace an entire blog post.

    All fields must be provided. Returns 404 if post doesn't exist.
    """
    if post_id not in posts_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Post {post_id} not found"
        )

    # Keep original created_at, update everything else
    updated_post = {
        "id": post_id,
        "title": post.title,
        "content": post.content,
        "author_email": post.author_email,
        "created_at": posts_db[post_id]["created_at"],
        "updated_at": datetime.now()
    }

    posts_db[post_id] = updated_post
    return updated_post

# PATCH /posts/{post_id} - Partial update
@app.patch("/posts/{post_id}", response_model=Post, status_code=200)
async def update_post(post_id: int, updates: PostUpdate):
    """
    Partially update a blog post.

    Only provided fields are updated. Returns 404 if post doesn't exist.
    """
    if post_id not in posts_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Post {post_id} not found"
        )

    post = posts_db[post_id]

    # Update only provided fields
    if updates.title is not None:
        post["title"] = updates.title
    if updates.content is not None:
        post["content"] = updates.content

    post["updated_at"] = datetime.now()
    posts_db[post_id] = post

    return post

# DELETE /posts/{post_id} - Delete post
@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(post_id: int):
    """
    Delete a blog post.

    Returns 204 No Content on success, 404 if post doesn't exist.
    """
    if post_id not in posts_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Post {post_id} not found"
        )

    del posts_db[post_id]
    return Response(status_code=status.HTTP_204_NO_CONTENT)

# Run with: uvicorn main:app --reload
What Makes This RESTful?
  • Resource-based URIs: /posts and /posts/{post_id}
  • HTTP methods used correctly: GET, POST, PUT, PATCH, DELETE
  • Proper status codes: 200, 201, 204, 404
  • Stateless: Each request is self-contained
  • Consistent naming: Plural nouns, lowercase
  • Query parameters for filtering: ?author=email@example.com
  • Location header: Points to newly created resource

Key Takeaways

  • REST is an architectural style, not a strict protocol. Follow principles, not rigid rules.
  • Resources are nouns, actions are HTTP methods. URIs identify what, methods specify how.
  • Statelessness is crucial for scalability. Each request contains all needed information.
  • Status codes matter. They're the first thing developers check when debugging.
  • Idempotency enables safe retries. GET, PUT, and DELETE should be idempotent.
  • Consistency beats perfection. Pick conventions and stick to them across your entire API.