Warming up the neural circuits...
By the end of this chapter you will:
The moment you ship an , you've made a . Versioning is how you keep that promise while still moving forward.
Picture a highway that 10,000 drivers use every day. You need to add lanes, but you can't shut down the highway for a year. So you build new lanes alongside the old ones, route traffic through both, and when the new lanes are ready, you gradually shift drivers over — with big signs saying "Old exit closing in 6 months." Eventually, the old lanes are demolished.
API versioning is exactly this. Your API has active users (drivers). You can't break their integrations (close the highway). Instead, you build /v2 alongside /v1, communicate deprecation timelines clearly (the signs), and eventually sunset the old version when traffic drops to zero.
But here's what most tutorials miss: versioning is only half the equation. Documentation is the other half. An unversioned API with excellent docs is more usable than a perfectly versioned API with no docs. The two are inseparable — your versioning strategy determines how you structure your docs, and your docs determine whether developers can actually follow your versioning evolution.
Every API versioning strategy boils down to where you put the version identifier. Each has tradeoffs that affect caching, routing, and client complexity.
/v1/, /v2/)// GET /api/v2/users/42
// GET /api/v1/users/42 (old version, still supported)The most common approach. GitHub, Stripe, and most public APIs use this.
Advantages:
location /api/v1/ → old service, location /api/v2/ → new service.Disadvantages:
// Express example: URL versioning with route prefixing
import { Router } from 'express';
const v1Router = Router();
v1Router.get('/users/:id', v1UserController.show);
const v2Router = Router()
// GET /api/users/42
// Header: Accept: application/vnd.myapp.v2+json
// or: API-Version: 2024-03-15Used by Azure DevOps, Twilio, and increasingly popular for internal/ APIs.
Accept header variant (content negotiation):
app.get('/api/users/:id', (req, res) => {
const acceptHeader = req.headers.accept || '';
if (acceptHeader.includes('vnd.myapp.v2')) {
return
Advantages: URL stays clean — same resource identifier regardless of version. Better aligns with REST's content negotiation principle. Easy to default to latest version without client changes.
Disadvantages: Harder to test in browser (can't type a URL into the address bar). Caching proxies may not consider the custom header as part of the key. Debugging is harder — the URL alone doesn't tell you which version is being served.
?version=2)The simplest to implement but the least recommended for public APIs.
For public APIs: URL path versioning (/v1/). It's what every developer expects, works with every tool, and the REST-purist arguments against it are academic — your users care about getting their job done, not about Roy Fielding's dissertation. For internal microservice APIs: date-based header versioning (API-Version: 2024-03-15). It avoids URL pollution in service-to-service calls and makes it easy to deploy breaking changes on a schedule.
The entire point of versioning is to manage breaking changes. But what counts as "breaking"?
Breaking changes (require a new version):
string → number, string → object)Non-breaking changes (safe within a version):
"Be conservative in what you send, be liberal in what you accept" sounds wise, but in API design, being too liberal masks bugs. If a client sends a malformed field that you silently ignore, they never know their integration is broken. Consider being strict: return 400 for unknown fields in request bodies (with a clear error message), but allow unknown fields in responses.
You can't support /v1 forever. Sun-setting requires communication and time:
// Server: Add deprecation headers to v1 responses
app.use('/api/v1', (req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset
The Sunset header (RFC 8594) tells clients exactly when the endpoint will stop working. The Deprecation header signals that the resource is deprecated. The Link header with rel="deprecation" points to documentation about the migration.
Deprecation timeline:
410 Gone for 5% of requests (brownout) on Tuesdays to wake up inattentive clients.410 Gone with a clear migration message.Brownout technique: Temporarily returning errors for a deprecated version forces clients that ignored your deprecation notices to finally pay attention. GitHub and Stripe both use this pattern.
OpenAPI (formerly Swagger) is the industry standard for describing REST APIs. Write a spec, and documentation, client SDKs, and server validation all derive from it.
# openapi.yaml
openapi: 3.1.0
info:
title: My API
version: 2.0.0
description: |
## Deprecation Notice
Version 1 is deprecated and will be sunset on 2025-03-01.
See [migration guide](/docs/migration-v1-v2).
servers:
- url: https://api.example.com/v2
description: Production (v2)
- url: https://api.example.com/v1
description: Production (v1, deprecated)
Generating OpenAPI from code (not the other way around):
The best workflow: your code is the source of truth. Annotations in your code generate the OpenAPI spec. The spec generates documentation.
// Express + swagger-jsdoc approach:
/**
* @openapi
* /users/{id}:
* get:
* summary: Get a user by ID
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: User found
Linting your OpenAPI spec with Spectral:
Spectral is Stoplight's open-source API linter. It catches spec errors and enforces style guides:
# .spectral.yaml
extends: [[spectral:oas, all]]
rules:
path-params-exist: error
operation-operationId: error
my-org-versioning:
description: All paths must include a version prefix
given: $.paths[*]~
then:
function: pattern
functionOptions:
match: ^/api/v\d+/Run in CI:
npx @stoplight/spectral-cli lint openapi.yamlIf the lint fails, the build fails. This prevents undocumented or malformed API changes from reaching production.
GitHub's REST API is one of the largest public APIs, serving billions of requests per day. Their versioning approach is nuanced:
Primary strategy: Accept header with media type. GitHub uses custom media types like application/vnd.github.v3+json. The v3 indicates API version 3 (their current REST API). This is content negotiation — the URL stays the same, the response format changes based on the Accept header.
Secondary strategy: Date-based feature previews. For new features that aren't stable yet, GitHub uses the Accept header with a feature preview name: application/vnd.github.nebula-preview+json. This lets early adopters opt into beta features. When the feature stabilizes, the preview is removed and the feature becomes part of the base version.
Sunset policy: GitHub commits to supporting each API version for at least 24 months after announcing a replacement. They use the Sunset header and send emails to repository owners. Their deprecation window is generous because enterprise customers have slow upgrade cycles.
Documentation: GitHub's API docs are generated from an OpenAPI spec that lives in their open-source rest-api-description repository. The spec is the source of truth; the docs site is a build artifact. This means community contributions (fixing a description typo) go directly to the spec and flow into docs automatically.
The lesson: GitHub treats their API as a product. Versioning, deprecation windows, feature previews, and documentation are all part of that product's user experience.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Releasing v2 by breaking v1 | You've broken every existing integration simultaneously — production outage for all your API consumers | Deploy v2 alongside v1. Announce deprecation. Wait months before removing v1. |
| Versioning too granularly (v1.1.1, v1.2.0) | API consumers don't care about SemVer — they care about whether their integration works | Use integer versions (v1, v2, v3). Reserve minor/patch changes for non-breaking additions. |
| Not documenting breaking changes explicitly | Clients discover breakage at runtime — often in production, often at 3 AM | Maintain a CHANGELOG.md and a migration guide for each major version bump |
| Writing docs manually instead of generating from OpenAPI | Docs drift from implementation. Two sources of truth inevitably contradict | Generate OpenAPI from code → generate docs from OpenAPI. Code is the single source of truth. |
| Using the same error format across versions | v2 might add new error fields that v1 parsers can't handle | Include API version in error responses. v1 clients get v1-formatted errors even on a v2 server. |
| Removing a field without a deprecation period | Clients parsing that field immediately crash | Mark the field as deprecated in docs. Return null for 3 months. Then remove. |
| Skipping API linting in CI | Invalid OpenAPI specs, inconsistent naming, missing descriptions — accumulated tech debt | Run Spectral lint in CI. Fail the build on errors. Treat API specs like code. |
API-Version: 2024-03-15). It's easier to reason about "the March 2024 version" than "v17."Vary: Accept or Vary: API-Version on your cache.Add versioning to an existing Express API: Take a simple API (users, posts) and add URL path versioning. Create /api/v1/users and /api/v2/users with a breaking change (e.g., rename name to fullName in the response). Run both versions simultaneously.
Write an OpenAPI spec: Write an OpenAPI 3.1 YAML spec for a 3-endpoint user API. Include path parameters, query parameters, request bodies, and response schemas. Validate with Spectral.
Implement accept-header versioning: Modify the Express API to use Accept header versioning (application/vnd.myapp.v2+json). Add content negotiation logic that defaults to v1 when no version header is present. Write tests that verify both versions respond correctly.
Automate OpenAPI generation: Set up swagger-jsdoc (or a similar tool for your framework) to generate an OpenAPI spec from JSDoc comments in your code. Add a build script that generates the spec and fails if the spec is invalid. Add Spectral linting to the pipeline.
Build a deprecation system: Implement a complete deprecation pipeline: (a) Add Deprecation and Sunset headers to deprecated endpoints. (b) Log v1 vs v2 usage to a metrics system. (c) Build a dashboard showing migration progress. (d) Implement brownout logic that returns 410 for 10% of deprecated endpoint requests.
Backward-compatible schema evolution: Design a response schema that supports adding fields without breaking v1 clients, removing fields without breaking v2 clients, and changing field types through a transitional period. Implement the server-side transformation logic. Write integration tests that verify both v1 and v2 clients receive correct responses.
Q: What are the three common places to put an API version identifier?
A: (1) URL path (/api/v1/users), (2) HTTP header (Accept: application/vnd.myapp.v2+json or API-Version: 2), (3) Query parameter (/api/users?version=2). URL path versioning is most common for public APIs; header versioning is preferred for internal microservices.
Q: What's the difference between a breaking and non-breaking change? A: A breaking change requires clients to modify their code to continue working (removing a field, changing a type, removing an endpoint). A non-breaking change is backward-compatible (adding a new endpoint, adding an optional field, adding a new enum value). Non-breaking changes don't require a new API version.
Q: What is OpenAPI? A: OpenAPI (formerly Swagger) is a specification for describing REST APIs in a machine-readable format (YAML or JSON). It defines endpoints, parameters, request/response schemas, authentication methods, and server URLs. OpenAPI specs can generate documentation, client SDKs, and server validation code.
Q: You discover a security vulnerability in your v1 API. v1 has 5,000 active clients but is scheduled for sunset in 6 months. Do you patch v1 or force-migrate everyone to v2? A: Patch v1 immediately for security — a known vulnerability is unacceptable regardless of deprecation status. Then accelerate the v1→v2 migration: reduce the sunset window to 2 months, send direct emails to v1 users, implement brownouts (5% of v1 requests return 410) after 1 month to force attention. Security patches transcend versioning strategy.
Q: How do you handle database schema changes across API versions when both versions read from the same database? A: Never let API versioning drive database schema changes directly. The database schema should be designed to support all active API versions. Techniques: (1) Add columns, never remove or rename them until all versions using the old name are sunset. (2) Use database views for version-specific field mapping. (3) Handle field renaming at the API layer with a transformation layer per version.
Q: Your team wants to move from REST to . How do you version this transition for existing REST API consumers? A: GraphQL typically doesn't version its schema (it uses deprecation of individual fields instead). The transition: (1) Deploy the GraphQL endpoint alongside REST. (2) Build the GraphQL schema as a superset of REST functionality. (3) Announce REST v1 is feature-frozen. All new features go to GraphQL. (4) After 6-12 months, announce REST sunset. (5) Provide a migration guide mapping REST endpoints to GraphQL queries. (6) Consider an automatic translation layer (REST→GraphQL proxy) for clients that can't migrate quickly.
API versioning is not about picking the cleverest URL scheme — it's about keeping promises to your developers. URL path versioning (/v1/, /v2/) wins for public APIs because it's universally understood. Header-based versioning shines for internal microservices where URL cleanliness matters. Whatever you pick, the non-negotiable parts are: (1) a clear definition of breaking vs non-breaking changes, (2) a documented deprecation timeline with Sunset headers, (3) OpenAPI specs generated from code and linted in CI, and (4) monitoring that tells you who's still on old versions. Documentation isn't a separate concern — it's the other half of versioning. An API without docs is a puzzle; a versioned API without docs is a puzzle with extra steps.
/v1/) — simplest, most common, works everywhere.Accept or API-Version) — cleaner URLs, harder to test.?version=2) — easiest to implement, least recommended.Sunset header (RFC 8594) tells clients when an endpoint stops working.Which HTTP header (RFC 8594) tells clients the date after which an endpoint will no longer work?
A: Sunset
Adding an optional response field is a breaking or non-breaking change? A: Non-breaking (clients ignore unknown fields)
What tool from Stoplight lints OpenAPI specs? A: Spectral
GitHub uses which versioning strategy for their REST API?
A: Accept header with custom media types (application/vnd.github.v3+json)
What temporary error technique forces clients to notice deprecation? A: Brownouts (returning 410 Gone for a percentage of requests)
True or false: API docs should be served from the same server as the API. A: False — they should be statically generated and served from a CDN or docs subdomain.
What's the minimum recommended deprecation period before sunsetting a public API version? A: 6 months (12 months for enterprise APIs like GitHub)