The App That Broke Overnight
Imagine you built a small phone app in your Computer Science project that shows live running status for Indian trains. Your app does one simple thing: it sends a request to a train-data server and asks, "Where is train number 12951 right now, and is it delayed?" The server replies with a neat block of data, and your app reads one number out of it — the delay in minutes — and shows a red or green icon.
Your app worked perfectly for three months. Then, one morning, every single user's screen shows an error and a blank card. Nothing in your code changed. You didn't touch a single line. What happened is that the server you depend on changed the shape of its reply — maybe it renamed one field, or changed a number into a piece of text — and your code, which was written assuming the old shape, could no longer find what it was looking for.
This is the exact problem that API versioning exists to solve. An API (Application Programming Interface) is a contract: "if you ask me this way, I promise to answer you that way." The moment a server changes its side of that contract without warning, every app built on the old promise can break — even though those apps did nothing wrong. Learning how real engineering teams manage this contract, and how they let old and new versions coexist safely, is what this chapter is about.
What Exactly Is the "Contract"? Requests, Responses, and Fields
Before we can talk about versioning, we need to be precise about what an API actually promises. When your train-status app talks to the server, two things are fixed by agreement:
- The request shape — the exact URL, method, and parameters the client must send. For example:
GET /trains/12951/status. - The response shape — the exact structure, field names, and data types the server will send back.
Here is a realistic response from our train-status server, written as JSON (a text format almost every API on the internet uses to send structured data):
{
"train_no": "12951",
"status": "On Time",
"delay_minutes": 0
}
A piece of client code that reads this looks like:
def get_delay(response):
return response["delay_minutes"]
response_v1 = {"train_no": "12951", "status": "On Time", "delay_minutes": 0}
print(get_delay(response_v1))
# Output: 0
This works because get_delay trusts that the key "delay_minutes" will always exist and will always hold a number. That trust — "this key will exist, with this type, meaning this thing" — is the API contract. Versioning is the discipline of never silently breaking that trust.
Semantic Versioning: Reading MAJOR.MINOR.PATCH
Almost every professional API is labelled with a version number written as three numbers separated by dots, like 2.4.1. This scheme is called semantic versioning, and each position has an exact, agreed-upon meaning:
- MAJOR (the first number) — increases only when a breaking change is made: something that could make existing client code stop working correctly.
- MINOR (the second number) — increases when new capability is added in a way that is fully backward compatible: old clients keep working exactly as before.
- PATCH (the third number) — increases for backward-compatible bug fixes, where the contract itself doesn't change, only an internal mistake is corrected.
Let's trace a realistic version history for our train-status API, one change at a time, and work out the correct version number after each step.
Start: version 1.0.0. The response looks exactly like the JSON block above.
Change 1 — the server team adds a new, optional field called "platform", showing which platform the train will arrive at. Old clients never asked for this field, so they are completely unaffected:
response_v1_1 = {
"train_no": "12951",
"status": "On Time",
"delay_minutes": 0,
"platform": "4"
}
print(get_delay(response_v1_1))
# Output: 0 (old client still works — it simply ignores the new field)
This is a backward-compatible addition, so the version becomes 1.1.0: MINOR goes up, MAJOR stays the same, PATCH resets to zero.
Change 2 — the team discovers that delay was being reported in seconds instead of minutes for trains delayed by more than an hour, and fixes the calculation. No field is renamed, added, or removed; the same key still holds a number with the same meaning — it was just computed wrongly before. That's a bug fix within the existing contract, so the version becomes 1.1.1: only PATCH goes up.
Change 3 — the team renames "delay_minutes" to "delay" to match a new naming convention across all their APIs:
response_v2 = {"train_no": "12951", "status": "On Time", "delay": 0}
print(get_delay(response_v2))
# Traceback (most recent call last):
# ...
# KeyError: 'delay_minutes'
Every old client that calls response["delay_minutes"] now crashes with a KeyError, because that key no longer exists — the dictionary only has "delay". Nothing about the client's code was wrong; the server broke its promise. This forces the version to 2.0.0: MAJOR goes up, and by convention MINOR and PATCH reset to zero.
Seeing It on a Timeline
The diagram below shows exactly this history. Notice how an old client, written once against version 1.0.0, keeps working without any changes through 1.1.0 and 1.1.1 — but fails the instant it is pointed at 2.0.0.
The Golden Rule: Loosening Is Safe, Tightening Breaks
Rather than memorising a long list of examples, it helps far more to learn one underlying principle that predicts whether any change is breaking or not:
A change is backward compatible only if it loosens what the server promises or loosens what it demands from the client — never if it tightens either one.
Using that single rule, you can classify almost any change correctly:
- Adding a new optional response field — safe. The server now promises slightly more; old clients that don't look for it are unaffected.
- Removing a response field — breaking. Any client reading that field now fails.
- Renaming a response field — breaking. It is really "remove the old field" plus "add a new one" at the same time.
- Changing a field's data type (say,
"temperature": 32becoming"temperature": "32") — breaking. Client code doing arithmetic on a number will fail or misbehave on a string. - Making a previously required request parameter optional — safe. The server now demands less from the client.
- Making a previously optional request parameter required — breaking. Any client that didn't already send it will now be rejected.
- Adding a brand-new endpoint — safe. It doesn't touch any existing contract at all.
- Changing what a status code means (say, redefining HTTP 200 to sometimes carry an error) — breaking. Clients that trusted the old meaning will misinterpret responses.
Common Misconception: "Adding Something New Is Always Safe"
Many students assume that since nothing is being deleted, adding a field or a rule can never break anyone. This is only half true, and the missing half matters a great deal in real systems. Adding a new optional field is safe, exactly as shown above. But adding a new required field is a breaking change, even though technically you "only added something."
Here is why: suppose the train-status server decides that every request must now include a required header identifying the calling app, and it starts rejecting any request missing that header with an error. Old clients never knew this header existed, so they never send it — and now every single one of their requests fails. The word "required" is what tightens the contract; whether the thing being added is a request parameter or a response field doesn't matter — tightening a requirement always risks breaking whoever didn't already satisfy it. This is precisely the "loosen vs. tighten" rule from above, and it is one of the most common mistakes students (and even professional developers) make when they first design an API change.
A second, related misconception: "if I change how the server computes something internally, I need a new version." This is false as long as the client-visible contract — the request and response shapes — stays identical. If the train-status server switches from one internal database to another, or rewrites its delay calculation to be faster, but the JSON it sends out is byte-for-byte the same shape with the same meaning, no version change is needed at all. Versioning tracks the promise to the client, not the internal implementation behind it.
Where Does the Version Number Actually Go? Three Real Strategies
Knowing that "version 2.0.0 broke compatibility" is only half the problem — a server also needs a way to let a client say which version it wants to talk to, so that old and new clients can be served correctly at the same time. There are three common strategies used across real APIs.
1. URI (path) versioning. The version number is placed directly in the URL:
GET https://api.example.com/v1/trains/12951/status
GET https://api.example.com/v2/trains/12951/status
This is simple to understand, easy to test in a browser, and easy to see in server logs — you can tell at a glance which version any given request used. Its downside is that, strictly speaking, /v1/trains/12951 and /v2/trains/12951 are treated as two different resources even though they describe the same train, which purists consider slightly untidy. Large public APIs such as Twitter/X's REST API have used this style, placing a version segment like /2/ directly in the path.
2. Query-parameter versioning. The version rides along as an extra parameter:
GET https://api.example.com/trains/12951/status?version=1
This keeps the "real" URL clean and is easy to add on top of an existing API, but it's also easy for a client to forget the parameter entirely, silently landing on whatever the server treats as default — which can cause confusing, hard-to-diagnose bugs.
3. Header versioning. The version is sent in an HTTP header rather than the URL at all:
GET /trains/12951/status
Accept: application/vnd.trainstatus.v1+json
This is considered the most "correct" from a pure design standpoint, because the URL always identifies the same resource regardless of version — only the representation format changes. GitHub's REST API works this way, letting a client request a specific version through a header. The trade-off is that it's invisible in a plain browser address bar and slightly harder for beginners to test, since you need a tool that lets you set custom headers.
A fourth approach worth knowing about, because it is used by a very large real payments platform, is date-based versioning: Stripe's payment API labels versions by release date rather than a number. A client "pins" itself to a date when it first integrates, and Stripe guarantees that pinned behaviour never silently changes underneath it, even while newer dated versions keep shipping for new integrators. This shows that "version" doesn't have to be a MAJOR.MINOR.PATCH triple at all — the underlying discipline of never breaking an existing promise is what matters, not the exact labelling scheme.
Closer to home, India's UPI payment system — the interbank network built by the National Payments Corporation of India (NPCI) that powers apps used for everyday transactions — is itself one large, heavily used API that banks and apps talk to. Its specification evolves over time, and because a huge volume of transactions per day depends on it working continuously, changes that would break existing bank integrations are introduced as new specification versions rather than silently overwriting the old behaviour, giving every connected bank time to update its own systems.
Deprecation: Retiring an Old Version Responsibly
Releasing version 2.0.0 doesn't mean version 1.x has to vanish immediately — in fact, doing that would defeat the entire purpose of versioning, since it would break every client on the old version anyway, just delayed. Responsible API providers instead follow a deprecation process:
- Announce, well in advance, that version 1.x is deprecated — still working, but no longer recommended for new integrations.
- Give a concrete sunset date: the day version 1.x will actually stop responding.
- Often, mark this directly in the HTTP response itself, using headers such as
Deprecation: trueandSunset: 2027-01-01, so that automated tools and careful developers can detect the warning programmatically, not just from a blog post. - Only after the sunset date passes does the server retire the old version, typically replying with an HTTP 410 ("Gone") status to any request still aimed at it.
This gives every team depending on the old version — potentially dozens of apps they've never even heard of — real time to migrate their code, test it, and switch over on their own schedule, rather than being broken without warning, exactly like the train-status app at the start of this chapter.
Worked Example: Classify the Change, Pick the Version
Try reasoning through each of these using the loosen/tighten rule before checking the classification given.
- The train-status API starts returning
"delay_minutes": 0.0(a floating-point number) instead of"delay_minutes": 0(an integer), for the same zero-delay case. Classification: in most programming languages this is technically safe, since0.0and0behave identically in arithmetic and comparisons — but strict JSON-schema validators that specifically require an integer type would reject it, so careful API teams still document a numeric-subtype change clearly rather than assuming it's completely free. - A new endpoint
GET /trains/12951/coachesis added, listing coach numbers. Nothing about/statuschanges. Classification: non-breaking — new capability, old requests untouched. Version bump: MINOR. - The
"status"field, which used to be free text like"On Time"or"Delayed", is restricted to only ever return one of five fixed values from now on, and the server starts rejecting any client-sent filter value outside that fixed list. Classification: breaking for any client that was matching against a wider range of text, or sending a filter value outside the new fixed list — the space of valid values just got tighter. Version bump: MAJOR. - A typo is fixed in an internal comment inside the server's source code; no request or response changes at all. Classification: not a version-worthy change of any kind — the client-visible contract never moved.
Practice: Test Your Understanding
- A weather API changes
"temperature": 32to"temperature": "32°C". Using the loosen/tighten rule, classify this change and state the correct part of the version number (MAJOR, MINOR, or PATCH) that must increase. - An API currently requires clients to send a
city_codeparameter on every request. The team changes it socity_codeis optional, defaulting to Delhi when omitted. Is this breaking? Why or why not? - Given a jump from version
3.2.4straight to4.0.0, what do you already know happened to the API's contract, without reading any changelog? - A cricket-score app you built reads
response["runs"]from a scores API. The API provider renames that field to"total_runs"in their next release but keeps the old URL path unchanged. Will your existing code still work? What specifically would you see happen when you run it, and why? - Explain, in your own words, why a purely internal change — like switching which database the server uses — normally requires no version bump at all, using the definition of "API contract" from this chapter.
Answer key: (1) Breaking — the data type changed from a number to a string, so client code doing arithmetic on it would fail or behave incorrectly; MAJOR must increase. (2) Non-breaking — the server now demands less from the client than before (loosening a requirement), so every existing request that already sent city_code keeps behaving identically; a MINOR increase is appropriate. (3) A breaking, backward-incompatible change was made somewhere in the contract — old clients built against 3.2.4 are not guaranteed to keep working against 4.0.0. (4) No — running response["runs"] against the new response would raise a KeyError: 'runs', because that key no longer exists in the reply; the URL staying the same doesn't protect you, since URI versioning only helps if the version number in the path actually changes too. (5) Because the API contract is defined purely by the request and response shapes the client sees — not by anything happening inside the server — so as long as those shapes stay identical, no promise to the client has been broken, and no version change is required.
Summary
An API is a promise about exactly what a client can send and exactly what shape it will get back. Backward compatibility means every old request keeps working, unmodified, against a newer version of the service. Semantic versioning encodes the size of a change directly into the version number: PATCH for invisible bug fixes, MINOR for safe additions, MAJOR for anything that could break an existing client. The single rule that predicts almost every case is that loosening a promise or a requirement is safe, while tightening either one is breaking — even something as innocent-sounding as "adding a required field" tightens the contract and therefore breaks old clients. Real systems carry the version number in the URL path, in a query parameter, or in an HTTP header, each with its own trade-offs, and responsible providers deprecate old versions with clear advance notice and a sunset date rather than switching them off without warning. Getting this discipline right is what allows an API — whether it's a train-status service, a payments network like UPI, or any other system millions of apps depend on — to keep evolving without breaking the very users it exists to serve.