API Versioning in ASP.NET Core: Asp.Versioning on .NET 10
Last updated: October 4, 2026 · Tested with .NET 10 (SDK 10.0.401), Asp.Versioning.Mvc 10.2.1, Asp.Versioning.Http / Asp.Versioning.OpenApi 10.2.3, and the old Microsoft.AspNetCore.Mvc.Versioning 5.1.0 for the migration section
Short answer: API versioning in ASP.NET Core uses the Asp.Versioning.* NuGet packages. They replace Microsoft.AspNetCore.Mvc.Versioning, which is deprecated. Call builder.Services.AddApiVersioning(...), then .AddMvc() for controllers, then .AddApiExplorer() and .AddOpenApi() for documentation. Mark controllers with [ApiVersion(1.0)], or minimal APIs with app.NewVersionedApi(). I built a .NET 10 API that serves v1 and v2 through the URL, a header and the query string, and sent real requests to it. Everything below is real output, including four things that tripped me up. If you migrate and forget .AddMvc(), the app compiles and then returns 500. Sending 2 and 2.0 in the same request is ambiguous. With the default readers, a version header is silently ignored. And an unknown version in the URL is a 404, not a 400.
Install the Packages
dotnet add package Asp.Versioning.Mvc # controllers
dotnet add package Asp.Versioning.Mvc.ApiExplorer # versioned API descriptions
dotnet add package Asp.Versioning.OpenApi # one OpenAPI document per version (.NET 9+)
# minimal APIs only: dotnet add package Asp.Versioning.Http
The library is maintained in the dotnet/aspnet-api-versioning repo. Version 10.x targets .NET 10. Asp.Versioning.OpenApi works with the built-in Microsoft.AspNetCore.OpenApi document generator, so you don’t need Swashbuckle to get one document per version.
Program.cs: Controllers, Minimal APIs and OpenAPI
This is the full Program.cs I tested. It accepts the version from the URL segment, an x-api-version header or an api-version query string:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
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"),
new QueryStringApiVersionReader("api-version"));
// RFC 9745 Deprecation + RFC 8594 Sunset headers, only for the Products API
options.Policies.Deprecate("Products", 1.0)
.Effective(new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero))
.Link("https://example.com/api/migrate-to-v2");
options.Policies.Sunset("Products", 1.0)
.Effective(new DateTimeOffset(2027, 3, 31, 0, 0, 0, TimeSpan.Zero))
.Link("https://example.com/api/migrate-to-v2");
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
})
.AddOpenApi();
var app = builder.Build();
app.MapControllers();
// Minimal APIs: one versioned API, one group per version
var weather = app.NewVersionedApi("Weather");
var weatherV1 = weather.MapGroup("/api/v{version:apiVersion}/weather").HasApiVersion(1.0);
weatherV1.MapGet("/", () => new { version = "1.0", temp = 20 });
var weatherV2 = weather.MapGroup("/api/v{version:apiVersion}/weather").HasApiVersion(2.0);
weatherV2.MapGet("/", () => new { version = "2.0", tempC = 20, tempF = 68 });
// Same URL for every version: the version comes from the header or query string
var orders = app.NewVersionedApi("Orders");
orders.MapGroup("/api/orders").HasApiVersion(1.0)
.MapGet("/", (ApiVersion version) => new { version = version.ToString(), orders = 3 });
orders.MapGroup("/api/orders").HasApiVersion(2.0)
.MapGet("/", (ApiVersion version) => new { version = version.ToString(), orders = 3, total = 149.97m });
app.MapOpenApi().WithDocumentPerVersion();
app.Run();
Three lines matter more than they look:
.AddMvc()is what makes controllers version-aware. Without it, the attributes compile but do nothing (see the 500 below).AddProblemDetails()turns versioning errors intoapplication/problem+jsonresponses. The status code and thecodefield come from the library either way.- A minimal-API handler can take an
ApiVersionparameter to get the requested version, with no extra binding code.
On build, the library’s own analyzer warned me about one line:
Program.cs(11,5): warning AV0016: AssumeDefaultVersionWhenUnspecified = true is only
necessary for existing APIs that do not have an explicit API version
That is fair advice. AV0016 exists because assuming a default hides mistakes, as you will see in the header section. Keep it on only when you add versioning to an API that already has clients sending no version. For a new API, delete the line and make clients ask for a version.
URL Path Versioning (Controllers)
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[ApiVersion(1.0, Deprecated = true)]
[ApiVersion(2.0)]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion(1.0)]
public IActionResult GetV1() =>
Ok(new { version = "1.0", products = new[] { "Keyboard", "Mouse" } });
[HttpGet]
[MapToApiVersion(2.0)]
public IActionResult GetV2() =>
Ok(new
{
version = "2.0",
products = new[]
{
new { name = "Keyboard", price = 49.99m },
new { name = "Mouse", price = 19.99m }
}
});
}
Real responses (headers trimmed to the versioning ones):
GET /api/v1/products
200
api-supported-versions: 2.0
api-deprecated-versions: 1.0
Sunset: Wed, 31 Mar 2027 00:00:00 GMT
Link: <https://example.com/api/migrate-to-v2>; rel="sunset"
Link: <https://example.com/api/migrate-to-v2>; rel="deprecation"
Deprecation: @1790812800
{"version":"1.0","products":["Keyboard","Mouse"]}
GET /api/v2/products
200
api-supported-versions: 2.0
api-deprecated-versions: 1.0
{"version":"2.0","products":[{"name":"Keyboard","price":49.99},{"name":"Mouse","price":19.99}]}
GET /api/v3/products
404 (empty body)
Two things to notice. First, ReportApiVersions lists 1.0 under deprecated and only 2.0 under supported. Clients that read these headers get a warning on every call, including calls to v2. Second, an unknown version in the path returns a plain 404 with no body. The UnsupportedApiVersionStatusCode option defaults to 400, but in my tests that 400 only applied to header and query-string versions. With the URL segment, /api/v3/products doesn’t match any route, so the result is a 404. If your API docs promise a 400 for an unsupported version, check what the URL case really returns.
Header and Query String Versioning
The /api/orders endpoints have no version in the URL, so the version comes from the readers:
GET /api/orders
200 {"version":"1.0","orders":3} <- AssumeDefaultVersionWhenUnspecified
GET /api/orders x-api-version: 2.0
200 {"version":"2.0","orders":3,"total":149.97}
GET /api/orders?api-version=2.0
200 {"version":"2.0","orders":3,"total":149.97}
GET /api/orders?api-version=3.0
400 application/problem+json
{"type":"https://docs.api-versioning.org/problems#unsupported","title":"Unsupported API version",
"status":400,"detail":"The HTTP resource that matches the request URI 'http://localhost:5099/api/orders'
does not support the API version '3.0'.","code":"UnsupportedApiVersion",...}
Gotcha: “2” and “2.0” in the Same Request Is a 400
With several readers combined, a client can send the version twice, for example from a shared HTTP client that adds the header everywhere plus a URL that already has a query string. If the two values are different, the library returns 400, which makes sense. I didn’t expect it for 2 and 2.0:
GET /api/orders?api-version=2.0 x-api-version: 2
400 application/problem+json
{"type":"https://docs.api-versioning.org/problems#ambiguous","title":"Ambiguous API version",
"status":400,"detail":"The following API versions were requested: 2, 2.0. At most, only a single
API version may be specified. ...","code":"AmbiguousApiVersion",...}
The comparison is on the raw strings, not the parsed version. Pick one format (I use 2.0) and send it from one place only. Also note that the version echoes back as it was sent: a request with x-api-version: 2 gets "version":"2" from ApiVersion.ToString(), not "2.0".
Gotcha: The Default Readers Silently Ignore Your Header
If you don’t set ApiVersionReader, the default is the query string plus URL segment. There is no header reader. Combined with AssumeDefaultVersionWhenUnspecified = true, this is the response a v2 client gets:
GET /api/orders x-api-version: 2.0 (default readers)
200 {"version":"1.0","orders":3}
That’s a 200 with the v1 body and no error, so the bug shows up later as “the new field is missing”. It happens the other way round too: with only HeaderApiVersionReader configured, ?api-version=2.0 is ignored and the client gets v1. URL-segment routes kept working in both setups in my tests, because the version is part of the route template. With AssumeDefaultVersionWhenUnspecified off, the same mistake fails loudly instead:
GET /api/orders
400 {"type":"https://docs.api-versioning.org/problems#unspecified","title":"Unspecified API version",
"status":400,"detail":"An API version is required, but was not specified.","code":"ApiVersionUnspecified",...}
Gotcha: No Vary Header, So Caches Can Mix Versions
With header versioning the URL is the same for v1 and v2. My v2 response had no Vary header (only Content-Type, Date, Server and api-supported-versions). A CDN or reverse proxy that caches by URL can therefore serve a cached v1 body to a v2 client. If anything caches your API responses, add Vary: x-api-version yourself, or include the header in the cache key. With ASP.NET Core output caching that means .SetVaryByHeader("x-api-version") on the policy. URL and query-string versions don’t have this problem.
Media Type Versioning
MediaTypeApiVersionReader("v") reads the version from the Accept header parameter. I tested it with a minimal API and a version set:
GET /api/stock Accept: application/json;v=2.0
200 {"version":"2.0"}
GET /api/stock (no version, AssumeDefaultVersionWhenUnspecified off)
400
It works, but most clients and tools (browsers, Postman defaults, many generated SDKs) won’t send a custom Accept parameter, so I only use it for APIs that already depend on content negotiation.
Minimal APIs: NewVersionedApi vs NewApiVersionSet
The Program.cs above uses app.NewVersionedApi("Weather") with one route group per version. That is the cleaner model in 10.x, and the name (“Weather”) is what named policies and OpenAPI tags use. The older style also still works on 10.2.3. I ran this exact code:
ApiVersionSet versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.ReportApiVersions()
.Build();
app.MapGet("/api/v{version:apiVersion}/weather", () =>
Results.Ok(new { version = "1.0", temp = 20 }))
.WithApiVersionSet(versionSet)
.MapToApiVersion(1.0);
app.MapGet("/api/v{version:apiVersion}/weather", () =>
Results.Ok(new { version = "2.0", tempC = 20, tempF = 68 }))
.WithApiVersionSet(versionSet)
.MapToApiVersion(2.0);
GET /api/v1/weather 200 {"version":"1.0","temp":20}
GET /api/v2/weather 200 {"version":"2.0","tempC":20,"tempF":68}
If you have more than a couple of endpoints per version, use groups. One MapGroup per version keeps the version on the group, so you don’t repeat .MapToApiVersion() on every endpoint. For the basics of the endpoint style itself, see minimal APIs in .NET.
Deprecation and Sunset Headers
[ApiVersion(1.0, Deprecated = true)] only adds 1.0 to api-deprecated-versions. The standard headers come from policies. Those are Deprecation (RFC 9745, a Unix timestamp after @), Sunset (RFC 8594, the date the version stops working) and a Link to the migration guide. @1790812800 in the output above is 2026-10-01 00:00 UTC, the date I passed to Effective.
Watch the overload you use. My first version called options.Policies.Sunset(1.0) without a name. Then every v1 response in the app got the Sunset header, including /api/v1/weather and /api/orders, which were not deprecated:
GET /api/v1/weather (with Policies.Sunset(1.0), no name)
200
api-supported-versions: 1.0, 2.0
Sunset: Wed, 31 Mar 2027 00:00:00 GMT
Link: <https://example.com/api/migrate-to-v2>; rel="sunset"; title="Migrating to v2"; type="text/html"
Pass the API name (Sunset("Products", 1.0)) to target one API. For controllers the name is the controller name without the “Controller” suffix. For minimal APIs it’s the name passed to NewVersionedApi. With the named policies, /api/v1/weather went back to just api-supported-versions: 1.0, 2.0.
OpenAPI: One Document per Version
.AddOpenApi() on the versioning builder plus app.MapOpenApi().WithDocumentPerVersion() produced one document per version from the code above:
GET /openapi/v1.json 200
info: {"title":"api-versioning-post | v1","description":"","version":"1.0"}
paths: /api/v1/weather, /api/orders, /api/v1/products (deprecated: true)
GET /openapi/v2.json 200
info: {"title":"api-versioning-post | v2","description":"","version":"2.0"}
paths: /api/v2/weather, /api/orders, /api/v2/products
GET /openapi/v3.json 404 No OpenAPI document with the name 'v3' was found.
Deprecated operations are flagged, so a UI such as Scalar or Swagger UI shows them crossed out. SubstituteApiVersionInUrl = true is why the paths say /api/v1/... instead of /api/v{version}/.... With the unnamed Sunset(1.0) policy from the previous section, the v1 document also got a description. With the named one it stays empty, because the policy belongs to one API rather than the whole version:
"description": "The API will be sunset on 03/31/2027.
### Links
- [Migrating to v2](https://example.com/api/migrate-to-v2)"
The date in that text is formatted with the server’s culture (03/31/2027 on my machine). Keep that in mind if your build server runs in a different locale. Microsoft’s OpenAPI overview covers the generator itself.
Migrating from Microsoft.AspNetCore.Mvc.Versioning to Asp.Versioning.Mvc
The old package still installs and even runs on .NET 10. It ships netcoreapp3.1, net5.0 and net6.0 assemblies, and my test app built without warnings. NuGet does mark it as deprecated:
> dotnet list package --deprecated
Top-level Package Requested Resolved Reason(s) Alternative
> Microsoft.AspNetCore.Mvc.Versioning 5.1.0 5.1.0 Other Asp.Versioning.Mvc >= 0.0.0
> Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer 5.1.0 5.1.0 Other Asp.Versioning.Mvc.ApiExplorer >= 0.0.0
It gets no new features (no OpenAPI integration for Microsoft.AspNetCore.OpenApi, no RFC 9745 deprecation policy), so it’s worth moving. Here is what happened when I swapped the packages on a small v1/v2 controller app and fixed one error at a time.
Step 1: swap the packages, build. Every versioning type is missing, because the namespace changed:
error CS0234: The type or namespace name 'Versioning' does not exist in the namespace 'Microsoft.AspNetCore.Mvc'
error CS0246: The type or namespace name 'ApiVersionAttribute' could not be found
error CS0246: The type or namespace name 'MapToApiVersionAttribute' could not be found
Fix: replace using Microsoft.AspNetCore.Mvc.Versioning; with using Asp.Versioning;. ApiVersion used to live in Microsoft.AspNetCore.Mvc and is now in Asp.Versioning as well. String arguments such as [ApiVersion("1.0")] still compile, so the attributes can stay as they are.
Step 2: build again.
error CS1061: 'IServiceCollection' does not contain a definition for 'AddVersionedApiExplorer'
warning AV0013: Call Services.AddApiVersioning().AddMvc() to version MVC (Core) controller-based APIs
Step 3: the trap. I deleted the AddVersionedApiExplorer call to get a clean build and ignored the warning. The app compiled and started, then every versioned controller endpoint failed:
GET /api/v1/products 500
GET /api/v2/products 500
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException: The request matched multiple endpoints. Matches:
ProductsController.GetV1 (api-versioning-migrate)
ProductsController.GetV2 (api-versioning-migrate)
In the new library, AddApiVersioning() alone only versions minimal APIs. Without .AddMvc(), ASP.NET Core sees two actions on the same route and can’t choose between them. If you searched for that AmbiguousMatchException after an upgrade, this is the likely cause. The fix is the builder chain:
builder.Services.AddApiVersioning(options =>
{
// same options as before
})
.AddMvc() // was implicit in Microsoft.AspNetCore.Mvc.Versioning
.AddApiExplorer(options => // was services.AddVersionedApiExplorer(...)
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
After that, v1 and v2 returned 200 with the same versioning headers as before. Two behaviour changes are worth testing before you ship:
| Request | Microsoft.AspNetCore.Mvc.Versioning 5.1.0 | Asp.Versioning.Mvc 10.2.1 |
|---|---|---|
GET /api/v3/products (unknown version in URL) | 400 + {"error":{"code":"UnsupportedApiVersion",...}} | 404, empty body |
| Unknown version in header/query | 400, custom {"error":...} JSON | 400, application/problem+json with "code":"UnsupportedApiVersion" |
Any client or integration test that parses error.code from the old JSON shape will break, even though the status code (for header and query string) didn’t change.
When to Add a New Version
- Don’t version additive changes. A new optional field or a new endpoint doesn’t break anyone. New versions are for removed or renamed fields, changed types and changed behaviour.
- URL segment for public APIs. It is visible in logs, works in a browser, can’t be ignored by the wrong reader, and needs no
Varyheader. Use header versioning for internal services with generated clients. - Keep version shaping at the edge. v1 and v2 should be different DTOs over the same service, not two copies of the business logic. The REST API with ASP.NET Core walkthrough uses that DTO layer.
- Deprecate with dates.
Deprecated = trueplus namedDeprecate/Sunsetpolicies give clients machine-readable dates and a link. Then log which clients still call v1 before you remove it. - Version the gateway too. In a microservices setup, make sure the gateway forwards the
x-api-versionheader and query string. Per-version rate limits can live there as well.
Checklist
Asp.Versioning.*packages, notMicrosoft.AspNetCore.Mvc.Versioning..AddMvc()afterAddApiVersioning()if you have controllers.- Set
ApiVersionReaderexplicitly. The default has no header reader. AssumeDefaultVersionWhenUnspecifiedonly for existing APIs (AV0016).- Clients send one version, in one format (
2.0, not2in one place and2.0in another). Vary: x-api-versionif you use header versioning behind any cache.- Named
Deprecate/Sunsetpolicies, not the unnamed overload. - An integration test for the unsupported-version response, because it is 404 in the URL and 400 elsewhere.
FAQ
Is Microsoft.AspNetCore.Mvc.Versioning deprecated? Yes. NuGet lists it as deprecated, with Asp.Versioning.Mvc as the alternative. 5.1.0 still runs on .NET 10 but gets no new features.
What replaced AddVersionedApiExplorer? services.AddApiVersioning().AddMvc().AddApiExplorer(...). The options (GroupNameFormat, SubstituteApiVersionInUrl) are the same.
Why do I get AmbiguousMatchException after migrating to Asp.Versioning? You are missing .AddMvc(). Without it, controllers aren’t versioned and v1/v2 actions on the same route collide.
Which API versioning strategy is best in ASP.NET Core? URL segment (/api/v1/...) for public APIs. Header or query string when the URL must stay stable and you control the clients.
Does API versioning work with minimal APIs? Yes. Use Asp.Versioning.Http and app.NewVersionedApi() with one route group per version, or NewApiVersionSet() with .MapToApiVersion().
Conclusion
The routing is the easy part. The problems I hit were all defaults and silent fallbacks: a header the default readers never look at, a missing .AddMvc() that only fails at runtime, 2 vs 2.0, an unnamed sunset policy that marks the whole API, and a 404 where you expected a 400. Set the readers explicitly, keep one version format, name your policies, and add an integration test that requests an unsupported version through each reader you accept.
References: dotnet/aspnet-api-versioning (source, wiki, samples) · AV0016 analyzer rule · RFC 9745: Deprecation header · RFC 8594: Sunset header
This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).
every developer consuming your API, will thank you.
API versioning is essential when an ASP.NET Core application needs to evolve without breaking existing consumers. The article’s explanation of maintaining v1 while introducing v2 provides a practical way to think about backward compatibility, especially for public APIs and microservices. A ASP.NET Core ReactJS Full Stack Online Course can be a useful foundation for developers working with ASP.NET Core alongside modern frontend applications.
The comparison of URL, header, and query-string approaches is also valuable because each strategy affects how clients discover and consume different API versions. Understanding these choices helps developers design APIs that can evolve while keeping existing integrations stable. This makes a RESTful API Online Course relevant for strengthening knowledge of endpoints, request handling, versioning, and API design principles.