API versioning: how to ship breaking changes without breaking your users

Change a response field and old apps break. Learn which changes are breaking, how to version an API and how to retire old versions kindly.

Two API versions v1 and v2 serving different response shapes side by side

You published an API and people built apps on it. Now you want to rename a field, and the moment you do, their apps break while you cannot even see them. Versioning is how you keep improving the API without surprising the people who depend on it. The server, client and outputs below were run with Python 3 before publishing.

Breaking or not?

Usually safe (non-breaking):

  • Adding a new endpoint.
  • Adding a new optional field to a response (clients should ignore fields they do not know).
  • Adding an optional request parameter.

Breaking:

  • Removing or renaming a field or endpoint.
  • Changing a type (a number becomes a string).
  • Changing the meaning of a field, or its units.
  • Making an optional parameter required.
  • Changing error formats or status codes that clients rely on.

The best versioning strategy is to avoid needing a new version: add rather than change. Only when a change is truly breaking do you create a new version.

Where to put the version

  1. In the URL path: /v1/users, /v2/users. Easy to see, test in a browser, route and cache. Most common.
  2. In a header: a custom header or a versioned media type, e.g. Accept: application/vnd.example.v2+json. The URL stays clean, but it is harder to try quickly and easier to get wrong.
  3. In a query parameter: ?version=2. Simple, though it mixes versioning with filtering.

None is perfect. For most teams, URL versioning is the pragmatic choice because everyone understands it. The example below uses it.

A working example

Version 1 returns a single name. Version 2 splits it into first_name and last_name, which is a breaking change, so it needs a new version. Save as api.py:

import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

USERS = {1: {"first": "Rahim", "last": "Uddin", "email": "rahim@example.com"}}


def v1_user(u):
    # old shape: one "name" field
    return {"id": 1, "name": f"{u['first']} {u['last']}", "email": u["email"]}


def v2_user(u):
    # new shape: name split in two (a breaking change for v1 clients)
    return {"id": 1, "first_name": u["first"], "last_name": u["last"], "email": u["email"]}


ROUTES = {"/v1/users/1": v1_user, "/v2/users/1": v2_user}
SUNSET = {"/v1/": "Wed, 31 Dec 2026 23:59:59 GMT"}  # v1 retirement date


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        build = ROUTES.get(self.path)
        if build is None:
            return self.send_json(404, {"error": "not found"})
        extra = {}
        for prefix, date in SUNSET.items():
            if self.path.startswith(prefix):
                extra = {
                    "Deprecation": "true",
                    "Sunset": date,
                    "Link": '</v2/users/1>; rel="successor-version"',
                }
        self.send_json(200, build(USERS[1]), extra)

    def send_json(self, status, data, headers=None):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        for k, v in (headers or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


if __name__ == "__main__":
    HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "3931"))), Handler).serve_forever()

A small client, client.py (start the server first with PORT=3931 python3 api.py):

import json
import urllib.error
import urllib.request

BASE = "http://127.0.0.1:3931"


def get(path):
    try:
        r = urllib.request.urlopen(BASE + path)
    except urllib.error.HTTPError as e:
        r = e
    return r.status, dict(r.headers), json.load(r)


for path in ("/v1/users/1", "/v2/users/1", "/v3/users/1"):
    status, headers, body = get(path)
    print(path, status, body)
    for h in ("Deprecation", "Sunset", "Link"):
        if h in headers:
            print("   ", h + ":", headers[h])

Output:

/v1/users/1 200 {'id': 1, 'name': 'Rahim Uddin', 'email': 'rahim@example.com'}
    Deprecation: true
    Sunset: Wed, 31 Dec 2026 23:59:59 GMT
    Link: </v2/users/1>; rel="successor-version"
/v2/users/1 200 {'id': 1, 'first_name': 'Rahim', 'last_name': 'Uddin', 'email': 'rahim@example.com'}
/v3/users/1 404 {'error': 'not found'}

Reading the result

  • v1 is untouched: old apps keep getting name.
  • v2 has the new shape for new or updated clients.
  • v1 carries retirement signals. Sunset is a standard HTTP header (RFC 8594) giving the date the endpoint is expected to go away. Deprecation is used in practice to mark it as deprecated, and Link with rel="successor-version" points to the replacement. Clients and monitoring tools can read these automatically.
  • An unknown version returns a clean 404.

Tests

Save as test_api.py:

import json
import threading
import unittest
import urllib.request
from http.server import HTTPServer

from api import Handler


class VersionTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.srv = HTTPServer(("127.0.0.1", 0), Handler)
        cls.port = cls.srv.server_address[1]
        threading.Thread(target=cls.srv.serve_forever, daemon=True).start()

    @classmethod
    def tearDownClass(cls):
        cls.srv.shutdown()

    def get(self, path):
        r = urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}")
        return r, json.load(r)

    def test_v1_shape_is_unchanged(self):
        _, body = self.get("/v1/users/1")
        self.assertEqual(set(body), {"id", "name", "email"})

    def test_v2_has_new_shape(self):
        _, body = self.get("/v2/users/1")
        self.assertEqual(set(body), {"id", "first_name", "last_name", "email"})

    def test_v1_is_marked_deprecated(self):
        r, _ = self.get("/v1/users/1")
        self.assertIn("Sunset", r.headers)

    def test_v2_has_no_sunset(self):
        r, _ = self.get("/v2/users/1")
        self.assertNotIn("Sunset", r.headers)


if __name__ == "__main__":
    unittest.main()
$ python3 -m unittest -v
Ran 4 tests in 0.544s

OK

The first test is the important one: it pins v1's shape. If someone later "improves" v1 and breaks old clients, this test fails before users notice.

A sensible deprecation process

  1. Announce early (docs, email, changelog) with a real date, giving clients months, not days.
  2. Add the signals: Deprecation and Sunset headers, a link to a migration guide.
  3. Measure usage per version. Log which version each request uses (and, if you can, which client). You cannot retire v1 safely if you do not know who still calls it.
  4. Contact the remaining users shortly before the date.
  5. Retire it. Some teams first return 410 Gone with a helpful message, instead of a confusing 404.

Costs of versioning

Every live version is code you must maintain, test and secure. A security fix may need to land in v1, v2 and v3. Keep the number of supported versions small. A good design shares the core logic and keeps only a thin layer per version, as the v1_user / v2_user functions do: both read the same data and differ only in how they present it.

Common mistakes

  • Making a breaking change without a new version, because "it is a tiny rename".
  • Versioning everything for small additive changes, creating needless v2, v3, v4.
  • Never removing old versions, so the maintenance cost grows forever.
  • Retiring a version with no warning and no usage data.
  • Copy-pasting the whole codebase per version instead of sharing logic.
  • Not testing old versions after changes to shared code.

Internal APIs and mobile apps

Mobile apps are the hardest case, because old app versions stay installed for a long time and users do not update on your schedule. Keep old versions alive longer, and consider a minimum-supported-version check that asks very old apps to update. For APIs used only by your own web frontend that you deploy together, you can often change both at once and skip formal versioning, but still deploy in a safe order (backend first, supporting both shapes briefly).

Main idea: prefer additive changes; when a change is truly breaking, release a new version (URL versioning is the simplest), keep the old one stable and pinned by tests, announce a date, signal it in headers, measure who still uses it, and then retire it.


Backend series, Part 3 (reliable APIs): 4 of 5. Previous: Background jobs and queues: retries, backoff and dead jobs, explained with code.

Keep reading