REST API Versioning: URL, Header, and Query Parameter Strategies

Breaking changes are inevitable in any API's lifecycle. Whether you're renaming fields, changing response structures, or modifying business logic, these changes will break client applications that...

Key Insights

  • URL path versioning (/api/v1/users) offers the best visibility and testability, making it ideal for public APIs and teams prioritizing developer experience over REST purity.
  • Header-based versioning keeps URLs clean and aligns with REST principles, but requires more sophisticated tooling and can complicate debugging and documentation.
  • Most production systems benefit from explicit versioning strategies combined with deprecation headers and sunset policies rather than trying to maintain indefinite backwards compatibility.

Why API Versioning Matters

Breaking changes are inevitable in any API’s lifecycle. Whether you’re renaming fields, changing response structures, or modifying business logic, these changes will break client applications that depend on your existing contract. Without a versioning strategy, you’re forced to choose between never making breaking changes or breaking all your clients simultaneously.

The cost of getting this wrong is substantial. Mobile apps can’t force users to update immediately. Third-party integrations may take months to migrate. Internal services might be maintained by different teams with different priorities. A solid versioning strategy lets you introduce improvements while giving clients time to migrate on their own schedule.

The three dominant approaches—URL path versioning, header-based versioning, and query parameter versioning—each make different tradeoffs between visibility, REST compliance, and implementation complexity. Let’s examine each in detail.

URL Path Versioning

URL path versioning embeds the version number directly in the endpoint path. This is the most common approach you’ll see in production APIs, from Stripe to GitHub.

// Express.js router setup with versioned endpoints
import express from 'express';

const app = express();

// Version 1 - original user structure
app.get('/api/v1/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    created: user.createdAt
  });
});

// Version 2 - split name into firstName/lastName
app.get('/api/v2/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  
  res.json({
    id: user.id,
    firstName: user.firstName,
    lastName: user.lastName,
    email: user.email,
    metadata: {
      createdAt: user.createdAt,
      updatedAt: user.updatedAt
    }
  });
});

The advantages are compelling. Version information is immediately visible in URLs, making debugging trivial. You can test different versions directly in your browser or with curl. Documentation tools automatically organize endpoints by version. Load balancers and proxies can route traffic based on version without inspecting headers or query parameters.

The downsides are equally clear. You’re duplicating route definitions, which can lead to code duplication if you’re not careful. URLs become longer and more cluttered. Each new version potentially requires duplicating every endpoint, even those that haven’t changed.

Despite these drawbacks, URL versioning remains the pragmatic choice for most APIs. The visibility and simplicity outweigh the maintenance burden, especially for public APIs where developer experience is paramount.

Header-Based Versioning

Header-based versioning moves the version indicator into HTTP headers, keeping URLs clean and theoretically more RESTful. You can use custom headers or leverage the Accept header with media types.

// Middleware to parse version headers
function versionMiddleware(req, res, next) {
  // Check custom header first
  let version = req.headers['api-version'];
  
  // Fall back to Accept header parsing
  if (!version && req.headers.accept) {
    const match = req.headers.accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
    if (match) {
      version = match[1];
    }
  }
  
  // Default to v1 if no version specified
  req.apiVersion = version || '1';
  next();
}

app.use(versionMiddleware);

app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  
  if (req.apiVersion === '2') {
    return res.json({
      id: user.id,
      firstName: user.firstName,
      lastName: user.lastName,
      email: user.email,
      metadata: {
        createdAt: user.createdAt,
        updatedAt: user.updatedAt
      }
    });
  }
  
  // Version 1 response
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    created: user.createdAt
  });
});

Client-side usage requires setting headers explicitly:

// Using fetch with custom header
const response = await fetch('https://api.example.com/users/123', {
  headers: {
    'API-Version': '2',
    'Content-Type': 'application/json'
  }
});

// Using Accept header with media type versioning
const response = await fetch('https://api.example.com/users/123', {
  headers: {
    'Accept': 'application/vnd.myapi.v2+json'
  }
});

Header-based versioning appeals to REST purists because it separates resource identification (the URL) from representation negotiation (the headers). URLs remain stable across versions, which feels cleaner.

However, this approach introduces friction. You can’t easily test different versions by changing a URL. Browser dev tools require more clicks to inspect headers. Documentation becomes more complex. Caching layers need to respect version headers with Vary headers, which many CDNs handle poorly.

Use header-based versioning when you have sophisticated clients, strong REST principles, and tooling that makes header management seamless. Avoid it for public APIs where simplicity matters more than architectural purity.

Query Parameter Versioning

Query parameter versioning offers a middle ground, adding version information to the URL without modifying the path structure.

app.get('/api/users/:id', async (req, res) => {
  const version = req.query.version || '1';
  const user = await db.users.findById(req.params.id);
  
  // Handle different versions
  switch(version) {
    case '2':
      return res.json({
        id: user.id,
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
        metadata: {
          createdAt: user.createdAt,
          updatedAt: user.updatedAt
        }
      });
    
    case '1':
    default:
      return res.json({
        id: user.id,
        name: user.name,
        email: user.email,
        created: user.createdAt
      });
  }
});

This approach is simple to implement and test. URLs like /api/users/123?version=2 are easy to modify and share. No middleware or header parsing required.

The problems emerge at scale. Query parameters have semantic meaning in REST—they typically filter or modify the resource, not change its fundamental structure. Mixing version parameters with actual query parameters creates confusion. Is ?version=2&status=active filtering active users in v2, or getting all users with version and status filters?

Query parameter versioning works acceptably for internal APIs or simple use cases, but it’s the least common approach in production systems for good reason.

Practical Comparison & Decision Framework

Here’s how these strategies compare across key dimensions:

Aspect URL Path Headers Query Params
Visibility Excellent Poor Good
Browser Testing Easy Difficult Easy
Caching Simple Complex Moderate
REST Compliance Moderate Excellent Poor
Implementation Moderate Complex Simple
Documentation Easy Moderate Easy

Choose URL path versioning when you have a public API, multiple client types, or teams that value simplicity over REST purity. This is the safe default choice.

Choose header-based versioning when you have sophisticated clients, strong architectural principles, and the tooling to make header management seamless. This works well for internal microservices or APIs consumed by teams you control.

Choose query parameter versioning sparingly, primarily for internal tools or simple APIs where you need quick implementation and don’t expect many versions.

Here’s a utility function that supports multiple strategies for maximum flexibility:

function negotiateVersion(req) {
  // Priority: URL path > custom header > Accept header > query param
  
  // Check URL path (e.g., /api/v2/users)
  const pathMatch = req.path.match(/\/v(\d+)\//);
  if (pathMatch) return pathMatch[1];
  
  // Check custom header
  if (req.headers['api-version']) {
    return req.headers['api-version'];
  }
  
  // Check Accept header
  if (req.headers.accept) {
    const acceptMatch = req.headers.accept.match(/application\/vnd\.myapi\.v(\d+)/);
    if (acceptMatch) return acceptMatch[1];
  }
  
  // Check query parameter
  if (req.query.version) {
    return req.query.version;
  }
  
  // Default version
  return '1';
}

Implementation Best Practices

Regardless of your versioning strategy, implement deprecation warnings to guide clients through migrations:

function deprecationMiddleware(req, res, next) {
  const version = negotiateVersion(req);
  
  // Define deprecation timeline
  const deprecatedVersions = {
    '1': {
      sunset: '2024-12-31',
      deprecatedSince: '2024-06-01',
      migrateTo: '2'
    }
  };
  
  if (deprecatedVersions[version]) {
    const info = deprecatedVersions[version];
    
    // Add standard deprecation headers
    res.set('Deprecation', 'true');
    res.set('Sunset', info.sunset);
    res.set('Link', `</api/v${info.migrateTo}/docs>; rel="successor-version"`);
    
    // Optionally add warning header
    res.set('Warning', 
      `299 - "API version ${version} is deprecated. ` +
      `Migrate to v${info.migrateTo} before ${info.sunset}"`
    );
  }
  
  next();
}

app.use(deprecationMiddleware);

Establish clear version lifecycle policies. Maintain at least two versions simultaneously—the current version and one previous version. Announce deprecations at least 6-12 months before sunset dates. Monitor usage of old versions to identify clients that need migration support.

Document version differences prominently. Create migration guides that show side-by-side examples of old and new responses. Use changelog formats that highlight breaking changes versus backwards-compatible additions.

Conclusion

API versioning isn’t optional—it’s a fundamental requirement for any API that will evolve over time. URL path versioning offers the best balance of visibility, simplicity, and developer experience for most use cases. Header-based versioning appeals to REST purists and works well in controlled environments. Query parameter versioning should be reserved for simple internal APIs.

More important than which strategy you choose is that you choose one deliberately and implement it consistently. Combine your versioning strategy with clear deprecation policies, sunset timelines, and migration documentation. Your future self—and your API consumers—will thank you for the foresight.

Start with URL path versioning unless you have compelling reasons to do otherwise. It’s battle-tested, widely understood, and strikes the right balance between pragmatism and maintainability.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.