API versioning
Strategies for evolving APIs without breaking clients, including URI versioning, Stripe's date-based model, content negotiation, and the compatibility rules that let you ship changes safely.
TL;DR
- API versioning lets you evolve an API over time without forcing all clients to update simultaneously.
- Three main strategies: URI versioning (
/v1/,/v2/), header versioning (Stripe'sAPI-Version: 2024-01-01), and content negotiation (Accept: application/vnd.api+v2+json). - URI versioning is the most common in practice. Header versioning is ideal for Stripe-style date-based APIs with many incremental changes.
- Backward-compatible changes (add optional fields, add new endpoints) ship without a version bump. Breaking changes (rename/remove fields, change types) require a new version.
- The real challenge is not picking a strategy but managing the lifecycle: routing, deprecation, monitoring, and eventual sunset.
The Problem It Solves
Your team ships a REST API for a mobile app. Version 1.0 of the app hits GET /users/123 and expects a response with a name field (a single string). Six months later, product requirements change: you need to split name into firstName and lastName.
You deploy the change on a Friday afternoon. The new API returns { "firstName": "Jane", "lastName": "Doe" } and removes the old name field. Monday morning, your support queue explodes. Every user running an older version of the mobile app (which is 70% of your install base, because mobile update cycles are slow) sees a blank name field. The app was reading response.name, which is now undefined.
I've watched this exact scenario play out at three different companies. The root cause is always the same: the API has multiple clients that deploy on their own schedule. You cannot coordinate a synchronized update across mobile apps, third-party integrations, partner systems, and internal services. API versioning creates a contract: "v1 clients will always get v1 responses until you choose to migrate."
What Is It?
API versioning is a strategy for maintaining multiple behavioral contracts for the same API simultaneously, so old clients continue working while new clients use updated functionality.
Think of it like a restaurant menu. When the chef redesigns the menu (new dishes, reorganized categories, different prices), they don't yank the old menus out of customers' hands mid-meal. Customers who already ordered keep their existing menu. New customers get the new menu. And the kitchen can serve both, because each order references the menu version the customer was reading.
The same principle applies to APIs. Old clients continue calling the old contract. New clients opt into the new contract. The server knows which contract each request expects and responds accordingly.
How It Works
Let me walk through a single request to show how versioning works end-to-end, using the most common approach (URI versioning with gateway routing):
Step 1. Client sends a request with the version identifier (in the URL, header, or media type).
Step 2. The API gateway inspects the version and routes to the correct backend. For URI versioning, this is simple path-based routing. For header versioning, the gateway inspects the API-Version header.
Step 3. The versioned backend reads from the same database but transforms the response into the format that version's contract promises. The v1 service concatenates first_name + last_name into a single name field. The v2 service returns them separately.
Step 4. Client receives a response matching the contract it was written against.
// Gateway routing config (nginx example)
// /v1/* → upstream_v1
// /v2/* → upstream_v2
// v1 handler: maintains backward compatibility
app.get('/v1/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
name: `${user.first_name} ${user.last_name}`, // v1 contract
email: user.email,
});
});
// v2 handler: new contract with split name fields
app.get('/v2/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
firstName: user.first_name, // v2 contract
lastName: user.last_name,
email: user.email,
});
});
Key Components
| Component | Role |
|---|---|
| Version identifier | The signal in each request that declares which API contract the client expects (URL prefix, header, media type). |
| API gateway / router | Inspects the version identifier and routes to the appropriate backend or transformer. |
| Versioned handler | The code that implements a specific version's contract, reading shared data but shaping responses differently. |
| Compatibility rules | The documented list of what constitutes a breaking vs. non-breaking change, used to decide when a new version is required. |
| Deprecation policy | Timeline and process for sunsetting old versions: announcement, sunset headers, usage monitoring, eventual removal. |
| Version usage metrics | Per-version, per-client request counts that inform deprecation decisions and migration priority. |
| Migration guide | Documentation that tells clients how to move from version N to version N+1, including field mappings and behavioral changes. |
Types / Variations
URI Versioning
GET /v1/users/123
GET /v2/users/123
Version in the URL path. The most widely adopted approach. Simple to route at the gateway, visible in logs and metrics, and requires no special client configuration.
The downside: it violates REST semantics (the same resource shouldn't exist at two URLs), and it encourages version proliferation without cleanup. I've seen codebases with /v1/ through /v7/ where nobody remembers which versions have active clients.
Used by: GitHub REST API, Twilio, Google Cloud APIs.
Header Versioning (Stripe Model)
GET /users/123
API-Version: 2024-01-01
Version specified in a request header. Stripe pioneered the date-based variant: clients pin to the API behavior date they tested against. Stripe maintains backward compatibility by default but lets clients opt into newer behavior by setting a later date.
This is elegant for APIs that evolve incrementally. Instead of major version jumps, each API change gets a date, and clients move forward at their own pace. Stripe maintains over 100 dated API versions simultaneously.
The downside: versions are invisible in URLs, so you can't test in a browser by changing the URL, and log/metric enrichment requires explicit header extraction.
Used by: Stripe, Shopify (date-based), Microsoft Azure (some services).
Content Negotiation
GET /users/123
Accept: application/vnd.myapi.v2+json
Content-Type: application/vnd.myapi.v2+json
Version embedded in the media type. The most REST-pure approach: clients advertise the representation format they want, servers respond with what they support.
In practice, very few public APIs use this. The syntax is cumbersome, client libraries don't support it natively, and developers find it surprising. I'd recommend it only for internal APIs in organizations that enforce strict content type contracts.
GraphQL's Approach: No Versions
GraphQL takes a fundamentally different stance: don't version at all. Instead, evolve the schema additively. Add new fields freely. Mark old fields as @deprecated with a reason. Clients query only the fields they need, so adding new fields never breaks existing queries.
type User {
id: ID!
name: String @deprecated(reason: "Use firstName and lastName")
firstName: String!
lastName: String!
}
This works because GraphQL clients explicitly declare their data requirements. A client querying { user { name } } keeps working even after firstName and lastName are added. The server knows exactly which fields each client depends on.
The downside: you can never remove a field. @deprecated is a hint, not enforcement. Fields accumulate forever unless you have tooling to detect unused fields and aggressive deprecation policies.
Strategy Comparison
| Dimension | URI versioning | Header versioning | Content negotiation | GraphQL (no versions) |
|---|---|---|---|---|
| Visibility | Obvious in logs/URLs | Hidden in headers | Hidden in headers | N/A |
| Gateway routing | Simple path match | Header inspection | Media type inspection | Schema introspection |
| Client complexity | Change URL prefix | Add/change header | Set Accept header | Query only needed fields |
| REST purity | Low (duplicate URLs) | Medium | High | N/A (different paradigm) |
| Granularity | Major versions only | Per-change (date) | Per-representation | Per-field |
| Adoption | Very high | Growing (Stripe effect) | Rare | Growing |
For your interview: say "URI versioning for public REST APIs because it's the simplest to reason about and route. Header versioning for APIs with frequent incremental changes, using Stripe's date-based model as reference." That covers 90% of what interviewers want to hear.
Backward-Compatible vs. Breaking Changes
Knowing what requires a version bump and what doesn't is the most practical skill in API versioning. Get this wrong and you either break clients unnecessarily (version bump for a non-breaking change) or break them silently (no version bump for a breaking change).
Safe changes (no version bump needed)
- Add a new optional request field
- Add a new field to the response
- Add a new endpoint
- Add a new optional query parameter
- Add a new enum value (if clients ignore unknown values)
- Change a field from required to optional
These are safe because clients that don't know about the new field will ignore it. The key principle: additive changes are safe; subtractive and mutative changes are not.
Breaking changes (require a version bump)
- Remove a response field
- Rename a field
- Change a field's type (string to integer, string to object)
- Change authentication scheme
- Change a previously optional field to required
- Remove an endpoint
- Change pagination behavior
- Change error response format
Each of these breaks clients written against the old behavior. The most insidious one is changing a field's type. If age goes from string "25" to integer 25, every client that does string operations on it silently breaks.
Adding a required field is a breaking change
A common mistake: adding a new required field to a request body. Existing clients don't send it, so their requests start failing validation. If you need to add a field, make it optional with a sensible default, then migrate clients, then make it required in the next version. This is the expand-and-contract pattern.
Versioning Architecture
At scale, you have multiple API versions running simultaneously. Three implementation approaches, each with distinct trade-offs:
Option 1: Version conditionals in the codebase. The handler checks the version and branches. Simple for 2 versions, nightmarish at 5+. Every endpoint accumulates if/else branches. Testing multiplies combinatorially. I've seen codebases where removing a single field required auditing 40 conditional blocks.
Option 2: Separate deployments per version. /v1/* routes to a pinned v1 deployment, /v2/* routes to the current code. Clean separation: v1 code never changes after launch. The cost is operational: you're running and monitoring multiple deployments, and shared database schema changes affect all versions.
Option 3: Gateway request/response transformers. A single service runs the latest version. The gateway intercepts v1 requests, transforms them to v2 format, forwards to the service, then transforms v2 responses back to v1 format. Clean service code (only one version), but the transformer layer becomes a source of bugs when field mappings are non-trivial.
My recommendation: start with Option 3 for minor version differences (field renames, format changes). Use Option 2 for major version gaps where the business logic itself has changed. Avoid Option 1 unless your API has exactly 2 versions and you plan to sunset v1 soon.
Expand-and-Contract Migration
When you need to make a breaking change without a hard version cutover, use the expand-and-contract pattern. This is how Stripe and GitHub ship breaking changes gradually.
Phase 1 (Expand): Add the new fields alongside the old ones. Both name and firstName/lastName appear in the response. New clients use the new fields. Old clients continue reading the old field. No breakage.
Phase 2 (Migrate): Monitor which clients still read the old field (via usage metrics or explicit client versioning). Reach out to high-traffic consumers. Provide migration guides.
Phase 3 (Contract): Once the old field has zero (or negligible) usage, remove it. If any clients can't migrate, create a new API version that excludes the old field and keep them on the old version.
This pattern is slower than a hard version bump but avoids the "Big Bang migration" problem where you need every client to update simultaneously.
Deprecation Lifecycle
Deprecation is where most teams fail. They announce a sunset, nobody migrates, and the deprecated version lives forever.
Key practices:
- Sunset header: Add
Sunset: Sat, 01 Mar 2025 00:00:00 GMTandDeprecation: trueto every response from the deprecated version. Clients that check these headers can auto-alert. - Usage dashboards: Track requests per version, per client (via API key). Don't sunset a version with 10,000 daily requests from a paying customer.
- Graduated response: Start returning
Warningheaders, then degrade rate limits on the deprecated version, then return 410 Gone. Give clients escalating motivation to migrate. - Long tail: Keep returning 410 (not 404) for at least 6 months after sunset. A 410 tells the client "this existed but was removed," which is far more debuggable than a 404.
Trade-offs
| Advantage | Disadvantage |
|---|---|
| Clients upgrade on their own schedule | Operational cost of maintaining multiple versions simultaneously |
| Breaking changes ship without breaking anyone | Version proliferation if deprecation is not enforced |
| Clear contract between client and server | Testing surface multiplies with each active version |
| Gateway routing enables clean separation | Shared database schema changes affect all versions |
| Enables gradual migration (expand-and-contract) | Documentation burden: every version needs its own reference |
| Usage metrics inform data-driven deprecation | Legacy versions accumulate technical debt |
The fundamental tension is evolution speed vs. stability. Aggressive versioning lets you ship fast but fragments the client ecosystem. Conservative versioning keeps things simple but slows down API evolution and forces workarounds.
When to Use It / When to Avoid It
Use API versioning when:
- You have external consumers (mobile apps, third-party integrations) that you cannot force to update
- Your API is a product (Stripe, Twilio, GitHub) with paying customers depending on stability
- Multiple internal teams consume your API and deploy on independent schedules
- You anticipate breaking changes in the API's data model or behavior
Avoid formal versioning when:
- The API has a single consumer that you control (e.g., your own frontend). Just coordinate deploys.
- You can use the expand-and-contract pattern for all changes without formal version numbers
- You're using GraphQL with additive-only schema evolution (field deprecation handles it)
- The API is internal, all consumers are in the same deployment pipeline, and you can do synchronized releases
If you have one frontend and one backend and they deploy together, versioning is overhead. Just make your changes, deploy both, done. Versioning solves the coordination problem, and if you don't have a coordination problem, you don't need it.
Real-World Examples
Stripe
Stripe's API uses date-based header versioning with over 100 API versions maintained simultaneously. When you create a Stripe account, your API key is pinned to the current API version date. All requests from that key get behavior consistent with that date, regardless of changes Stripe ships later. Developers can explicitly opt into newer versions by setting the Stripe-Version header. This model lets Stripe ship multiple breaking changes per month without affecting any existing integration. Their API changelog is a masterclass in incremental evolution.
GitHub
GitHub uses URI versioning for their REST API (/v3/) and GraphQL for their newer API surface. The REST v3 API has been stable for years, with additive changes shipped without version bumps. For their GraphQL API, they use schema evolution instead of versioning: new fields are added, old fields are deprecated with @deprecated, and clients query only what they need. This dual approach gives them stability for existing REST consumers and flexibility for new GraphQL consumers.
Twilio
Twilio uses URI versioning (/2010-04-01/) with a twist: the version is a date representing when the API surface was defined, not a sequential number. They maintain very long-lived versions (their 2010 version is still active) and provide explicit migration guides between versions. Their approach prioritizes stability over evolution speed, which makes sense for telephony integrations that are expensive to update and test.
How This Shows Up in Interviews
When to bring it up
Mention API versioning when:
- You're designing an API that serves multiple client types (web, mobile, third-party)
- The system requirements mention "backward compatibility" or "evolving API"
- You're designing a platform or marketplace where partners integrate via API
- The interviewer asks about deployment strategy for API changes
Don't over-invest in versioning strategy during a system design interview. Say "I'd use URI versioning for simplicity, with gateway-based routing" and move on to the interesting parts of the design.
Depth expected at senior/staff level
- Know the three strategies and their trade-offs (URI for simplicity, header for granularity, content negotiation for REST purity)
- Articulate the difference between backward-compatible and breaking changes with specific examples
- Describe the expand-and-contract pattern for gradual migration
- Explain gateway-based version routing architecture
- Know Stripe's date-based model as a reference for header versioning
- Understand why GraphQL sidesteps versioning and when that approach works
Interview shortcut: Stripe as reference architecture
When discussing API versioning, name-drop Stripe's date-based model. Say: "Stripe pins each API key to a version date and maintains 100+ versions simultaneously using request transformers at the gateway layer." This signals real-world awareness and gives the interviewer a concrete mental model.
Follow-up Q&A
| Interviewer asks | Strong answer |
|---|---|
| How do you handle a breaking change in a field type? | Expand-and-contract: add the new field alongside the old one, migrate clients over weeks, remove the old field once usage drops to zero. If any clients can't migrate, ship a new API version. |
| How would you version a microservices API that's internal only? | For service-to-service APIs within the same deploy pipeline, use contract testing (Pact) instead of formal versioning. Each consumer declares what it expects; the provider's CI verifies all contracts before deploy. Formal versioning is overkill for tightly coupled internal services. |
What's wrong with versioning in the query string (?version=2)? | It works, but query params are for filtering data, not selecting API behavior. Caching proxies may strip or ignore query params. Gateway routing on query params is less standard than path or header routing. It's technically viable but unconventional. |
| How does Stripe handle 100+ API versions without 100 codebases? | Gateway-level request/response transformers. Stripe runs one current codebase. For each historical API version, a transformer converts the old request format to current and the current response format back to old. The transformers are chained: v1 request goes through the v1-to-v2 transformer, then v2-to-v3, and so on, until it reaches the current version. |
| When would you version a GraphQL API? | Almost never. GraphQL's per-field deprecation handles most evolution. You'd version only for fundamental schema redesigns (e.g., changing the query model from REST-mapped to domain-driven) that can't be expressed as additive changes. In practice, this is rare. |
Test Your Understanding
Quick Recap
- API versioning maintains multiple behavioral contracts simultaneously so old clients keep working while new clients use updated functionality.
- URI versioning (
/v1/,/v2/) is the simplest and most common: visible in logs, easy to route, simple for clients. - Header versioning (Stripe's date model) provides per-change granularity and handles frequent incremental breaking changes without version number inflation.
- Backward-compatible changes (add optional fields, add endpoints) ship freely. Breaking changes (rename/remove fields, change types) require a new version or expand-and-contract migration.
- At scale, gateway-based request/response transformers are cleaner than conditional version checks scattered through handler code.
- Deprecation requires active management: sunset headers, usage monitoring, consumer outreach, and graduated response (warning, rate limit, 410 Gone).
- Monitor actual version usage before sunsetting. Deprecated versions almost always have active clients nobody anticipated.
Related Concepts
- API Gateway: The gateway is where version routing happens. Path-based routing for URI versioning, header inspection for date-based versioning.
- REST vs. GraphQL: GraphQL's field-level deprecation sidesteps versioning entirely. Understanding both approaches lets you make a stronger case for whichever you pick.
- Microservices: In a microservices architecture, each service may version its API independently. Service-to-service versioning uses contract testing rather than formal version numbers.