Versioning an API

Why a public API needs versions, how URL and header versioning differ, and exactly what counts as a breaking change.

SyntaxGET /v2/books/7 or GET /books/7 with X-Api-Version: 2.0

Once something you do not control calls your API, you cannot change it freely. A mobile app in a user's pocket keeps sending yesterday's requests for months. Versioning is how you ship a new shape without breaking the old callers: the old version keeps working while the new one exists beside it.

What counts as breaking

Breaking means a consumer that worked before stops working. Removing a field, renaming one, changing its type, changing its meaning, making an optional request field required, adding a new required request field, narrowing what a parameter accepts, turning a 200-with-body into a 204, or changing a status code a client branches on. Adding an optional field to a response is not breaking, as long as clients ignore fields they do not know. Adding a whole new endpoint is not breaking either.

Where the version goes

In the URL: /v2/books/7. It is visible, easy to curl, easy to route, and cacheable without extra configuration. The objection is that the same resource now has two URLs. In a header: X-Api-Version: 2.0, or in the media type. URLs stay stable, but a request is no longer something you can paste into a browser bar, and caches must be told to vary on that header. URL versioning is the common choice for public APIs; header versioning fits internal services with generated clients.

Keeping it honest

Version the whole API, not each endpoint. Freeze a version when you publish it, and announce an end date rather than deleting it quietly.

Example

Example · csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// URL versioning with nothing but route groups. Each version is a group,
// and a group frozen on the day it is published stays frozen.
RouteGroupBuilder v1 = app.MapGroup("/v1/books");

v1.MapGet("/{id:int}", (int id) =>
    TypedResults.Ok(new BookV1(id, "Dune, Frank Herbert")));

// v2 splits one field into two. Old clients read TitleAndAuthor and would
// find nothing there, so this cannot go into v1: it needs its own version.
RouteGroupBuilder v2 = app.MapGroup("/v2/books");

v2.MapGet("/{id:int}", (int id) =>
    TypedResults.Ok(new BookV2(id, "Dune", "Frank Herbert")));

app.Run();

// One DTO per version. They diverge on purpose; do not share a base type
// and then change it, because that is how v1 breaks.
record BookV1(int Id, string TitleAndAuthor);
record BookV2(int Id, string Title, string Author);

When to use it

  • Changing a response field's name without breaking a mobile app that is already installed.
  • Deciding whether to put the version in the path or in a header before the first external client integrates.
  • Answering a reviewer who says an added optional field needs a v2, with a concrete rule for what breaking means.
  • Retiring an old version on a published date, with the warning visible in every response beforehand.

More examples

URL and header versioning together, with the Asp.Versioning package

Hand-rolled route groups stop scaling once you want both styles, a default version and headers telling clients which versions exist. This package reads the version from wherever the client put it and routes accordingly. Add AddApiExplorer() to the AddApiVersioning() call when you also want a separate OpenAPI document per version.

Example · csharp
// dotnet add package Asp.Versioning.Http
using Asp.Versioning;
using Asp.Versioning.Builder;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;

    // Adds api-supported-versions and api-deprecated-versions response headers.
    options.ReportApiVersions = true;

    // Accept the version from the URL segment or from a header.
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"));
});

var app = builder.Build();

ApiVersionSet versions = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .Build();

app.MapGet("/v{version:apiVersion}/books", () => new[] { "Dune" })
   .WithApiVersionSet(versions)
   .MapToApiVersion(new ApiVersion(1, 0));

app.Run();

The additive change that does not need a new version

Most changes people reach for a version number over do not need one. Adding a field, adding an endpoint and loosening a validation rule are all safe. Keeping this list in front of you is what stops an API growing a v2, v3 and v4 in a single quarter.

Example · csharp
// Non-breaking: PageCount is optional and defaults to null, so a client
// written against the three-field version still deserialises this fine.
public record BookV2(int Id, string Title, string Author, int? PageCount = null);

// Breaking, and therefore a v3:
//   - renaming Title to Name
//   - changing Id from int to string
//   - removing Author
//   - making PageCount required in a request body
//   - returning 204 where a 200 with a body used to come back
//   - rejecting an input a previous version accepted

Announcing the end of a version instead of deleting it

A version you intend to retire should say so in every response, months before it goes. Clients and gateways can log these headers, so the people still on v1 find out from their own telemetry rather than from an outage. Do this the day v2 ships, not the week before v1 is switched off.

Example · csharp
RouteGroupBuilder v1 = app.MapGroup("/v1/books");

v1.MapGet("/{id:int}", (int id, HttpResponse response) =>
{
    // Sunset (RFC 8594) carries the date this version stops responding.
    response.Headers["Sunset"] = "Wed, 01 Jul 2026 00:00:00 GMT";

    // Deprecation (RFC 9745) carries the date this version became, or
    // becomes, deprecated. Its value is a structured-field date: "@"
    // followed by a Unix timestamp. A bare "true" is not valid.
    response.Headers["Deprecation"] = "@1767225600";   // 2026-01-01

    // Link to the migration notes so the header is actionable.
    response.Headers["Link"] = "<https://example.com/api/v2>; rel=\"successor-version\"";

    return TypedResults.Ok(new BookV1(id, "Dune, Frank Herbert"));
});

Discussion

  • Be the first to comment on this lesson.