REST — The Architectural Style
Resources, verbs, statelessness, cache, uniform interface, HATEOAS. Fielding's PhD thesis applied to your CRUD endpoints.
What it is#
REST (Representational State Transfer) is an architectural style for distributed hypermedia systems, defined by Roy Fielding in his 2000 PhD thesis. It is not a protocol, not a framework, not a specification — it is a set of six architectural constraints that, when applied together, produce systems with desirable properties (cacheability, scalability, evolvability, partial-failure resilience).
The six constraints, in Fielding’s order:
- Client–server — separate concerns; the client knows about UI, the server knows about storage.
- Stateless — every request carries everything the server needs to process it; no session state on the server between requests.
- Cacheable — responses are explicitly labeled as cacheable or not.
- Uniform interface — a single uniform way to identify and manipulate resources (this constraint expands into four sub-constraints: resource identification via URIs, manipulation through representations, self-descriptive messages, HATEOAS).
- Layered system — intermediaries (caches, gateways, load balancers) are invisible to the client.
- Code on demand (optional) — the server may ship executable code (JavaScript) to extend client behaviour.
REST does not say “use HTTP”. HTTP just happens to be the protocol everyone uses to build RESTful systems, because HTTP was designed by the same person, around the same time, with the same constraints in mind.
When to use it#
Reach for REST when:
- The consumer is a browser or a polyglot ecosystem of clients. REST over HTTP is the most-supported API style in every language, framework, debugger, proxy, and gateway.
- Resources are the right abstraction for the domain. If the verbs you’d want are mostly CRUD-shaped (
POST /orders,GET /orders/123,PATCH /orders/123), REST fits. - Cacheability matters. REST’s cache constraint is load-bearing — CDN-friendly URLs, ETag headers, conditional GETs all fall out of doing REST well.
- You want documentation tooling for free. OpenAPI / Swagger / Redoc generate readable docs straight from the schema.
Avoid REST (or be careful) when:
- The interaction is fundamentally function-call-shaped (
Translate(text, lang),ComputeETA(from, to)). RPC or GraphQL fits better. - The client needs to query arbitrary subsets of a resource graph. GraphQL’s whole reason to exist.
- The traffic is bidirectional or streaming. WebSocket or gRPC streaming, not REST.
- The fan-out across resources is too granular. A mobile screen that needs 12 REST calls before rendering is the canonical “we should have built GraphQL” moment.
How it works#
Resources, identifiers, representations#
In REST, everything addressable is a resource, every resource has a URI, and the server exchanges representations of resources (most commonly as JSON, but XML, HTML, or Protobuf are equally valid).
GET /v1/orders/ord_a3f9c2 HTTP/1.1Host: api.example.comAccept: application/jsonAuthorization: Bearer eyJhbGciOi...HTTP/1.1 200 OKContent-Type: application/jsonETag: "W/c8f3"Cache-Control: private, max-age=60
{ "id": "ord_a3f9c2", "status": "confirmed", "amount": { "value_minor": 4999, "currency": "USD" } }The URL identifies the resource. The verb (GET) says what to do with it. The headers carry metadata. The body carries the representation. Every part is doing one job.
The verb taxonomy#
| Verb | Semantics | Idempotent? | Safe? |
|---|---|---|---|
GET | Read a resource | yes | yes |
POST | Create a resource (server picks ID) | no | no |
PUT | Replace a resource (client picks ID) | yes | no |
PATCH | Partial update | depends | no |
DELETE | Remove a resource | yes | no |
HEAD | Read response metadata (no body) | yes | yes |
OPTIONS | Discover allowed verbs | yes | yes |
Safe means it does not change state on the server (a GET can have side-effects in practice — logging, view counters — but those don’t change resource state). Idempotent means N identical requests have the same effect as 1.
A representative client across three languages#
The same POST /v1/orders call in Python, Go, and Node. The wire format is identical; the language is style.
import requestsimport uuid
resp = requests.post( "https://api.example.com/v1/orders", headers={ "Authorization": "Bearer eyJhbGciOi...", "Idempotency-Key": str(uuid.uuid4()), "Content-Type": "application/json", }, json={ "items": [{"sku": "SKU-X42", "qty": 1}], "shipping_address_id": "addr_18df", }, timeout=5,)resp.raise_for_status()order = resp.json()print(order["id"], order["status"])package main
import ( "bytes" "encoding/json" "fmt" "net/http"
"github.com/google/uuid")
func main() { body, _ := json.Marshal(map[string]any{ "items": []map[string]any{{"sku": "SKU-X42", "qty": 1}}, "shipping_address_id": "addr_18df", })
req, _ := http.NewRequest("POST", "https://api.example.com/v1/orders", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer eyJhbGciOi...") req.Header.Set("Idempotency-Key", uuid.New().String()) req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close()
var order struct{ ID, Status string } json.NewDecoder(resp.Body).Decode(&order) fmt.Println(order.ID, order.Status)}const { randomUUID } = require("crypto");
const resp = await fetch("https://api.example.com/v1/orders", { method: "POST", headers: { Authorization: "Bearer eyJhbGciOi...", "Idempotency-Key": randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ items: [{ sku: "SKU-X42", qty: 1 }], shipping_address_id: "addr_18df", }),});if (!resp.ok) throw new Error(`HTTP ${resp.status}`);const order = await resp.json();console.log(order.id, order.status);Status codes that fit#
REST leans on HTTP’s status taxonomy. The five families:
- 2xx success —
200 OK(read),201 Created(write that produced a new resource),202 Accepted(async),204 No Content(success with no body). - 3xx redirect —
301 Moved Permanently,304 Not Modified(cache hit). - 4xx client error —
400 Bad Request(malformed),401 Unauthorized(missing auth),403 Forbidden(wrong auth),404 Not Found,409 Conflict(state mismatch),422 Unprocessable Entity(semantically wrong),429 Too Many Requests(rate limit). - 5xx server error —
500 Internal Server Error,502 Bad Gateway,503 Service Unavailable,504 Gateway Timeout.
A well-designed REST API picks the right code, every time, consistently. Returning 200 with { "error": "..." } is the most common rookie mistake.
Variants#
| Variant | Mechanism | When it fits |
|---|---|---|
| Strict REST (HATEOAS) | Server returns hypermedia links in every response ({ "_links": { "next": "/orders/124" } }). | Highly evolving APIs where clients should not hard-code URLs. Rare in practice; HAL and JSON:API are the formal forms. |
| REST-ish JSON | Resources + verbs + status codes, but no hypermedia. The de facto industry standard. | Almost every REST API in production today. |
| REST + filtering DSL | Query parameters express filters / sorts / pagination (?status=confirmed&sort=-created_at&limit=20). | Read-heavy APIs where the client needs flexibility without GraphQL’s complexity. |
| REST + bulk endpoints | A POST /orders:batch collective verb when individual creates would N+1. | High-throughput integration APIs (Stripe Invoices, BigQuery jobs). |
Trade-offs#
What REST gives you:
- The biggest tooling ecosystem on the planet. Every language, framework, proxy, and gateway knows REST.
- Cacheability for free. A well-designed REST API gets CDN, browser, and gateway caching by following the constraints.
- Browser-friendliness. REST works in every browser without a code-generated client.
- Evolvability. Adding a new resource, a new endpoint, a new field — all additive. Old clients ignore what they don’t recognise.
What REST costs you:
- Over-fetching and under-fetching. A mobile screen that needs a partial slice of three resources either pays N+1 round-trips or accepts whole-resource bloat. GraphQL exists for this reason.
- Verbosity on internal services. Encoding a function call as a verb-on-resource is sometimes more ceremony than the operation deserves.
- The HATEOAS debate every quarter. Strict REST without HATEOAS is technically not REST; few production systems follow it. You will have this conversation.
- Per-call latency. Each request is a round-trip; HTTP/2 multiplexing helps but doesn’t eliminate the cost.
Common pitfalls#
- Returning
200with an error body. Use the right status code. Clients can’t distinguish “the call succeeded” from “the call failed with a payload” if both come back as 200. - Tunnelling RPC through
POST /do_thing. If your “REST” endpoints are mostly verbs, you’ve built RPC over HTTP. That’s fine — call it RPC and pick the right tooling. - No versioning strategy. Then breaking changes ship inside
/v1and someone’s integration breaks on Tuesday. - Inconsistent error envelopes across endpoints. One endpoint returns
{ "error": "..." }, another returns{ "errors": [...] }, a third returns{ "code": "...", "message": "..." }. Pick one. Document it. - PUT vs PATCH confusion.
PUTreplaces the entire resource (idempotent).PATCHpartially updates (often not idempotent unless you use JSON-Patch or include a version token). - No idempotency keys on
POST. Every retryable write needs one. Stripe’sIdempotency-Keyheader is the industry-standard pattern.
Related building blocks#
- RESTful API Design in Practice — the practical playbook for designing RESTful endpoints (paths, pagination, filtering, error envelopes).
- HTTP — The Foundational Protocol for APIs — the protocol REST stands on; methods, status codes, headers in depth.
- GraphQL — A Query Language for APIs — the query-language alternative that solves REST’s over-fetching problem at the cost of new ones.
- gRPC — Protobuf over HTTP/2 — the polyglot RPC alternative for internal services.
- REST vs GraphQL vs gRPC — Comparison — REST vs GraphQL vs gRPC, honestly.