API Versioning and Evolution Without Breaking Clients
Learn which API changes break clients, Hyrum’s law, tolerant readers, semantic versioning, Deprecation and Sunset headers, brownouts and 410 Gone.
API versioning and evolution: common questions
Is adding a field to an API response a breaking change?
Usually not: clients that ignore unknown fields keep working. It breaks only clients that validate responses against a closed schema, which is why clients should be tolerant readers.
How do you retire an old API version safely?
Measure who still calls it, announce a date with a migration guide, send Deprecation and Sunset response headers, run short planned brownouts, and return 410 Gone only once traffic is near zero.
Related chapters
- Start here in System Design
- How a system design interview is graded
- The delivery framework: five phases and a clock
- Numbers to know: what one machine can really do
- System Design Concepts: CAP, SQL vs NoSQL, Message Queues
- SQL vs NoSQL: How to Choose a Database (System Design)
- Traffic Jumps 10×: What Breaks First? (System Design)
- Proxy vs Load Balancer vs API Gateway (System Design)
- CAP Theorem Explained Simply, with Examples
- Why Use a Message Queue? System Design Explained
- Message Queue vs Pub/Sub vs Kafka: Differences
- Polling vs Long Polling vs SSE vs Webhooks (System Design)
- API design in System Design
- REST vs GraphQL vs gRPC: Choosing an API (System Design)
- API Goals Canvas: Design APIs from the Consumer
- REST API Design: Resources, Methods, Status Codes
- HTTP Semantics, Richardson Maturity Model and HATEOAS
- GraphQL Explained: Over-fetching, Schema, N+1 Problem
- RPC and gRPC Explained: Protobuf, HTTP/2, When to Use
- API Design Best Practices: 9 Principles with Examples
- API Payload Design: Names, Types, Links and Errors
- Mapping Database Tables to API Resources, Step by Step
- API Design for Complex Schemas: Trees, Polymorphism, Bookings
- API Pagination, Idempotency Keys, Errors and Versioning
- Network-Efficient APIs: HTTP Caching, ETags, Field Selection
- Async APIs: 202 Accepted, Webhooks and Bulk Operations
- API Contracts: OpenAPI, Contract Testing and Docs
- API Security: Authentication, Authorization, Rate Limiting
- OAuth 2, OpenID Connect and Scopes for APIs
- OWASP API Top 10 Explained: BOLA, SSRF and More
- Running APIs: Gateways, Mesh, Rate Limits, Canaries
- API Troubleshooting Interview: Slow APIs and Unexpected Errors
- API Design Review: A Staff-Level Capstone
- Spotify Web API: Resources, IDs, Payloads and Paging
- Spotify Web API: OAuth PKCE, Scopes, Quotas, Rate Limits
- Spotify Web API Changes: Real API Evolution Lessons
- Database-heavy designs in System Design
- Design a Distributed Key-Value Store (System Design Interview)
- Design a Hotel Reservation System (System Design Interview)
- Design a Digital Wallet (System Design Interview)
- Design S3-like Object Storage (System Design Interview)
- Games in System Design
- Shard City: Learn Database Sharding by Playing a Game
- System Design Case Studies: Flash Sale, Chat, ChatGPT
- Flash Sale System Design: Who Gets the Last Item
- Food Delivery App System Design: Which Database?
- Cache Stampede: Why the Database Fell Over and the Fix
- Live Streaming System Design: Scaling for a Final
- ChatGPT System Design: Streaming LLM Responses
- RAG System Design: Product Q&A on Sale Day
- IRCTC Tatkal System Design: Booking Under Extreme Load
- Group Chat System Design: WebSockets and Fan-Out
- Authentication in System Design: Sessions, JWT, OAuth
- Authentication Methods Compared: Sessions, JWT, OAuth
- JWT in System Design: Sessions, Revocation, Refresh Tokens
- JWT Interview Questions and Answers
- Life of a session, step by step
- JWT Structure Explained: Header, Payload, Signature
- Forge lab: why verification must be strict
- JWT vs Session Authentication: Which to Use
- The validation pipeline: where a bad request dies
- How to Revoke a JWT: Blocklists and Short Expiry
- Refresh Tokens and Rotation Explained
- Logout in depth: one device, all devices, SSO
- JWT Security Best Practices
- OAuth 2.0 and OpenID Connect Explained
- Where to Store JWT: localStorage vs HttpOnly Cookie
- JWT in Microservices: API Gateway vs Service Validation
- Microservices, keys, and trust boundaries
- Sender-constrained tokens: making a stolen token useless
- Authentication vs Authorization Middleware
- Failure explorer: what breaks when X fails
- Capacity planner: sessions vs JWT vs hybrid
- Edge cases catalog
- Test it, measure it, migrate to it
- Anti-patterns to reject in a design review
- Design checklist and cheat sheet
Keep learning
Overview
Add a response field Old apps never read doors_open_at, so nothing changes for them. The one catch: a client that validates responses against a closed schema (no unknown properties) will reject it. That is why tolerant reading matters (section 4). Rename a field - "price": 1500\n+ "price_inr": 1500 Remove a field Somebody reads every field you ever shipped. The venue screen in app 4.x shows the phone number and now gets nothing. Change a type or format - "price": 1500\n+ "price": "₹1,500" Same name, different meaning. An app that adds two prices now joins two strings. Changing a date from ISO 8601 to “14 Nov” breaks the same way. Add a required request field Add an optional request field Add an enum value to a response "status": "confirmed" | "cancelled"\n+ | "waitlisted" Safe on paper, a crash in practice: a Swift or Kotlin client with an exhaustive switch and no default branch fails to decode the booking. Safe only if the docs said from day one that new values may appear. Tighten validation max tickets per booking:\n- 10\n+ 6 A request that worked yesterday (John booking 8 seats for his office) gets 400 today. Loosening a rule, 6 to 10, is safe. Change a default GET /events?city=pune\n include_sold_out default:\n- false\n+ true Nobody touched the request, yet the answer changed. Apps that never passed the flag now show sold-out events as bookable. Defaults are part of the contract. Change an error status or code The app checks code === "SEAT_UNAVAILABLE" to reload the seat map. Now it shows a generic “something went wrong” instead. Change a URL Old apps call the old URL and get 404. A redirect does not rescue a POST everywhere: many clients won’t re-send a body after a redirect. Keep the old route as an alias. Reduce a rate limit per partner key:\n- 1,000 requests / hour\n+ 200 requests / hour Nothing in the schema changed, so a schema diff says “safe”. But the CityPass partner syncs 600 events an hour and now gets 429 most of the day. Quotas are contract too. Lab: breaking or safe? Pick a change, then judge it Would an app built against yesterday’s API still work? · you got it URL path GET /v2/events/123 Visible in every log line, link and curl command. The gateway routes /v1 and /v2 to different services with one rule. Caches need nothing special. Says “this is a different resource”, though event 123 is the same event. A major bump changes every URL, including links stored by clients. Custom header GET /events/123\nApi-Version: 2026-10-01 URLs stay stable forever. Works well with date-pinned versions: Stripe pins each account to the version it first used and lets a request override it with a Stripe-Version header. Invisible in a browser or a pasted link. Responses must carry Vary: Api-Version or a shared cache can hand v1 bodies to v2 callers. You must pick a default for requests without it. Media type GET /events/123\nAccept: application/vnd.tickets.v2+json Versions the representation, not the resource, which is the most HTTP-minded answer. One URL can serve both shapes through content negotiation. Hardest to test and to explain. Needs Vary: Accept. Many tools and SDKs make custom media types awkward. Query parameter GET /events/123?api-version=2026-10-01 Visible and easy to try in a browser. Azure’s REST APIs use an api-version parameter like this. Mixes a contract choice with filters like ?city=. Easy to drop when a client builds URLs from the next_cursor link. Lab: where does John’s app say which version it wants? What you gain What it costs Asha’s venue dashboard our own web team, ships daily iOS app 6.x needs a store release, then users must update CityPass partner contact email bounces, but their client logs deprecation headers Android app 4.1 Price-alert script a hobby developer, anonymous key, no contact at all Lab: plan the retirement of /v1, then see who breaks Email every registered key owner Send Deprecation and Sunset headers Brownouts two months before Nobody breaks. Every caller heard in time and had enough months to move. Check the logs once more: /v1 traffic should be near zero before you flip it to 410. Some never heard at all: they learn about the change from the outage. Some heard, but too late for the work they need.
Why change is hard: you don’t ship the clients
You deploy /events minutes, many times a day Your app team ships days, through store review Users update Partners change code when their roadmap allows One API, four very different clocks. You control only the first box. On the ticket site, John’s phone may run an app version from two years ago, and the CityPass partner’s integration was written by an engineer who has since left. Both call the same production API you deploy today. server version running right now app versions still calling it on a typical day of them you can force to update tonight So every change has to answer one question: would a client built against yesterday’s API still work, unchanged? The same holds for the event payloads you push through webhooks ( long jobs and events ): a subscriber’s parser is a client too.
Breaking or safe? Judge twelve changes
A breaking change is any change after which a correct, unchanged client stops working or starts doing the wrong thing. Try your instinct on these before reading the rules: the security chapter are part of what clients rely on.
The invisible contract: everything observable
GET /events/123/seats always came back sorted by seat number. Nothing promised that, but the CityPass seat map draws row A first because of it. Booking used to reply in 200 ms. A new fraud check makes it 4 s, and the app’s 3 s timeout now retries, and without an idempotency key it books twice. Error text A partner matched message.includes("sold out"). You reworded it to “No seats left” and their retry logic broke. ID shape Booking IDs were 8 digits. Someone stored them in an INT column. Moving to bk_ prefixed strings breaks their database. Page size The default limit was 20. A client never paged and assumed 20 was “all events this week”. Side effects Cancelling a booking used to email Priya. Moving the email to a nightly batch changes what she, and support, depend on.
Three rules for safe change, and the tolerant reader
1. Take nothing away No removed fields, endpoints, enum values you send, or accepted inputs. Mark them deprecated instead. 2. Don’t change meaning 3. Make new things optional New request fields have defaults equal to the old behaviour. New response fields can be ignored. A rename becomes three safe steps, often called expand and contract: add price_inr next to price, send both while clients move, and drop price only in the next major version once logs show nobody reads it. The rules only work if clients hold up their side. That is the tolerant reader: read the fields you need, ignore the rest, and survive values you have not seen. Strict reader Validates the whole response against a closed schema, so an extra doors_open_at fails the parse. An exhaustive switch (status) with no default crashes on "waitlisted". Tolerant reader Picks id, name, price and ignores the rest. Unknown status maps to “Other, tap for details”. Unknown links are skipped. Be tolerant in what you read and careful in what you send. If you publish an SDK for the ticket site, ship it tolerant, because most partners will never write their own parser.
Versioning in depth: what the number means and where it lives
API version: the promise What clients code against: v2, or 2026-10-01. Changes rarely, and only on purpose. Implementation version: the build What you deployed: bookings-svc 2026.10.22-3a1f. Changes many times a day. Clients should never need to know it. Many deploys, one API version: that is the goal. The patterns chapter bump it when clients must MAJOR 1 → 2 any breaking change price becomes a money object change code MINOR 2.3 → 2.4 compatible additions new doors_open_at field, new /venues/9/parking PATCH 2.4.0 → 2.4.1 fixes, no interface change Whole API Per resource Per operation Finest grain, common in RPC. Lots of small versions to document, test and retire. The coarser the grain, the simpler the story and the bigger each upgrade. Most public REST APIs version the whole API and work hard to need a new major rarely.
Lifecycle promises: how long does a version live?
A version number says what changed. A lifecycle pattern says how long the old thing will keep working. Patterns for API Design names four, and teams often combine them: Experimental preview no promise yet Ship /preview/seat-recommendations to a few partners for feedback, clearly labelled: may change or vanish any time. Learn before you commit. Two in production old + new Run at most two majors side by side. Releasing v3 starts the retirement of v1, so clients always have one release cycle to move. Limited lifetime guarantee fixed date Each version is promised for a fixed time from release, say 18 months. The end date is known on day one, so partners can plan. Aggressive obsolescence provider first Cheap for the provider Stable for clients End date known up front ✓ yes · ~ partly · ✕ no A typical ticket-site policy: new features start as previews, the public API follows two in production, and paying partners get a written 12-month guarantee per major version.
Deprecating and retiring a version
who still calls /v1, per key and app version why, when, and the migration guide Signal in-band Deprecation + Sunset headers short planned outages 410 Gone with a pointer to v2 Deprecated means “still works, please leave”. Retired means “stopped”. A brownout switches the old version off on purpose for an hour or a day, announced in advance, weeks before the real date. Callers who ignored every email suddenly see errors while there is still time to fix them. After the sunset, answer with 410 Gone rather than 404, so the error says “this existed and was retired”. The lab shows the two ways a retirement fails: callers who never heard (no channel reached them) and callers who heard too late for the work they need (mobile apps and partners are slow). Turn every signal on and push the date out to six months, and everyone makes it.
Design for change on day one, then let CI police it
The cheapest breaking change is the one your first design never forces. A few shapes leave room to grow: closed shape extensible shape room for paging and metadata later "price": 1500 add currency, taxes, fees without a new field name "is_vip": true "tier": "vip" (string enum) a third tier is a new value, not a second boolean "status" listed as a fixed set documented as open: “new values may appear” clients write a default branch from day one next steps returned as links or an actions list add a “pay later” step without an app release Then stop relying on reviewers to spot breaks. If the API is described in an OpenAPI file, CI can compare the file in the pull request with the one in production and fail the build on breaking differences. Tools such as oasdiff and openapi-diff do this. PR edits openapi.yaml Diff against main old spec vs new spec Classify each change safe / breaking Fail, or require a major bump the next chapter builds both.
In the interview
Next: writing the contract down so machines can check it, with OpenAPI, contract tests and documentation people actually read. Spotify’s February 2026 changes renamed /tracks to /items, removed fields and capped search, all inside /v1. Spotify’s 2026 migration GitHub date headers and Meta URL versions, side by side. Versioning compared
More courses
- Jobs, linked to lessons — Browse jobs where every requirement links to the chapter that teaches it. See how ready you are, how long the gap takes to close, and which listings look stale.
- Match a job post to courses — Paste a job post. Each skill is matched to a track here and put in learning order, foundations first.
- React from open source with patterns — Patterns extracted from Material UI, Radix, React Aria, React Hook Form, Redux Toolkit, TanStack, and Next.js — not slides about hooks.
- Database in depth — How a query becomes work. Indexes, isolation, locks, and the plans that explain why a page is slow — not an ORM tour.
- Build a real API with NestJS — One ticket-booking API, built in order: a plain Node server first, then NestJS modules, validation, a real database, and the features real clients lean on.
- Microservices — Microservice patterns one diagram at a time: how to split a monolith, how services talk, and how data stays consistent without distributed transactions.
- Kafka — A beginner-to-senior path through event logs, reliable delivery, production operations, and disaster recovery.
- AI engineering for software engineers — Eight free reads, in order, for software engineers moving into AI: when to build an agent, how to build one, how to test it, and how the model works underneath.
- Frontend system design — How a UI is sliced when it is a product, not a page: ownership of state, cache, routes, and what happens when the network lies.
- TypeScript in depth — The type system as a design tool. Inference, narrowing, generics, and the errors that mean the model is wrong — not a catalog of utility types.
- CSS in depth — See the box, then the page. Visual chapters from the box model through Flexbox surprises, Grid, selectors, and units, then backgrounds, shapes, and visual effects, each with a playground.
- Accessibility — Foundations first (WCAG in plain words, semantic HTML, lists, alt text), then hands-on HTML labs, a full pass of WAI-ARIA 1.3, and a final fix-the-page challenge.
- Docker — What an image is, why layers cache, and how containers find each other on a network.
- Redis — Fifteen Redis patterns, one picture each: cache, session, lock, counter, rate limiter, queue, and when Redis is the wrong tool.
- Kubernetes — Kubernetes from zero to operators: why it exists, Pods, Deployments and Services, then configuration, networking, storage, scheduling, scaling and security.