API versioning

Backend Fundamentals · Core Backend Fundamentals · 8 min read

1. What problem does this solve?

Motivation
When a backend exposes an HTTP API, clients (web, mobile, third‑party services) depend on the shape of the data and the semantics of the endpoints. As the product evolves, new fields, new endpoints, or changed business rules are added. If we simply overwrite the existing routes, every client that still expects the old contract will break.

Problems that arise

ProblemWhy it mattersConsequence if ignored
Breaking changesClients may crash, show wrong data, or lose functionality.Loss of user trust, support tickets, and revenue.
Regression riskNew code may inadvertently change the API contract.Hard to detect without versioning.
Deployment cadenceContinuous delivery demands frequent changes.Cannot ship new features without risking existing clients.
Legal/ComplianceSome APIs are regulated and must maintain a stable contract.Violations, fines, or legal disputes.

Where it appears in real systems

  • Public REST APIs (Stripe, GitHub, Google Cloud)
  • Internal microservice contracts (e.g., a user service that feeds many downstream services)
  • Mobile backends that need to support multiple app releases simultaneously
  • AI model serving endpoints that evolve as models improve

2. Mental model

Analogy – Library book editions
Think of an API like a library’s book collection. Each book has an edition number. Readers (clients) can request a specific edition. If the library updates the book, it publishes a new edition but keeps the old one available. Readers who need the older content can still access it.

Engineering model
An API version is a namespace that groups a set of endpoints and data contracts. Clients explicitly request the namespace (e.g., /v1/... or Accept: application/vnd.myapi.v2+json). The server routes the request to the correct implementation, allowing multiple versions to coexist.


3. Deep explanation

Internals

ConceptWhat it isHow it works
URL path versioningEmbed the version in the URL (/v1/users)Simple routing; stateless; easy to cache
Header versioningUse a custom header or media type (Accept: application/vnd.myapi.v2+json)Keeps URLs clean; supports content negotiation
Query param versioning?api_version=2Quick to add but can be abused; not cache‑friendly
Content‑type negotiationContent-Type: application/vnd.myapi+json; version=2Allows multiple representations in one endpoint

Important terminology

  • Contract – The exact shape of request/response payloads.
  • Deprecation – Marking a version as soon to be removed.
  • Semantic versioning – Using MAJOR.MINOR.PATCH to signal breaking vs. non‑breaking changes.
  • Feature flag – Toggle new behavior without changing the API surface.

Trade‑offs

ApproachProsCons
PathEasy to understand, cacheable, no header parsingURL clutter, hard to support multiple media types
HeaderClean URLs, supports content negotiationRequires clients to set headers; harder to test in browsers
QueryQuick to addNot cache‑friendly; can be misused
Content‑typeFine‑grained controlRequires custom media types; more complex server logic

Failure modes

  • Unintended version drift – Adding a breaking change to /v1 without bumping the version.
  • Version leakage – Exposing internal implementation details in the public contract.
  • Stale data – Clients using old versions may receive outdated data if the underlying schema changes.

Limitations

  • Storage cost – Maintaining multiple code paths can increase codebase size.
  • Testing overhead – Each version needs its own test suite.
  • Deployment complexity – Rolling back a specific version may require separate containers or services.

Where it fits in a backend architecture

┌─────────────────────┐
│  Client (Web/Mobile)│
└───────┬──────────────┘
        │
        ▼
┌─────────────────────┐
│  API Gateway / Router│
└───────┬──────────────┘
        │
        ▼
┌─────────────────────┐
│  Service (Node.js,  │
│   FastAPI, etc.)     │
└───────┬──────────────┘
        │
        ▼
┌─────────────────────┐
│  Database / Cache   │
└─────────────────────┘

The gateway handles version routing; services expose multiple handlers per version; databases may use versioned schemas or feature flags.

  • Feature flags – Can be used to enable or disable new API features without version changes.
  • Schema evolution – Database migrations must align with API version changes.
  • Observability – Metrics should include the API version to detect usage patterns.
  • Security – Different versions may have different authentication/authorization requirements.

4. How it works

Step‑by‑step flow (path versioning)

sequenceDiagram
    participant C as Client
    participant G as API Gateway
    participant S as Service
    participant DB as Database

    C->>G: GET /v2/users/123
    G->>S: route /v2/users/123 → getUserV2()
    S->>DB: SELECT * FROM users WHERE id=123
    DB-->>S: user record
    S-->>G: 200 OK, JSON v2
    G-->>C: 200 OK, JSON v2

Architecture diagram (gateway + multiple services)

graph TD
    A[Client] -->|HTTP| G[API Gateway]
    G -->|/v1| S1[User Service v1]
    G -->|/v2| S2[User Service v2]
    S1 -->|SQL| DB1[PostgreSQL]
    S2 -->|SQL| DB2[PostgreSQL]

5. Practical implementation

Below is a minimal Node.js/Express example that supports both path and header versioning.
It uses TypeScript for type safety.

// src/server.ts
import express, { Request, Response } from 'express';
import { getUserV1, getUserV2 } from './users';

const app = express();
app.use(express.json());

// ---------- Path versioning ----------
app.get('/v1/users/:id', async (req: Request, res: Response) => {
  const user = await getUserV1(req.params.id);
  res.json(user);
});

app.get('/v2/users/:id', async (req: Request, res: Response) => {
  const user = await getUserV2(req.params.id);
  res.json(user);
});

// ---------- Header versioning ----------
app.get('/users/:id', async (req: Request, res: Response) => {
  const version = req.headers['accept']?.toString().match(/v(\d+)/)?.[1] ?? '1';
  const user = version === '2' ? await getUserV2(req.params.id) : await getUserV1(req.params.id);
  res.json(user);
});

app.listen(3000, () => console.log('API listening on :3000'));
// src/users.ts
export async function getUserV1(id: string) {
  // Simulate DB call
  return { id, name: 'Alice', email: 'alice@example.com' };
}

export async function getUserV2(id: string) {
  // New contract: include phone and address
  return {
    id,
    name: 'Alice',
    email: 'alice@example.com',
    phone: '+1-555-1234',
    address: { street: '123 Main St', city: 'Metropolis' },
  };
}

Running

npm install express
ts-node src/server.ts

Test with curl:

# Path v1
curl http://localhost:3000/v1/users/42

# Path v2
curl http://localhost:3000/v2/users/42

# Header v2
curl -H "Accept: application/vnd.myapi.v2+json" http://localhost:3000/users/42

6. Production considerations

ConcernWhat to watch forMitigation
ScalabilityMultiple versions increase code paths and memory usage.Use separate containers per major version; auto‑scale per version.
ReliabilityDeprecating a version may leave clients stranded.Deprecation policy: announce 6‑month sunset, provide fallback.
SecurityDifferent versions may have different auth rules.Centralize auth in gateway; enforce per‑version policies.
ObservabilityMetrics per version help detect usage decline.Tag Prometheus metrics with api_version.
PerformanceOlder versions may use legacy DB schema.Profile each version; consider read replicas for legacy data.
Error handlingReturn version‑appropriate error codes.Use consistent error schema; include api_version in response.
Resource usageLegacy code may be less efficient.Refactor legacy handlers gradually; keep them lightweight.
DeploymentRolling back a specific version may require separate deploy.Use feature flags or separate Docker images per version.
Failure recoveryIf a new version introduces a bug, old version must stay available.Blue‑green deployments per version; keep old containers alive.

7. Common mistakes

MistakeWhy it’s problematic
Changing the contract in /v1 without bumping the versionBreaks existing clients silently.
Using query params for versioningBrowsers and CDNs ignore query strings for caching; hard to enforce.
Not deprecating old versionsClients keep using stale APIs, increasing maintenance burden.
Embedding version in the domain (api.v1.example.com)Requires DNS changes for every version; not scalable.
Mixing path and header versioning inconsistentlyConfuses clients; hard to document.
Ignoring backward compatibility in database migrationsNew schema may break old API handlers.
Failing to tag metrics with the versionHard to spot a sudden drop in usage of a particular version.

8. Senior engineer perspective

What changes at scale?

  • Architecture decisions

    • Separate services per major version (micro‑service per version) vs. monolith with versioned routes.
    • Use an API gateway (e.g., Kong, NGINX, Envoy) to centralize routing and rate‑limiting per version.
  • Trade‑offs

    • Monolith + path versioning is simpler but can become a maintenance nightmare.
    • Micro‑services per version offers isolation but increases operational overhead.
  • Bottlenecks

    • Database schema divergence: older versions may need legacy tables or views.
    • Cache invalidation: different versions may cache different data shapes.
  • Failure scenarios

    • A new version introduces a bug that crashes the entire service.
    • Deprecation policy is not communicated; clients fail unexpectedly.
  • Monitoring

    • Version‑specific latency, error rates, and request counts.
    • Alert when a version’s traffic drops below a threshold (possible client migration).
  • Cost/performance

    • Running multiple versions increases compute and storage costs.
    • Evaluate whether to retire a version early to reduce cost.
  • When NOT to use the approach

    • If the API is internal and all clients are controlled, you can opt for feature flags instead of versioning.
    • For very short‑lived experiments, a temporary query param may suffice.

9. Interview questions

LevelQuestionAnswer points
BeginnerWhat is API versioning and why is it needed?Keeps backward compatibility; prevents breaking clients; allows independent evolution of API and clients.
BeginnerName two common ways to expose API versions.Path (/v1/...), Header (Accept: application/vnd.myapi.v2+json).
SeniorHow would you design a system that supports many API versions at scale?Use an API gateway, separate services or containers per major version, shared database with versioned schemas, deprecation policy, observability per version.
SeniorWhat are the trade‑offs between path‑based and header‑based versioning?Path is cacheable and simple; header keeps URLs clean but requires header support; query is easy but not cache‑friendly.
ScenarioA client is suddenly receiving 404 errors after a new deployment. What steps would you take to debug?Check routing rules for the version; verify that the new version’s handler is registered; inspect gateway logs; confirm that the client is still requesting the correct version; look for deprecation or removal of the endpoint.

10. Hands‑on task

Task: Add a new /v3/users/:id endpoint that returns the user’s full profile including a list of recent orders.
The orders should be fetched from a PostgreSQL table orders (fields: id, user_id, total, created_at).

Requirements

  1. Create a new handler getUserV3 in src/users.ts.
  2. Use a single SQL query with a JOIN to fetch user and orders.
  3. Return the data as:
{
  "id": "...",
  "name": "...",
  "email": "...",
  "orders": [
    { "id": "...", "total": 123.45, "created_at": "2023-01-01T12:00:00Z" },
    ...
  ]
}
  1. Update the router to expose /v3/users/:id.
  2. Add a unit test that verifies the response shape.
  3. Document the new version in the README and add a deprecation notice for /v2.

11. Quick revision

  • API versioning keeps clients stable while the backend evolves.
  • Common strategies: path, header, query, content‑type.
  • Path is simplest; header keeps URLs clean.
  • Always bump the version for breaking changes.
  • Deprecate old versions with a clear sunset timeline.
  • Use an API gateway for routing, rate‑limiting, and observability.
  • Tag metrics and logs with api_version.
  • Separate services per major version can isolate failures.
  • Avoid query‑string versioning in production due to caching issues.
  • Keep database migrations in sync with API contracts.
  • Test each version independently.
  • Monitor version usage to detect migration issues early.

12. References