REST is not a religion. It’s a set of constraints that, when applied thoughtfully, produces APIs that age well — clients can build mental models from them, debugging is straightforward, and the system stays understandable as it grows. When applied dogmatically, REST produces APIs that are obnoxious to use.
This post is about the middle ground. Practical guidance for designing APIs that work in production, drawn from too many years of building and consuming them.
The core idea
A REST API exposes resources. Resources have URLs. You operate on them with HTTP methods. You exchange representations (usually JSON). The HTTP standard does most of the work — your job is to map your domain to it cleanly.
The opposite is RPC: “call this function with these args.” RPC has its place (gRPC, GraphQL), but for most external HTTP APIs, REST hits the right balance of expressiveness and convention.
Resource modeling: nouns, not verbs
Bad:
GET /getUserById?id=42
POST /createPost
POST /deletePostById
POST /publishPost
Good:
GET /users/42
POST /posts
DELETE /posts/{id}
POST /posts/{id}:publish # action on a resource
A resource URL is a noun. The HTTP method is the verb. Don’t put the verb in the URL.
For “actions” that don’t fit cleanly — :publish, :cancel, :lock — Google’s API design guide popularized the colon-suffix convention. Pragmatic; better than POST /publish-post.
Plural collections, singular instances
/users # the collection of users
/users/42 # one user
/users/42/posts # this user's posts (sub-collection)
Always plural at the collection level. Always plural even if there’s only one instance — /users/42 is one user from the users collection.
HTTP method semantics
| Method | Idempotent | Safe | Body | Use for |
|---|---|---|---|---|
GET | Yes | Yes | No | Read |
HEAD | Yes | Yes | No | Read metadata only |
POST | No | No | Yes | Create / non-idempotent action |
PUT | Yes | No | Yes | Replace (full resource) |
PATCH | No (technically) | No | Yes | Partial update |
DELETE | Yes | No | No | Remove |
Idempotent means: the same request can be sent N times with the same effect as sending it once. Safe means: doesn’t change server state.
Get these right and clients can retry safely:
- A network blip during a
PUT? Retry. - A timeout on a
DELETE? Retry. - A timeout on a
POSTto create something? Don’t retry blindly; use idempotency keys (below).
Status codes that matter
You don’t need all 50+ HTTP status codes. You need maybe a dozen, used consistently:
2xx — success
200 OK— generic success.201 Created— successful resource creation. SetLocationheader to the new resource.202 Accepted— request accepted but not yet processed (async work).204 No Content— success, no body. Common forDELETEresponses.
3xx — redirection
301 Moved Permanently— for true API URL changes (rare and dangerous).304 Not Modified— if you supportETag/If-None-Matchfor caching.
4xx — client errors
400 Bad Request— malformed request (invalid JSON, missing required field structure-wise).401 Unauthorized— no/invalid credentials.403 Forbidden— authenticated but not allowed.404 Not Found— resource doesn’t exist (or shouldn’t be revealed to exist).405 Method Not Allowed— wrong method for the URL.409 Conflict— version conflict, duplicate, etc.422 Unprocessable Entity— semantically invalid (validation errors).429 Too Many Requests— rate limited.
5xx — server errors
500 Internal Server Error— unexpected failure.502 Bad Gateway— upstream server returned bad response.503 Service Unavailable— overloaded or down for maintenance.504 Gateway Timeout— upstream server timed out.
Structured error responses
When you return an error, return a structured one. The de facto standard is RFC 7807 / RFC 9457 (application/problem+json):
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://example.com/probs/validation",
"title": "Validation failed",
"status": 422,
"detail": "Email is required and must be valid",
"instance": "/users",
"errors": [
{"field": "email", "code": "required"},
{"field": "age", "code": "min", "limit": 18}
]
}
A simpler in-house format also works fine; the important thing is be consistent across the entire API:
- A machine-readable error code (
"validation_failed"). - A human-readable message.
- Per-field errors when relevant.
If your error format changes between endpoints, clients will hate you.
Pagination
Two strategies. Pick one.
Offset-based
GET /posts?page=3&page_size=20
Returns:
{
"data": [ /* 20 items */ ],
"page": 3,
"page_size": 20,
"total": 1247
}
Pros: simple, supports “jump to page N”.
Cons: unstable (inserts/deletes shift pages); slow on huge tables (OFFSET 100000 is expensive in SQL).
Cursor-based (recommended for big datasets)
GET /posts?cursor=eyJpZCI6MTIzfQ&limit=20
Returns:
{
"data": [ /* 20 items */ ],
"next_cursor": "eyJpZCI6MTQzfQ",
"prev_cursor": null
}
The cursor is an opaque token (often a base64-encoded {id, timestamp}). Pages are stable across inserts and fast on big tables. Lose the ability to jump to “page 50”.
For most APIs, cursor-based wins. For admin dashboards where users want to navigate, offset is fine.
Filtering, sorting, sparse fieldsets
GET /posts?status=published&author_id=42&sort=-created_at&fields=id,title,created_at
?key=valuefor simple equality filters.sort=-fieldfor descending,sort=fieldfor ascending. Comma-separate multiple.fields=to let clients ask for less data — useful for mobile.- For complex filters, JSON in a
?filter=param or a separatePOST /search.
Idempotency keys for unsafe writes
POST /payments is not idempotent — retrying could double-charge. The standard fix: let the client pass an Idempotency-Key header:
POST /payments
Idempotency-Key: 2c8a4a6b-9f3e-4f1c-bb88-5dceaa9b8311
Content-Type: application/json
{ "amount": 5000, "currency": "USD" }
Store the key + response on the server. If the same key arrives again within (e.g.) 24 hours, return the original response — don’t re-execute.
Stripe popularized this pattern. Use it for any POST where retries could cause harm.
Versioning: pick a strategy and commit
Three common approaches:
URL versioning (most common, simplest)
/v1/users
/v2/users
Clear, easy to route, easy to deprecate. Version per major change.
Header versioning
GET /users
Accept: application/vnd.example.v2+json
“Cleaner” URLs. Harder to inspect in logs, harder to test in curl. Generally not worth the friction.
No versioning
Just be additive. Don’t break things. New fields are okay; removing fields requires deprecation.
For APIs with millions of clients, additive evolution is what large platforms (GitHub, Stripe) actually do — explicit versions are reserved for very breaking changes. For a small API, URL versioning is the simplest path that buys you a future.
Authentication
- Bearer tokens in
Authorization: Bearer <token>— works for both session tokens and JWTs. - API keys in a header (
AuthorizationorX-API-Key). Don’t put them in URLs (logs, browser history, referrer leaks). - OAuth 2 for third-party integrations.
Document exactly which endpoints are public, which require auth, and which need elevated permissions. This is one of the most common docs gaps.
For implementation, see JWT Authentication in FastAPI .
Designing for change
The single biggest test of an API: how does it look in 5 years?
- Add fields freely. Clients should ignore unknown fields.
- Don’t remove fields without a deprecation period. Even unused fields. Clients depend on what they think exists.
- Don’t change semantics silently. A
200that suddenly means something else is a footgun. - Document deprecations with
DeprecationandSunsetheaders + a clear migration path. - Avoid magic enum changes. Adding a new enum value can crash clients that switch on the enum exhaustively.
A few more rules I’d die on
- Use ISO 8601 for timestamps (
2026-04-28T15:30:00Z). Always UTC. Never Unix epoch in user-facing fields unless you’re sure. - Use
snake_caseconsistently for JSON fields. OrcamelCaseconsistently. Pick one. Don’t mix. - Plural for arrays.
tagsnottag. - Booleans for binary state, enums for everything else.
is_active: trueis fine;status: "active" | "pending" | "suspended"ages better. - Don’t expose internal IDs you don’t control. UUIDs are fine; auto-incrementing primary keys leak how many records you have.
- Document with OpenAPI and check it into version control. Generated docs are better than written docs that go stale.
Testing your API
- Contract tests — schema validation that catches accidental breaking changes.
- Integration tests that hit real DB → real API → real responses. See Testing FastAPI Apps .
- Documented examples — every endpoint in the docs should have a working request/response example.
When REST isn’t the right answer
REST is great for resource-oriented APIs. It’s awkward for:
- Real-time updates — websockets or SSE, not polling REST.
- Rich querying with deeply nested data — GraphQL.
- Internal microservice RPC — gRPC.
- File uploads/downloads — REST works but feels heavy; consider direct-to-S3 with presigned URLs.
Don’t force REST onto problems it doesn’t fit. Use the right protocol for the job.
Conclusion
Good REST API design is mostly about consistency and respecting HTTP. Use the right method for the right semantics. Return the right status codes. Structure your errors. Pick one pagination, one auth, one versioning, one error format — and stick with all of them across every endpoint.
When you do that, your API stays simple to reason about even at thousands of endpoints. When you don’t, every endpoint is a snowflake — and your docs become a graveyard of “well, this one is different because…”
For implementation specifics, see Building a REST API with Django REST Framework , Getting Started with FastAPI , and Building a REST API in Go with net/http .
Happy designing!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .