Versioning & API evolution — interview questions
Published
Use these as active recall, not reading. Read the question, answer it out loud or in writing from memory first, and only then reveal the model answer to grade yourself. Recognizing an answer ≠ being able to produce it under pressure. Filter by difficulty; reveal-all only for a final review pass.
What is a breaking change?
Any change that can cause a deployed client to fail without the client changing its own code. The line that decides versioning: if a change can break an integration silently, it's breaking; if it can't, it's safe to ship in place.
Name the classic breaking changes.
- Removing or renaming a field
- Changing a field's type or its meaning
- Making an optional field required
- Tightening validation
- Changing a default behavior
- Removing an endpoint, or an enum value clients depend on
Each of these can break a client that did nothing wrong.
Name the non-breaking changes you can ship freely.
- Adding an optional request field
- Adding a field to a response
- Adding a new endpoint
- Adding a new enum value — if clients tolerate unknowns
These are pure additions: no version, no migration, no client action.
What is a "tolerant reader"?
A client that ignores fields and values it doesn't recognize instead of failing on them. It's the client half of additive evolution: the server only adds, the client only tolerates, and you can ship new fields and enum values without breaking anyone.
What is Postel's law, and how does it apply here?
"Be conservative in what you send, liberal in what you accept." For APIs: emit a strict, well-formed contract, but parse responses leniently — don't break on additions. It's the principle behind the tolerant reader.
What are the three places to put a version?
(1) URI path — /v1/users; (2) custom header — e.g. Accept-Version: 2 or API-Version; (3) media-type negotiation — Accept: application/vnd.company.v2+json. None is free; each buys clarity in one dimension and pays for it in another.
Why is URI-path versioning so popular?
It's visible and dead simple: trivial to route, you can test it in a browser, it's obvious in logs and docs, and it caches cleanly by URL. The pragmatic default for most teams.
How does Stripe version its API?
Stripe pins a version per account. Your version is fixed when you first integrate; you upgrade explicitly, and a single request can override with a header. So a server-side change never silently reshapes an existing integration's responses.
How does GitHub version its API?
GitHub uses a date-based version sent in the X-GitHub-Api-Version header — i.e. the header strategy combined with calendar versioning (e.g. 2022-11-28).
Walk through the deprecation lifecycle.
Announce → run old and new in parallel → emit Deprecation + Sunset headers → publish a migration guide with a generous window → monitor real usage → remove only once traffic has drained. Deprecation is a lifecycle, not a delete.
What does the Sunset header do?
Defined by RFC 8594, it carries the date an endpoint or version will be retired, in every response on the old version — so even silent clients that never read a changelog carry the warning in-band.
Why is "change a field's meaning" breaking even when the type stays the same?
Because the wire format passes validation but the client's interpretation is now wrong, and nothing fails loudly. If amount silently switches from dollars to cents, every consumer keeps parsing an integer and silently computes the wrong number. Semantic changes are the most dangerous breakage precisely because no parser catches them.
Why is making an optional field required a breaking change?
Every existing client that omitted it — legitimately, under the old contract — now gets a 400. You've retroactively invalidated requests that were valid when written. Same logic for tightening validation: a previously accepted payload now bounces.
Is adding a field to a response always non-breaking?
Only if clients are tolerant readers. A strict client that validates the response against a closed schema — rejecting any unexpected field — turns your "safe" addition into a breakage. The addition is safe on the server side; whether it's safe end-to-end depends on the client's tolerance. That coupling is the senior nuance.
Adding an enum value — safe or breaking?
It depends entirely on the client. Safe if clients ignore values they don't recognize; breaking if a client switches exhaustively on the enum and throws (or hits a default-deny) on an unknown. This is the canonical case where "non-breaking" is conditional on the tolerant-reader contract holding on both ends.
"How do you version a public API?" What's the senior reframe?
The question is really "how do I evolve the API without breaking the clients I can't redeploy?" A new version is a last resort, not a first move. Most changes can be made compatibly — additive change plus tolerant readers — with zero version bump. You version only when a change is genuinely impossible to make compatibly. Leading with /v1 vs. headers is the mid-level reflex; reframing to evolution is the senior signal.
Why is "just cut a new version" the wrong default?
The cost of a version isn't the URL — it's the permanent support burden: a migration imposed on every integrator, a fragmented client base, and a combinatorial testing matrix across parallel surfaces you now maintain forever. Mint one per change and you're running five APIs in perpetuity. Versions are expensive; you spend one only when compatible evolution is genuinely impossible.
Give an example of turning a "breaking" change into an additive one.
Need to rename name to full_name? Don't rename — add full_name, keep name populated alongside it, document name as deprecated, and remove it only in a future major (if ever). Need a new required input? Add it as optional with a sensible default. The discipline is engineering an additive path so the compatible road stays open as long as possible.
What's the downside of URI-path versioning?
It couples the version to the URL and is "not pure REST" — the same resource shouldn't change identity just because its representation changed. /v1/users/42 and /v2/users/42 are notionally the same user behind two URIs. Most teams accept this trade for the visibility; the senior move is naming it rather than pretending it's free.
Header versioning — pros and cons?
Pro: clean, stable, version-free URLs — the resource keeps a single identity. Con: the version is invisible: hard to test in a browser, easy to forget to set, easy to miss in logs and docs, and you must remember to Vary caches on it. Power at the cost of ergonomics and discoverability.
Media-type versioning — when and why?
Accept: application/vnd.company.v2+json is the most "RESTful" option: HATEOAS-friendly and versioned per representation, not per resource. The cost is real complexity — poor support from tooling, proxies, and casual clients. Worth it mainly for hypermedia-driven APIs where representation negotiation is already first-class; overkill for most.
How does each placement strategy interact with caching?
URI path caches cleanly — the version is in the cache key for free, no two versions collide. Header and media-type versions live off the URL, so a shared cache will serve a v1 response to a v2 request unless you set Vary: Accept-Version (or Vary: Accept). Forgetting that Vary is a classic cache-poisoning bug — a real reason path versioning stays popular.
Why is per-account pinning (Stripe) such a strong pattern?
It converts "we changed the API" from a client-breaking event into a client-controlled one. Existing integrations stay frozen on the version they were built against; nothing reshapes underneath them. New users get the latest; upgrades are opt-in and overridable per request. It's the cleanest way to ship breaking changes without a flag day for every integrator.
Date-based vs semver versions — which is "better"?
It's a labeling choice, not a strategy. 2024-10-01 vs. v2 changes how the version reads, not how evolution works. Date-based reads naturally for time-ordered release trains (GitHub); v2 reads naturally for discrete major jumps. Don't let the interviewer pull you into a labeling debate as if it were a design decision.
Why do APIs expose only the major version?
Because minor and patch are, by definition, the compatible changes you ship in place — consumers never need to pick them. Exposing v1.4.2 would force clients to track and choose versions for changes that don't affect them. Only the breaking jump (the major) is a decision a client makes; everything else flows through transparently.
Is semver even the right mental model for a web API?
Partly. Semver was built for libraries, where the consumer controls when they upgrade. A web API consumer doesn't control the server, so the only number that matters to them is the major. Minor/patch semantics still discipline you internally (what's compatible vs. not), but you surface only the major. Treat semver as a classification of changes, not a public version string.
Why signal deprecation in-band via headers when you've already emailed and updated docs?
Because out-of-band reaches humans; in-band reaches the code. The integrator who set this up two years ago and left the company never reads your email — but their service still gets a Deprecation header on every call, which their monitoring or a future maintainer can catch. You need both channels: announcement for people, headers for systems that outlive the people.
How do you decide when it's safe to actually remove the old version?
Monitor real traffic, don't guess on a calendar. Instrument usage per version (and ideally per consumer), watch it drain, and reach out directly to the stragglers still on the old surface. Sunset only when usage has genuinely gone — a date in the Sunset header is a target, not a guillotine. Pulling a version with live traffic on it because "the date arrived" is how you cause an outage you own.
How long should the migration window be?
For a public API, months, not days — generous enough that integrators can fit migration into their own roadmaps, not drop everything. The window scales with how many clients you can't redeploy and how deeply they depend on the surface. Internal APIs with first-party clients you control can move far faster. Match the window to your blast radius.
"Public API, thousands of integrations. We must change the user object's shape. How?" (60-second answer)
First ask whether it can be additive — add the new field alongside the old, deprecate the old, lean on tolerant readers to ignore what they don't use. That's a zero-version, zero-migration outcome for thousands of clients I can't redeploy, so I exhaust it first.
If it's genuinely breaking, I cut a new major (date-based or /v2), pin existing accounts so nothing reshapes silently, run both in parallel, and emit Deprecation + Sunset (RFC 8594) headers. Then a migration guide, a generous timeline, and I monitor real usage before retiring. On placement I default to the URI path for visibility and routing — accepting it's "less pure REST" — or a header for clean URLs at the cost of discoverability.
How does versioning interact with HATEOAS?
HATEOAS reduces the need for URI versioning: if clients follow server-supplied links instead of hard-coding paths, the server can move and restructure URLs without breaking them — one whole class of breaking change disappears. It doesn't help with payload-shape breakage (renamed/retyped fields), which is why media-type versioning pairs with hypermedia: links handle navigation, the media type versions the representation. The catch: almost no real clients are link-following, so in practice you still version as if they hard-code.
How does versioning interact with SDKs and codegen?
SDKs are the main reason additive evolution matters: a generated client often validates responses against a closed schema, so it's a brittle reader by default — your "safe" added field can break it. So you either (a) generate tolerant clients (ignore unknowns, open enums), and (b) align SDK major versions with API major versions so the breaking jump is explicit and intentional on both sides. The SDK is also where you absorb minor changes transparently, shielding the consumer.
A field is technically optional but every client has always sent it. You want to drop it from responses. Breaking?
Treat it as breaking in practice. The contract says optional, but the de facto contract is what clients actually depend on (Hyrum's Law: with enough consumers, every observable behavior is depended on by someone). Seniority is measuring real usage before deciding, not arguing from the spec. Check traffic; if anyone reads it, removing it breaks them.
How do you design the system so the additive path stays open longer?
On the server: never repurpose existing fields, prefer adding over mutating, keep defaults stable, and treat the response schema as append-only. On the client and in your SDKs: enforce tolerant parsing (ignore unknowns), avoid exhaustive enum switches without a default branch, and don't validate responses against closed schemas. Bake tolerance into the SDK so first-party clients can't accidentally become brittle readers — the addition is only as safe as the strictest client.
Is there a "right" answer on placement?
No — and saying so is the point. Even Microsoft's API guidance frames all three and explicitly declines to crown one. The choice follows your clients and your tooling, not dogma. In an interview: state your pick and name its trade-off in the same breath. That pairing — decision plus cost — is the senior signal, not the specific choice. For most public APIs the defensible default is the path for visibility and routing.
What's the deeper instinct Stripe and GitHub share, beyond their mechanics?
Never break a deployed client silently. Different mechanics — Stripe pins per account, GitHub pins per dated header — but the same north star: a change you make on the server must never reshape an existing integration's behavior without that integration opting in. Lead with that principle; the mechanics are just two ways to honor it.
An old version still has 2% of traffic at the sunset date — but it's expensive to keep running. What do you do?
Don't blind-pull it. Segment that 2%: who are they, how critical, can they migrate, are they paying? Reach out directly. Options short of a hard cutoff: extend the window for named accounts, offer migration help, throttle or brown-out the old version on a schedule to surface the dependency, or set a firm final date with direct contact. The decision is a business and relationship call informed by data, not a unilateral engineering switch-flip — the cost of the outage often dwarfs the cost of running it a bit longer.
How do you coordinate a migration across clients you don't control?
You can't force them, so you make migration cheap and the status visible. Instrument per-consumer usage of the old version. Publish a precise migration guide and, ideally, automated diffs or a compatibility shim. Reach the long tail individually — the top consumers by volume are a handful of conversations. Use in-band Deprecation/Sunset headers so even silent clients carry the signal. Then sunset by drained traffic, segmenting stragglers rather than pulling on a date. The work is communication and measurement, not code.
How do you keep the support cost of multiple live versions from compounding?
Hold a hard line on the number of concurrent majors (often N and N−1 only), so the testing matrix and bug-fix surface stay bounded. Where possible, implement old versions as a translation layer in front of a single current core — adapters that reshape requests/responses rather than forked codepaths — so business logic isn't duplicated per version. And keep the bar for cutting a version high: every version you don't mint is support cost you never pay. The cheapest version is still no new version.
When is forcing a breaking version on everyone the right call, despite the cost?
When compatible evolution is genuinely impossible and staying compatible has a real cost — a security flaw baked into the contract, a data-correctness bug, a semantic that's actively dangerous, or a compat shim so costly it's strangling the platform. Then you version, pin, communicate hard, give a window, and migrate with help. The judgment is weighing integrator pain against the cost of not changing — sometimes the contract itself is the bug, and the only honest fix is a breaking one.
- Classify the change. Breaking vs non-breaking is the line that decides everything. Apply additive-change discipline first: add optional fields/endpoints, never remove, rename, retype, or repurpose — most "version" requests dissolve here with zero migration.
- Decide where the version lives only if a bump is unavoidable: URI path (visible, routes/caches by URL, but couples version to identity), custom header (clean URLs, but invisible and needs
Vary), or media-type negotiation (most RESTful, but poor tooling support). State the pick and its trade-off. - Define the compatibility contract. Spell out tolerant-reader expectations on both ends — server append-only, clients ignore unknowns and open-enum — so additive evolution actually holds in practice, including in generated SDKs.
- Pin to insulate clients. Per-account (Stripe) or dated-header (GitHub) pinning converts "we changed the API" from a client-breaking event into a client-controlled one; new breaking surfaces never reshape existing integrations silently.
- Run a deprecation lifecycle. Announce → run old + new in parallel → emit in-band
Deprecation+Sunset(RFC 8594) headers → publish a migration guide with a generous window → reach humans out-of-band. - Migrate via dual-running / translation layer. Implement old versions as adapters in front of one current core rather than forked codepaths, so business logic isn't duplicated per version.
- Observe version usage, then retire on drained traffic — per-version (ideally per-consumer) instrumentation tells you when it's safe to sunset; never pull on a calendar guess. Bound concurrent majors (often N and N−1) to cap the testing matrix, support, and cost of N live versions.
Design a versioning & deprecation strategy for a public payments API with thousands of integrators.
A strong answer covers: leading with evolution, not versioning — additive-first so most changes ship in place with zero migration for clients you can't redeploy. For genuinely breaking changes: pin a version per account (Stripe's model) so a server change never silently reshapes a live integration; new accounts get the latest, upgrades are explicit and per-request overridable. Choose placement deliberately (URI path for visibility/routing, or a dated header à la GitHub) and name the trade-off. Run the full deprecation lifecycle: parallel old/new, in-band Deprecation + Sunset (RFC 8594) headers plus out-of-band changelog/email, a generous months-not-days window scaled to blast radius. Instrument per-consumer usage, retire only on drained traffic, and segment stragglers (reach the high-volume few directly) rather than pulling on a date. Bound concurrent majors (N and N−1) and implement old versions as a translation layer over one core to cap the cost, testing matrix, and operational complexity of running N live surfaces. For payments specifically, weigh data-correctness and security: sometimes the contract itself is the bug and a forced breaking change is the only honest fix — done with pinning, hard comms, and migration help.
You must make a breaking change to a widely-used response shape — design the rollout so no client breaks.
A strong answer covers: first exhausting the additive path — add the new field/shape alongside the old, keep the old populated, document it deprecated, and rely on tolerant readers to ignore what they don't use, so it's a zero-version outcome. If the change is truly incompatible (renamed/retyped/dropped field, changed meaning): cut a new major, pin existing accounts to their current version so nothing reshapes underneath them, and run both surfaces in parallel behind a translation layer over a single core. Signal in-band with Deprecation + Sunset (RFC 8594) headers so even silent clients carry the warning, and out-of-band with a migration guide and direct outreach to top consumers. Instrument per-version (ideally per-consumer) traffic, give a generous window, and retire only once usage has drained — segmenting the long tail rather than enforcing a calendar date. The discipline throughout: never break a deployed client silently; make migration cheap and the deprecation status visible in both the code path and to the humans who own it.