Skip to main content
SEMastery
ASP.NETintermediate

API Versioning in ASP.NET Core: A Friendly, Complete Guide

Learn API versioning in ASP.NET Core with simple examples. URL, query string, header, and media type versioning explained with diagrams, code, and OpenAPI tips.

12 min readUpdated March 7, 2026

A train with different platforms

Picture a busy railway station in your city. The same train line runs every day, but the railway keeps improving it. The old train still stops at Platform 1 for people who are used to it. The new, faster train stops at Platform 2. Both trains take you to the same city, but they work a little differently inside — new seats, new doors, new rules.

The station does not force everyone onto the new train on the same morning. That would cause chaos. Instead, both platforms run side by side for a while. People move to the new train when they are ready. Slowly, the old train is retired.

Your web API is just like that station. Over time you change how it works — you rename a field, you add a new rule, you remove an old one. But other people's apps already depend on the old behaviour. If you change everything at once, their apps break. API versioning is the station master who keeps both platforms open, points each traveller to the right train, and gives everyone time to move.

Let us learn how to build those platforms in ASP.NET Core.

Why versioning matters

When you build an API, other programs start to depend on it. A mobile app, a partner's website, a background job — they all send requests and expect a certain shape of answer back. The moment you change that shape, you risk breaking them.

There are two kinds of changes:

  • Additive changes are safe. Adding a new endpoint, a new optional field, or a new optional query parameter usually does not break anyone. Old clients simply ignore the new bits.
  • Breaking changes are not safe. Renaming a field, removing a field, changing a data type, or changing the meaning of a value can crash an old client.

You only need a new version for breaking changes. If a change is purely additive, keep the same version. This keeps your version numbers meaningful and your life simple.

Deciding whether a change needs a new API version

The four ways to choose a version

A client needs a way to tell your server which version it wants. There are four common strategies, and ASP.NET Core supports all of them. Here they are side by side.

StrategyExample requestGood forTrade-off
URL segmentGET /v1/ordersPublic APIs, easy to read and cacheVersion lives in the path, so routes change
Query stringGET /orders?api-version=1.0Quick to start, easy to testClutters the URL, easy to forget
HeaderGET /orders with X-Api-Version: 1.0Internal services, clean URLsHidden, harder to test in a browser
Media typeAccept: application/json;v=1.0Strict REST fansHardest to use and explain

Most teams pick URL segment versioning for public APIs because anyone can see the version just by reading the address. For internal microservices, header versioning is popular because it keeps URLs tidy.

How a versioned request flows

Client
Reader
Selector
Handler

Steps

1

Client

Sends version in URL, header, or query

2

Reader

Framework reads the version value

3

Selector

Picks the matching endpoint

4

Handler

Runs the right code for that version

From client request to the right handler

Getting started: install the packages

For .NET 10, the versioning toolkit lives in the community-maintained dotnet/aspnet-api-versioning project. The package names start with Asp.Versioning. Pick the one that matches how you build your API.

  • Use Asp.Versioning.Http for Minimal APIs.
  • Use Asp.Versioning.Mvc for controllers.
  • Add Asp.Versioning.Mvc.ApiExplorer if you want one Swagger or OpenAPI document per version.

You can add them from the command line:

// Run these in your project folder (they are dotnet CLI commands)
// dotnet add package Asp.Versioning.Mvc --version 10.0.0
// dotnet add package Asp.Versioning.Mvc.ApiExplorer --version 10.0.0
 
// Then register versioning in Program.cs
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddControllers();
 
builder.Services
    .AddApiVersioning(options =>
    {
        // If a client does not ask for a version, assume this one.
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = true;
 
        // Add api-supported-versions and api-deprecated-versions headers.
        options.ReportApiVersions = true;
    })
    .AddMvc();          // wires versioning into MVC controllers
 
var app = builder.Build();
app.MapControllers();
app.Run();

Three options do most of the work here:

  • DefaultApiVersion is the version used when a client does not say which one it wants.
  • AssumeDefaultVersionWhenUnspecified lets old clients that never knew about versions keep working.
  • ReportApiVersions adds helpful response headers so clients can see what is available.

URL segment versioning with controllers

This is the friendliest style for most people. The version sits right in the route, like /v1/weather and /v2/weather. Let us version a simple controller.

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
 
[ApiController]
[ApiVersion(1.0)]
[ApiVersion(2.0)]
[Route("v{version:apiVersion}/weather")]
public class WeatherController : ControllerBase
{
    // Responds to GET /v1/weather
    [HttpGet]
    [MapToApiVersion(1.0)]
    public IActionResult GetV1()
    {
        return Ok(new { temp = 30, note = "Version 1: temp only" });
    }
 
    // Responds to GET /v2/weather
    [HttpGet]
    [MapToApiVersion(2.0)]
    public IActionResult GetV2()
    {
        // Version 2 adds humidity. That is a breaking change in shape,
        // so it earns a new version.
        return Ok(new { temp = 30, humidity = 70, note = "Version 2: richer data" });
    }
}

The magic is the route template v{version:apiVersion}. The {version:apiVersion} part is a special route constraint added by the versioning library. It reads the number from the URL and matches it to the [ApiVersion] attributes on the controller. The [MapToApiVersion] attribute on each method says which version that method serves.

So GET /v1/weather runs GetV1, and GET /v2/weather runs GetV2. Same controller, two platforms.

URL segment versioning routes to the matching method

Minimal API versioning

If you prefer Minimal APIs, the idea is the same but the code is lighter. You build a version set and attach it to your endpoints.

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;
        options.ReportApiVersions = true;
    });
 
var app = builder.Build();
 
// A version set groups the versions an endpoint family supports.
ApiVersionSet versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();
 
var group = app.MapGroup("v{version:apiVersion}")
               .WithApiVersionSet(versionSet);
 
group.MapGet("/weather", () => new { temp = 30 })
     .MapToApiVersion(1, 0);
 
group.MapGet("/weather", () => new { temp = 30, humidity = 70 })
     .MapToApiVersion(2, 0);
 
app.Run();

Here NewApiVersionSet describes which versions exist. MapGroup("v{version:apiVersion}") puts the version into the URL, just like the controller route. Each MapGet then picks the version it answers with MapToApiVersion. The result is the same two platforms, /v1/weather and /v2/weather, with much less ceremony.

Reading the version from a header or query string

The URL is not the only place a version can live. The ApiVersionReader decides where ASP.NET Core looks. You can use one reader, or combine several so a client may choose any of them.

using Asp.Versioning;
 
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
 
    // Accept the version from a query string, a header, OR the URL.
    options.ApiVersionReader = ApiVersionReader.Combine(
        new QueryStringApiVersionReader("api-version"),
        new HeaderApiVersionReader("X-Api-Version"),
        new UrlSegmentApiVersionReader());
});

With this setup, all three of these requests reach version 2.0:

  • GET /orders?api-version=2.0
  • GET /orders with header X-Api-Version: 2.0
  • GET /v2/orders

Combining readers is handy while you migrate. New clients can use the clean style you prefer, while older clients still work with the style they already use.

Reader classWhere it looksExample
UrlSegmentApiVersionReaderA part of the path/v2/orders
QueryStringApiVersionReaderA query parameter/orders?api-version=2.0
HeaderApiVersionReaderA request headerX-Api-Version: 2.0
MediaTypeApiVersionReaderThe Accept headerAccept: application/json;v=2.0

Deprecating a version the kind way

You should never pull the rug out from under your clients. When a version is on its way out, mark it deprecated first. This does not turn it off. It just signals "this still works, but please move soon."

// The old version still runs, but it is flagged as deprecated.
[ApiVersion(1.0, Deprecated = true)]
[ApiVersion(2.0)]
[Route("v{version:apiVersion}/orders")]
public class OrdersController : ControllerBase { /* ... */ }

Because ReportApiVersions is on, a response now includes headers like:

  • api-supported-versions: 2.0
  • api-deprecated-versions: 1.0

A well-behaved client reads these and knows to upgrade. After enough time has passed and traffic on the old version has dropped, you can safely remove it. This slow, polite path is sometimes called sunset discipline: announce, deprecate, wait, then retire.

The deprecation lifecycle

Announce
Deprecate
Wait
Retire

Steps

1

Announce

Tell clients a new version exists

2

Deprecate

Flag old version with headers

3

Wait

Give clients months to migrate

4

Retire

Remove the old version safely

Retire a version without breaking clients

Showing every version in Swagger and OpenAPI

When you have several versions, your API documentation needs to show each one clearly. The Asp.Versioning.Mvc.ApiExplorer package builds a separate OpenAPI document for every version, so readers can switch between them.

In .NET 10 there is one important detail. After you turn on versioning, you must call the AddOpenApi method from the Asp.Versioning namespace, not the plain one. This special variant knows how to split your endpoints into one document per version.

using Asp.Versioning;
 
builder.Services
    .AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.ReportApiVersions = true;
    })
    .AddApiExplorer(options =>
    {
        // Group name format: 'v'major[.minor], e.g. v1, v2.
        options.GroupNameFormat = "'v'VVV";
        options.SubstituteApiVersionInUrl = true;
    });
 
// Use the versioning-aware AddOpenApi from the Asp.Versioning namespace.
builder.Services.AddOpenApi();

The GroupNameFormat = "'v'VVV" setting names each group v1, v2, and so on. SubstituteApiVersionInUrl = true replaces the {version} placeholder in the path with the real number in the generated docs, so the examples look clean.

One OpenAPI document per version

Picking a strategy: a simple guide

You do not have to overthink this. Here is a short, practical guide for what to choose.

  • Building a public API that outside teams call? Use URL segment versioning. It is visible, cacheable, and works in any tool, even a plain browser.
  • Building internal microservices? Use header versioning so your URLs stay clean and stable.
  • Just experimenting or building a small tool? Use query string versioning. It is the quickest to set up and test.
  • A strong REST purist on the team? Media type versioning is the most "correct" by the book, but it is the hardest for others to use, so weigh that cost.

Whatever you pick, stay consistent. Mixing styles across one API confuses the people who call it.

Common mistakes to avoid

A few traps catch many beginners. Knowing them ahead of time saves hours.

  • Versioning additive changes. If you only added an optional field, you did not break anyone. Do not bump the version. Save versions for real breaking changes.
  • Forgetting AssumeDefaultVersionWhenUnspecified. Without it, old clients that never sent a version suddenly get errors. Turn it on so they keep working.
  • Removing a version with no warning. Always deprecate first and watch the traffic. Pulling a live version is how you get angry phone calls.
  • Using the wrong AddOpenApi. In .NET 10, the plain one will not split your docs by version. Import from Asp.Versioning.
  • Mixing styles randomly. Pick one reader strategy as your main one. Combine readers only on purpose, usually to ease a migration.

While you set up an API, you may reach for messaging or mediator libraries too. Be aware that MediatR and MassTransit moved to commercial licensing in their recent versions. They are still excellent, but check the license terms and pricing before you add them to a production project. For pure versioning, you do not need them at all. The Asp.Versioning packages are open source and free under the dotnet foundation umbrella.

Quick recap

  • API versioning lets old and new clients use your API at the same time, like two trains running from different platforms.
  • You only need a new version for breaking changes. Additive changes keep the same version.
  • Install Asp.Versioning.Http for Minimal APIs or Asp.Versioning.Mvc for controllers. For .NET 10 these are version 10.0.0.
  • There are four ways to read a version: URL segment, query string, header, and media type. Use ApiVersionReader.Combine to accept several.
  • Set DefaultApiVersion, AssumeDefaultVersionWhenUnspecified, and ReportApiVersions to keep things smooth and clients informed.
  • Mark old versions Deprecated = true first, wait, then remove them. Never pull a live version with no warning.
  • For docs, add Asp.Versioning.Mvc.ApiExplorer and call the AddOpenApi from the Asp.Versioning namespace to get one document per version.

References and further reading

Related Posts