Web

REST API

Resources as URLs, verbs as methods — HTTP done right

REST (Representational State Transfer) is an architectural style for web APIs where each resource has a URL and operations on it use HTTP methods. GET fetches, POST creates, PUT replaces, DELETE removes. Stateless, cacheable, and uniform — the dominant API style of the modern web, and the contract that GraphQL and gRPC define themselves against.

  • Coined byRoy Fielding (2000 PhD thesis)
  • TransportHTTP (any version)
  • Default body formatJSON (also XML, MessagePack, etc.)
  • StatelessnessEach request carries everything needed to process it
  • VersioningURL path (/v1/), header, or query param
  • Vs GraphQLMany endpoints + fixed responses vs one endpoint + queryable responses

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

REST in practice — resources and verbs

Every "thing" your API operates on becomes a resource at a URL. Operations on it use HTTP methods. A user-management API might look like:

MethodURLActionStatus code
GET/usersList all users (paginated)200
GET/users/42Get user 42200 (or 404)
POST/usersCreate a new user201 Created (with Location header)
PUT/users/42Replace user 42 entirely200 or 204
PATCH/users/42Partial update of user 42200 or 204
DELETE/users/42Remove user 42204 No Content (or 404)
GET/users/42/ordersList user 42's orders (sub-resource)200

The pattern scales: any noun can be a resource. /orders, /orders/123, /orders/123/items, /orders/123/items/5. Every URL identifies one thing; every method does the standard operation on that thing.

REST vs alternatives

RESTGraphQLgRPCSOAP
TransportHTTPHTTP (usually POST to /graphql)HTTP/2 (mostly)HTTP, SMTP, others
SchemaOptional (OpenAPI/Swagger)Required (GraphQL SDL)Required (Protocol Buffers)Required (WSDL/XSD)
Body formatJSON usuallyJSONProtobuf binaryXML
Single endpointNo — many URLsYes — one URL, queries pick fieldsOne URL per RPC methodOne URL per service
Over-fetchingCommon — fixed response shapeEliminated — client picks fieldsLess — typed schemasCommon
Caching (HTTP)Excellent — Cache-Control worksHard — POST to /graphql isn't cachedCustom — outside HTTPHard
ToolingMature — Postman, curl, OpenAPI ecosystemApollo, GraphiQLgrpcurl, BloomRPCSoapUI
Best forPublic APIs, varied consumersTightly coupled clients, many fieldsService-to-service, perf-sensitiveEnterprise legacy, strict contracts

For most public APIs in 2025, REST + OpenAPI is the default choice. GraphQL when over-fetching is a real cost. gRPC for internal microservices.

REST design principles

  1. Resources, not actions. URLs are nouns: /orders/123/refund. The verb is the HTTP method (POST a refund). Avoid /createOrder, /refundOrder — those are RPC, not REST.
  2. Plural nouns for collections. /users for the collection, /users/42 for an individual. Avoid singular vs plural inconsistency (/user for one, /users for many).
  3. Status codes communicate outcomes. 200 for success, 201 for created (with Location header), 204 for "succeeded, no body," 400 for bad input, 404 for missing resource. Don't return 200 with an error body — that breaks every middleware and observability tool.
  4. Idempotency for retry safety. GET, PUT, DELETE should be idempotent. POST shouldn't be — but use Idempotency-Key headers to make it so when the operation matters (payments).
  5. Filter, sort, paginate via query params. /users?role=admin&sort=-created_at&limit=50. Don't put filter logic in the path.
  6. Versioning from day 1. Even if you only have v1, having /v1/ in the path makes it clear there could be a v2 later. Adding /v2/ next to /users later breaks every existing client.
  7. Hypermedia is optional. HATEOAS purists insist; the rest of the industry doesn't bother. OpenAPI specs serve the same documentation purpose.

JavaScript: building a REST client

class UserAPI {
  constructor(baseURL, token) {
    this.baseURL = baseURL;
    this.token = token;
  }

  async _fetch(path, options = {}) {
    const r = await fetch(`${this.baseURL}${path}`, {
      ...options,
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });
    if (!r.ok) {
      const body = await r.text();
      throw new Error(`HTTP ${r.status}: ${body}`);
    }
    return r.status === 204 ? null : r.json();
  }

  list(params = {}) {
    const q = new URLSearchParams(params).toString();
    return this._fetch(`/users${q ? '?' + q : ''}`);
  }
  get(id)            { return this._fetch(`/users/${id}`); }
  create(data)       { return this._fetch('/users', { method: 'POST', body: JSON.stringify(data) }); }
  replace(id, data)  { return this._fetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }); }
  patch(id, changes) { return this._fetch(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(changes) }); }
  delete(id)         { return this._fetch(`/users/${id}`, { method: 'DELETE' }); }
}

const api = new UserAPI('https://api.example.com/v1', token);
const user = await api.get(42);
const updated = await api.patch(42, { email: '[email protected]' });

Python: building a REST server with FastAPI

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class User(BaseModel):
    id: int
    name: str
    email: str

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

users_db = {}  # in-memory for example

@app.get('/v1/users', response_model=list[User])
def list_users(limit: int = 50, offset: int = 0):
    return list(users_db.values())[offset:offset + limit]

@app.get('/v1/users/{user_id}', response_model=User)
def get_user(user_id: int):
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail='User not found')
    return users_db[user_id]

@app.post('/v1/users', response_model=User, status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate):
    new_id = max(users_db.keys(), default=0) + 1
    users_db[new_id] = User(id=new_id, **user.model_dump())
    return users_db[new_id]

@app.delete('/v1/users/{user_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int):
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail='User not found')
    del users_db[user_id]

FastAPI auto-generates OpenAPI docs at /docs from your type hints. Type validation, serialization, and HTTP plumbing are handled automatically.

Pagination patterns

StyleRequestProsCons
Page + per_page?page=2&per_page=50Intuitive, easy to jump pagesDrifts when items added/removed
Offset + limit?offset=100&limit=50Same as page, just different paramsSame drift problem; SQL OFFSET is slow at scale
Cursor-based?cursor=abc123&limit=50No drift, fast at scaleCan't jump to arbitrary pages
Time-based?since=2025-01-01T00:00:00ZNatural for chronological feedsTies API shape to specific data model

For large collections (millions of rows) cursor-based pagination is the standard. Stripe, GitHub, Twitter all use it. The cursor encodes the last-seen item; the server can resume from that point in O(log n) with an indexed query.

Common REST API pitfalls

  • Returning 200 with an error in the body. Breaks observability — monitoring tools count this as success. Use the right status code: 4xx for client errors, 5xx for server errors. The body explains what happened.
  • Inconsistent error formats. Some endpoints return {"error": "..."}, others {"message": "..."}, others raw strings. Pick a schema and apply it everywhere. RFC 7807 (Problem Details for HTTP APIs) is a good standard.
  • Missing pagination on list endpoints. Returning all 100,000 users in one response brings down clients and your own database. Always paginate; default to a sensible page size (50-100).
  • Versioning by mutation. Changing the response shape of /users/42 breaks every existing client. Either don't break the contract within a version, or release v2.
  • POST for everything. Using POST when GET would do the job (for queries) breaks caching and middleware tools. Use the right verb.
  • Mixing query params and body for the same logical operation. Pick one. POST creates with body data; GET filters with query params. Don't accept both for one endpoint.
  • No rate limiting. A buggy or malicious client can exhaust your server. Apply per-token, per-IP rate limits with 429 Too Many Requests and Retry-After header.

Frequently asked questions

What makes an API "RESTful"?

Six constraints from Roy Fielding's thesis. (1) Client-server separation — UI separate from data storage. (2) Stateless — each request contains all info needed; server doesn't track client state between requests. (3) Cacheable — responses are explicit about cacheability. (4) Layered system — clients can't tell if they're talking to the origin server or a proxy. (5) Uniform interface — resources identified by URL, manipulated through representations, self-descriptive messages. (6) Code on demand (optional). Most APIs called "REST" satisfy 1-5; HATEOAS (the discoverability part of #5) is rare in practice.

How is REST different from RPC?

REST is resource-oriented — URLs are nouns (/users/42), HTTP methods are verbs. RPC is action-oriented — endpoints are verbs (/getUser, /deleteUser, /updateUser). REST exposes a uniform interface (any noun supports the same set of verbs); RPC exposes a custom interface per action. REST scales better for public APIs because the constraints make caching, versioning, and tooling more uniform. RPC is faster to implement when you control both ends.

Should I use REST or GraphQL?

REST when consumers vary widely (public API, third-party integrations) — different clients want different fields, but uniform endpoints are easier to cache, document, and rate-limit. GraphQL when consumers are tightly coupled to the server (your own mobile + web apps) and over-fetching/under-fetching is a real cost — clients ask for exactly what they need. gRPC when you control both ends and need maximum performance with strongly-typed contracts.

What HTTP method should I use to update a resource?

PUT replaces the entire resource (idempotent — same effect repeated). PATCH partially updates fields (sometimes idempotent, depends on the operations). POST is also acceptable for updates if you don't want strict semantics, but conflicts with REST conventions. Rule of thumb: PUT for "set this resource to X." PATCH for "change these fields." POST for "create new" or "non-idempotent change."

How do you handle pagination?

Three common approaches. (1) Page-based — `?page=2&per_page=50`. Simple but breaks if items are added/removed during pagination. (2) Cursor-based — `?cursor=abc123`. Server returns opaque cursor for the next page. Survives concurrent modifications. (3) Offset/limit — `?offset=100&limit=50`. Same drift issues as page-based. Cursor-based is the modern best practice for large collections.

How do you version a REST API?

Three options. (1) URL path — `/v1/users` and `/v2/users` coexist. Easy to understand and route. Most common. (2) Header — `Accept: application/vnd.example.v2+json`. Cleaner URLs but harder to test in browsers. (3) Query parameter — `/users?api_version=2`. Easy but pollutes URLs. Path versioning is dominant; pick it unless you have a strong reason otherwise.

What's HATEOAS and do I need it?

Hypermedia As The Engine Of Application State — responses include links telling the client what actions are available. A user response might include `"links": {"orders": "/users/42/orders", "delete": "/users/42"}`. Strict REST requires it; almost no production APIs implement it. The justification is "discoverable APIs" but in practice clients are generated from OpenAPI/Swagger specs anyway. Skip HATEOAS unless you have a specific need.