Concept
The beginner framing: once an API has real external clients calling it, you can't just change it whenever you want, a field rename, a status code change, or a response shape tweak that seems harmless internally can break every client depending on the old shape, silently, in production, at a time you don't control.
Versioning strategies, three real options, three real tradeoffs
URL PATH VERSIONING: GET /v2/users/42
HEADER/CONTENT-NEGOTIATION: GET /users/42
Accept: application/vnd.myapi.v2+json
NO VERSIONING (evolvable): GET /users/42
(only ADD fields, never remove/rename, old clients ignore new fields)URL path versioning (/v1/, /v2/) is the most common in practice, it's visible, cacheable per-version, and trivially routable (even at the load-balancer/infra level, before any application code runs), at the cost of "polluting" the URL with something that isn't really part of the resource's identity, and creating pressure to maintain parallel full implementations. Header/content-negotiation versioning (Accept: application/vnd.myapi.v2+json) keeps URLs clean and is more aligned with REST's original content-negotiation model, at the cost of being much less visible/discoverable (you can't tell the version from the URL alone) and harder to test casually in a browser. No explicit versioning (an "evolvable" API) avoids the whole problem by committing to strict backward-compatible evolution rules, only ever ADD optional fields, never remove or rename existing ones, never change a field's type or meaning, pushing the discipline into engineering process rather than URL/header mechanics; this is genuinely viable for internal or tightly-controlled-client APIs, much riskier for public ones with clients you don't control.
RFC 9457, the actual current standard for error responses
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Account balance is $12.50, transfer requires $50.00.",
"instance": "/accounts/42/transfers/8f9e1c",
"balance": 12.50,
"required": 50.00
}RFC 9457 ("Problem Details for HTTP APIs," which obsoletes the older RFC 7807) defines a real, standardized shape for error responses, not just a convention someone made up. type is a URI identifying the specific error category (dereferenceable documentation, ideally, though it doesn't have to resolve to anything); title is a short, human-readable summary that should be the SAME for every occurrence of this type (don't put per-request detail in title); status mirrors the HTTP status code (redundant with the actual response status, but useful when the problem body is logged/stored separately from the HTTP envelope); detail is the specific, request-scoped explanation; instance optionally identifies this specific occurrence. Extension members (balance, required above) can be added freely for domain-specific detail. Using the real application/problem+json media type (not just application/json) lets clients and tooling recognize "this response follows the standard error shape" programmatically, rather than needing bespoke per-API error-parsing logic.
Try It
Predict the compatibility outcome before checking the solution.
// v1 response shape, already shipped to real clients:
{ "userId": 42, "userName": "asamit78" }
// A developer wants to change userName's type from a plain string
// to a structured object with first/last, WITHOUT bumping the version:
{ "userId": 42, "userName": { "first": "Amit", "last": "S" } }Under a strict "evolvable, no explicit versioning" API discipline, is this specific change safe to ship without a version bump?
Solution
No, this breaks backward compatibility, even though nothing was "removed." The evolvable-API discipline specifically requires never changing a field's TYPE or MEANING, only adding new optional fields. An existing client that does response.userName.toUpperCase() (treating it as a string) will throw a runtime error the moment this ships, since userName is now an object. The backward-compatible way to add structured name data would be to ADD a new field (userNameStructured: { first, last }) alongside the existing userName string, leaving old clients completely unaffected, and only removing the old field in a genuinely new major version (with its own migration period) once clients have had time to move to the new field. This is exactly the discipline "no versioning" requires in exchange for avoiding the URL/header versioning mechanics, it's not less work, it's different work, front-loaded into every single change rather than batched into occasional version bumps.
Implement It Yourself
Build a minimal RFC 9457 problem-response helper, the actual shape-generation logic an API's error-handling middleware implements:
function problemResponse({ type, title, status, detail, instance, ...extensions }) {
return {
status,
headers: { "Content-Type": "application/problem+json" },
body: JSON.stringify({ type, title, status, detail, instance, ...extensions }),
};
}
// Usage in a route handler:
function transferFunds(accountId, amount, currentBalance) {
if (amount > currentBalance) {
return problemResponse({
type: "https://api.example.com/errors/insufficient-funds",
title:
The mechanism: every error path in the API funnels through one consistent shape-builder, guaranteeing every error response, regardless of which route or developer wrote it, carries the same standardized type/title/status/detail structure with the correct media type. This is what lets a client write ONE generic error-parsing function (if (response.headers["content-type"] === "application/problem+json") { ... }) instead of bespoke per-endpoint error handling.
Under the Hood
This topic builds directly on the status-code discipline established in REST Design & Best Practices, RFC 9457's status field is meant to mirror the ACTUAL HTTP response status honestly, the same discipline that topic covers (never 200-with-error-body). Documenting a versioned API's contract, including its error shapes, formally is exactly what API Documentation (OpenAPI/Swagger) covers mechanically, generating that documentation from (or alongside) the real implementation.
Common Mistakes
1. Inventing a bespoke error shape instead of using the standard
{ "error": true, "msg": "bad request", "errorCode": 42 } // ❌ ad-hoc, no standard, every API differsA one-off error shape means every client has to write bespoke parsing logic for THIS specific API, and the shape itself carries no guarantees about consistency across different error types within the same API. RFC 9457 exists specifically so this doesn't need to be reinvented per-project.
2. Treating "no versioning" as "no discipline required"
// v1 shipped, then someone quietly renames a field:
{ "user_name": "..." } → { "userName": "..." } // ❌ silently breaks every existing clientSkipping explicit version numbers doesn't mean skipping compatibility discipline, it means the discipline has to be enforced on every single change, continuously, rather than batched into occasional version bumps. This requires MORE ongoing rigor, not less.
3. Version-bumping the entire API for a change that only affects one endpoint
/v2/ // bumped for a change that only touched POST /orders, every OTHER endpoint now has a pointless duplicateA global version bump forces clients using completely unrelated endpoints to also migrate, or forces the team to maintain two full parallel implementations for a change that only actually affected one resource. Finer-grained versioning (per-resource, or evolvable-by-default with versioning reserved for genuinely breaking changes) avoids this unnecessary churn.
Best Practices
- Use RFC 9457
application/problem+jsonfor error responses, a real standard, not a bespoke shape, letting clients write generic error-handling logic. - Pick ONE versioning strategy deliberately and document the compatibility promise it implies, URL path (visible, infra-routable), header-based (clean URLs, less discoverable), or evolvable/no-versioning (requires continuous backward-compat discipline).
- Under evolvable/no-versioning, only ever ADD optional fields, never remove, rename, or change the type/meaning of an existing field without a genuine version bump.
- Scope version bumps to what actually changed, avoid forcing a global major-version migration for a change that only affects one resource or endpoint.
- Give every distinct error
typea stable, consistenttitle, put the request-specific detail indetail, nottitle, so clients can reliably group/match on .
Performance Tips
- Maintaining multiple full API versions in parallel (a real cost of aggressive URL-path versioning) has a genuine ongoing engineering cost, every bugfix potentially needs to be applied N times across N live versions, which is a maintenance-velocity cost as much as a compute one.
- A consistent, machine-parseable error format (RFC 9457) reduces client-side complexity and bug surface, generic error-handling code is both simpler AND less likely to mishandle an edge case than N different bespoke per-endpoint error parsers.
