API Versioning Strategies: URL Path, Header, Query Parameter, and Content Negotiation

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

StrategyFormatURL ImpactClient AdoptionCachingRouting ComplexityBest For
URL path/api/v1/usersVersion in URLTrivialExcellentLowPublic APIs, most common
Query parameter/api/users?v=1Param appendedEasyGoodLowOptional versioning, testing
Accept headerAccept: application/vnd.api.v1+jsonNoneModeratePoorMediumInternal APIs, RESTful purists
Custom headerX-API-Version: 1NoneModeratePoorMediumInternal APIs, microservices
Content negotiationapplication/vnd.company.resource.v1+jsonNoneHardPoorHighEnterprise APIs with strict contracts
No versioning (evolution)/api/users (additive only)NoneTrivialExcellentNoneInternal APIs, rarely-changing APIs
Date-based/api/2025-01-15/usersDate in URLModerateExcellentMediumFrequent releases (Stripe model)
Semantic (major only)/api/v2/usersMajor version in URLTrivialExcellentLowInfrequent breaking changes

Decision Tree

START
├── API type is GraphQL?
│   ├── YES → Use schema evolution with @deprecated directives. Do NOT use URL versioning.
│   └── NO ↓
├── API type is gRPC?
│   ├── YES → Use protobuf package versioning (package api.v1;). Route at service level.
│   └── NO ↓
├── Is this a public API with third-party consumers?
│   ├── YES → URL path versioning (/api/v1/...). Maximum discoverability.
│   │   └── Breaking changes very frequent? → Consider date-based versioning (Stripe model)
│   └── NO ↓
├── Do you control all consumers (internal/microservices)?
│   ├── YES → Header versioning (Accept or custom header) keeps URLs clean.
│   │   └── Want zero versioning overhead? → Additive-only evolution + contract tests
│   └── NO ↓
├── Is this a partner API with limited known consumers?
│   ├── YES → URL path versioning + direct migration support per partner
│   └── NO ↓
└── DEFAULT → URL path versioning (/api/v1/...) — safest, most widely understood

Step-by-Step Guide

1. Choose your versioning strategy

Evaluate your API's audience, breaking change frequency, and infrastructure. Public APIs benefit from URL path versioning for maximum discoverability. Internal APIs can use header versioning for cleaner URLs. [src1]

Decision factors:
- Public API → URL path versioning (/api/v1/)
- Internal API, all clients controlled → Header versioning or evolution
- Frequent breaking changes → Date-based versioning (Stripe model)
- GraphQL → Schema evolution only

Verify: Document your choice in your API design spec. Ensure the team agrees before implementation.

2. Implement URL path versioning with route prefixes

Structure your application with version-prefixed route groups. Each version can share common logic through service layers while keeping route handlers separate. [src7]

// Express.js — version-prefixed routers
const express = require('express');
const app = express();

const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

Verify: curl http://localhost:3000/api/v1/users and curl http://localhost:3000/api/v2/users return version-appropriate responses.

3. Add version middleware for header-based versioning

When using header versioning, create middleware that extracts the version from headers and routes to the correct handler. [src4]

function versionRouter(req, res, next) {
  const version = req.headers['accept-version']
    || req.headers['x-api-version']
    || '1'; // default to v1
  req.apiVersion = parseInt(version, 10);
  next();
}

Verify: curl -H "Accept-Version: 2" http://localhost:3000/api/users returns v2 format.

4. Implement deprecation with Sunset headers

When deprecating an API version, add Sunset and Deprecation headers to every response. This gives clients programmatic notice of the retirement date. [src5]

function deprecateV1(req, res, next) {
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
  res.set('Link', '</api/v2/docs>; rel="successor-version"');
  next();
}
app.use('/api/v1', deprecateV1);

Verify: curl -I http://localhost:3000/api/v1/users includes Deprecation: true and Sunset: headers.

5. Set up version lifecycle management

Establish a deprecation timeline: announce 6 months before retirement, provide active migration support for 12 months, remove after 18-24 months. Track version usage to identify migration stragglers. [src2]

Timeline template:
  T+0:    Release v2, v1 still fully supported
  T+6mo:  Announce v1 deprecation, add Sunset headers
  T+12mo: v1 returns Warning header, rate-limit v1 calls
  T+18mo: v1 returns 410 Gone for new integrations
  T+24mo: v1 fully removed

Verify: Monitor version usage in your API analytics dashboard. v1 traffic should trend toward zero before removal.

Code Examples

Node.js/Express: URL Path Versioning with Shared Services

// Input:  HTTP requests to /api/v1/users or /api/v2/users
// Output: Version-appropriate JSON responses

// routes/v1/users.js
const router = require('express').Router();
const UserService = require('../../services/userService');

router.get('/users', async (req, res) => {
  const users = await UserService.list(req.query);
  res.json(users); // v1: flat array response
});

// routes/v2/users.js
router.get('/users', async (req, res) => {
  const users = await UserService.list(req.query);
  res.json({
    data: users,
    meta: { total: users.length, page: 1 }
  }); // v2: envelope with pagination
});

Python/FastAPI: URL Path Versioning with APIRouter

# Input:  HTTP requests to /api/v1/users or /api/v2/users
# Output: Version-appropriate JSON responses

from fastapi import FastAPI, APIRouter

app = FastAPI()
v1 = APIRouter(prefix="/api/v1", tags=["v1"])
v2 = APIRouter(prefix="/api/v2", tags=["v2"])

@v1.get("/users")
async def get_users_v1():
    users = await fetch_users()
    return users  # flat list

@v2.get("/users")
async def get_users_v2():
    users = await fetch_users()
    return {"data": users, "meta": {"total": len(users)}}

app.include_router(v1)
app.include_router(v2)

Go: URL Path Versioning with http.ServeMux

// Input:  HTTP requests to /api/v1/users or /api/v2/users
// Output: Version-appropriate JSON responses

func usersV1(w http.ResponseWriter, r *http.Request) {
    users := []User{{ID: 1, Name: "Alice"}}
    json.NewEncoder(w).Encode(users) // flat array
}

func usersV2(w http.ResponseWriter, r *http.Request) {
    users := []User{{ID: 1, Name: "Alice"}}
    resp := map[string]interface{}{
        "data": users,
        "meta": map[string]int{"total": len(users)},
    }
    json.NewEncoder(w).Encode(resp) // envelope
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/v1/users", usersV1)
    mux.HandleFunc("/api/v2/users", usersV2)
    http.ListenAndServe(":8080", mux)
}

Anti-Patterns

Wrong: Breaking changes without version bump

// BAD — changing response shape in existing version
app.get('/api/v1/users', (req, res) => {
  // Renamed "name" to "full_name" and wrapped in envelope
  res.json({ data: [{ id: 1, full_name: "Alice" }], meta: {} });
  // Every existing client just broke
});

Correct: New version for breaking changes

// GOOD — breaking change goes in new version
app.get('/api/v1/users', (req, res) => {
  res.json([{ id: 1, name: "Alice" }]); // unchanged
});
app.get('/api/v2/users', (req, res) => {
  res.json({ data: [{ id: 1, full_name: "Alice" }], meta: {} });
});

Wrong: Copying entire codebase per version

// BAD — duplicating all code for each version
src/
  v1/controllers/ models/ services/  # full copy
  v2/controllers/ models/ services/  # full copy with one change

Correct: Shared services with version-specific handlers

// GOOD — share business logic, version only the API layer
src/
  services/     # shared business logic
  routes/v1/    # thin route handlers calling services
  routes/v2/    # thin route handlers calling services

Wrong: Versioning too eagerly

// BAD — creating new versions for additive changes
// v1: { id, name }
// v2: { id, name, email }        <-- could be additive
// v3: { id, name, email, phone } <-- could be additive
// Now maintaining 3 versions unnecessarily

Correct: Additive changes without version bump

// GOOD — adding optional fields is NOT a breaking change
app.get('/api/v1/users', (req, res) => {
  res.json([{ id: 1, name: "Alice", email: "[email protected]", phone: null }]);
  // Old clients safely ignore email and phone
});

Common Pitfalls

Diagnostic Commands

# Check which API version a response is using
curl -s -D - http://localhost:3000/api/v1/users | head -20

# Test header-based versioning
curl -s -H "Accept-Version: 2" http://localhost:3000/api/users | jq .

# Check for deprecation headers
curl -s -I http://localhost:3000/api/v1/users | grep -i -E "deprecation|sunset|link"

# Count API version distribution in access logs
awk -F'"' '{print $2}' access.log | grep -oP '/api/v\d+' | sort | uniq -c | sort -rn

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Public API with third-party consumersInternal single-consumer microserviceContract testing (Pact) + additive evolution
Breaking response shape changes neededAdding new optional fields to responsesAdditive changes — no version needed
Multiple client generations must coexistGraphQL APISchema evolution with @deprecated directives
Regulatory or compliance requires stabilitygRPC service-to-serviceProtobuf package versioning
API monetization with SLA commitmentsPrototype or MVP stageShip unversioned, add versioning before public launch
Long-lived webhooks with fixed payloadsRapidly iterating internal toolFeature flags or gradual rollout

Important Caveats

Related Units