API Versioning Strategies in ASP.NET Core

APIs evolve. Fields get renamed, endpoints restructured, response shapes changed. Without versioning, every change risks breaking existing clients. ASP.NET Core doesn't include versioning out of the box, but the Asp.Versioning packages (formerly Microsoft.AspNetCore.Mvc.Versioning) provide a mature, flexible solution.

Setup

Install the package:

terminal
dotnet add package Asp.Versioning.Http        # For minimal APIs
dotnet add package Asp.Versioning.Mvc          # For controllers

Configure versioning in Program.cs:

Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"));
});

ReportApiVersions adds api-supported-versions and api-deprecated-versions headers to every response, helping clients discover what's available.

URL Segment Versioning

The most explicit and commonly used approach — the version is part of the URL:

ProductsV1Controller.cs
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("1.0")]
public class ProductsV1Controller : ControllerBase
{
    [HttpGet]
    public IActionResult GetAll()
    {
        return Ok(new[] { new { Id = 1, Name = "Widget" } });
    }
}

[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("2.0")]
public class ProductsV2Controller : ControllerBase
{
    [HttpGet]
    public IActionResult GetAll()
    {
        return Ok(new[] { new { Id = 1, Name = "Widget", Sku = "WDG-001" } });
    }
}

Clients call /api/v1/products or /api/v2/products. The routing constraint {version:apiVersion} ensures the correct controller handles each version.

Query String Versioning

Some teams prefer keeping URLs clean and passing the version as a query parameter:

Example.cs
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");

Clients call /api/products?api-version=2.0. This is the default reader if you don't specify one.

Header Versioning

Header versioning keeps the URL and query string completely free of versioning concerns:

Example.cs
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");

Clients include X-Api-Version: 2.0 in the request headers.

Minimal API Versioning

For minimal APIs, versioning works with route groups:

Example.cs
var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

var v1 = app.MapGroup("api/v{version:apiVersion}/products")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(new ApiVersion(1, 0));

var v2 = app.MapGroup("api/v{version:apiVersion}/products")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(new ApiVersion(2, 0));

v1.MapGet("/", () => Results.Ok(new[] { new { Id = 1, Name = "Widget" } }));

v2.MapGet("/", () => Results.Ok(new[] { new { Id = 1, Name = "Widget", Sku = "WDG-001" } }));

Deprecating Versions

Mark old versions as deprecated to signal to clients they should migrate:

Example.cs
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [MapToApiVersion("1.0")]
    public IActionResult GetAllV1() => Ok(new[] { new { Id = 1, Name = "Widget" } });

    [HttpGet]
    [MapToApiVersion("2.0")]
    public IActionResult GetAllV2() => Ok(new[] { new { Id = 1, Name = "Widget", Sku = "WDG-001" } });
}

Deprecated versions still work, but the response includes an api-deprecated-versions header. This gives clients time to migrate without breaking them.

Version-Neutral Endpoints

Some endpoints don't change between versions — health checks, discovery endpoints, or simple utilities. Mark these as version-neutral:

HealthController.cs
[ApiController]
[Route("api/health")]
[ApiVersionNeutral]
public class HealthController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok(new { Status = "Healthy" });
}

Combining Readers

You can accept versions from multiple sources simultaneously:

Example.cs
options.ApiVersionReader = ApiVersionReader.Combine(
    new UrlSegmentApiVersionReader(),
    new QueryStringApiVersionReader("api-version"),
    new HeaderApiVersionReader("X-Api-Version"),
    new MediaTypeApiVersionReader("version"));

This is useful during a migration from one versioning style to another, but in steady state you should pick one approach and stick with it.

Choosing a Strategy

URL segment is the most visible and debuggable — you can see the version in logs, browser address bars, and curl commands. It's the most popular choice for public APIs.

Query string is the default and works well for internal APIs where URL cleanliness matters less than simplicity.

Header is invisible in URLs, which appeals to purists who argue that the version isn't part of the resource identity. It's harder to test casually.

Media type (e.g., Accept: application/json;version=2) is the most RESTful approach but the least practical for most teams.

Whichever strategy you choose, the Asp.Versioning packages handle the mechanics. Your job is to decide when a change warrants a new version — and when it doesn't.