API versioning in ASP.NET Core lets multiple versions of the same endpoint run side by side, so you can ship breaking changes without breaking the clients already calling your API. On .NET 10, the modern way to do it is the Asp.Versioning package (10.2 as of August 2026) - URL-based versioning by default - wired into the built-in OpenAPI document and rendered with Scalar. That is the whole answer in one sentence; the rest of this guide is how to do it properly and which decisions you cannot undo later.
Here is the part most tutorials get wrong, including the one that probably sent you here: they tell you to install Microsoft.AspNetCore.Mvc.Versioning. That package was renamed at v6.0 - it dropped the Microsoft. prefix to become Asp.Versioning.* when the project stopped being a Microsoft-maintained package (it is now community-maintained, though the repo still lives in the dotnet org). v10 is the first release built for .NET 10 and its native OpenAPI support. If your tutorial still says AddApiVersioning inside a Startup.cs, it is teaching you 2020.
There is also a much more recent gap. Asp.Versioning 10.2 shipped on 6 August 2026 and changed something fundamental: you can now version an individual field without minting a new endpoint version. Every other guide on this topic, including the official .NET blog post, predates it. I will cover it below, along with the 31 Roslyn analyzers that release turned on by default.
I will cover the four versioning strategies with a decision matrix and a clear default, the modern setup for both controllers and Minimal APIs, the OpenAPI + Scalar integration that actually produces a version dropdown, field-level versioning, and how to deprecate a version with a proper Sunset header. Every snippet here was built and run on .NET 10.0.303. The full source is on GitHub.
TL;DR. Install
Asp.Versioning.Http(Minimal APIs) orAsp.Versioning.Mvc(controllers), plusAsp.Versioning.Mvc.ApiExplorerandAsp.Versioning.OpenApi. Register withbuilder.Services.AddApiVersioning(...).AddApiExplorer(o => o.GroupNameFormat = "'v'VVV").AddOpenApi(), thenapp.MapOpenApi().WithDocumentPerVersion(). Default to URL-segment versioning (/api/v1/products) for public REST APIs - it is the most visible, most cacheable, and easiest to test. SetReportApiVersions = trueso responses advertise supported versions. Version from day one (ship a v1), treat the scheme as a one-way door, use[VisibleInApiVersion]for single-field changes instead of a whole new version, and when you retire a version, announce it with aSunsetheader (RFC 8594) before you delete it.
What Is API Versioning, and Why Do You Need It?
API versioning is a technique for serving multiple, independently-evolving versions of the same API so that a change for one client does not break another. A client targets v1; you ship v2 with a different response shape; both keep working. Without versioning, the first breaking change you deploy - a renamed field, a removed property, a stricter validation rule - silently breaks every integration already in production.
The trigger is always a breaking change. Adding a new optional field is safe and needs no new version. Removing a field, renaming one, changing a type, tightening validation, or altering an error contract is breaking - and that is what a new version exists to absorb. Decide as a team what counts as breaking and write it down, because half of all “do we need a new version?” arguments are really “is this a breaking change?” arguments.
RESTful API Best Practices for .NET Developers
Versioning is one piece of good API design. This is the full picture - status codes, pagination, errors, and where versioning fits.
The Four Ways to Version an API in ASP.NET Core
There are four mainstream strategies. Asp.Versioning supports all of them through an ApiVersionReader. The choice matters more than it looks, because clients hard-code it - switching schemes later breaks everyone, so this is a decision you make once.
| Strategy | Example | Visibility | Cache-friendly | Easy to test | My verdict |
|---|---|---|---|---|---|
| URL segment | /api/v1/products | Highest - version is in the path | Yes - distinct URLs cache cleanly | Yes - just paste a URL | Default for public REST APIs |
| Query string | /api/products?api-version=1.0 | Medium | Mostly - some proxies ignore query in cache keys | Yes | Fine for internal APIs; ugly for public |
| HTTP header | X-API-Version: 1.0 | Low - invisible in the URL | No - same URL for all versions | No - needs a tool, not a browser | Clean URLs, but a testing tax |
| Media type | Accept: application/json;v=2.0 | Low | No | No | Most “RESTful”; most friction |
My take: use URL-segment versioning unless you have a specific reason not to. It is the most discoverable, the easiest for a new developer to reason about, the friendliest to CDNs and HTTP caches, and the only one you can test by pasting a link into a browser. The “URLs should be permanent so versioning belongs in a header” argument is theoretically pure and practically a support burden - you will spend the saved URL aesthetics on “why is my request hitting the wrong version” tickets. Header and media-type versioning earn their place on internal or hypermedia-driven APIs where clients are sophisticated and tooling is a given.
The Modern Package: Asp.Versioning (Not the One Your Old Tutorial Shows)
The single most common mistake in API-versioning content today is the package name. The library you want in 2026 is Asp.Versioning, maintained at dotnet/aspnet-api-versioning. The older Microsoft.AspNetCore.Mvc.Versioning packages are the pre-rename identity of the same project (renamed at v6.0, late 2022) and are no longer the ones to install.
Pick the packages by hosting model. These are the current versions as of August 2026:
| Package | Use it for | Version |
|---|---|---|
Asp.Versioning.Http | Minimal APIs | 10.2.2 |
Asp.Versioning.Mvc | Controllers | 10.2.1 |
Asp.Versioning.Mvc.ApiExplorer | OpenAPI metadata (both models) | 10.2.1 |
Asp.Versioning.OpenApi | Document-per-version + Sunset in OpenAPI | 10.2.2 |
One correction worth calling out: Asp.Versioning.OpenApi is now generally available. It sat at 10.0.0-rc.1 for months, which is why the official .NET blog post and every third-party article still tell you to install a release candidate. It went stable in the 10.2 line. You no longer need a prerelease flag for the OpenAPI integration.
# Minimal APIsdotnet add package Asp.Versioning.Http --version 10.2.2dotnet add package Asp.Versioning.Mvc.ApiExplorer --version 10.2.1dotnet add package Asp.Versioning.OpenApi --version 10.2.2
# Controllersdotnet add package Asp.Versioning.Mvc --version 10.2.1dotnet add package Asp.Versioning.Mvc.ApiExplorer --version 10.2.1dotnet add package Asp.Versioning.OpenApi --version 10.2.2Configuring API Versioning in .NET 10
Registration is one fluent chain. This is the setup I use as a baseline for a public API: a default version, an explicit reader, sensible reporting, and the API Explorer plus OpenAPI wired up.
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApiVersioning(options => { // Treat 1.0 as the version when a client does not ask for one. options.DefaultApiVersion = new ApiVersion(1, 0);
// Advertise supported + deprecated versions in response headers. options.ReportApiVersions = true;
// Read the version from the URL segment only. options.ApiVersionReader = new UrlSegmentApiVersionReader(); }) .AddApiExplorer(options => { // Formats groups as "v1", "v2" - matches the /openapi/v1.json convention. options.GroupNameFormat = "'v'VVV"; options.SubstituteApiVersionInUrl = true; }) .AddOpenApi();A few of these decide how your API behaves under real traffic, so they are worth understanding rather than copy-pasting:
DefaultApiVersionsets the version used when the request does not specify one. New APIs should start at1.0.ReportApiVersions = trueaddsapi-supported-versionsandapi-deprecated-versionsresponse headers. It pairs well with structured logging so you can see which versions clients actually call. Leave it on.ApiVersionReadernames the single source you read the version from. Setting it explicitly is faster than the default, which probes both the query string and the URL segment on every request. The library’s own analyzer will nudge you about this, and it is right.GroupNameFormat = "'v'VVV"controls how versions appear in OpenAPI. TheVVVformat collapses1.0tov1and keeps1.1asv1.1..AddOpenApi()is theAsp.Versioning.OpenApihook. It is what makes the next section a one-liner instead of a hand-rolled loop.
One behaviour change to know about: as of 10.2, AddApiVersioning() registers IHttpContextAccessor for you. It needs the requested version to be reachable during serialization, which is what makes field-level versioning work.
Choosing the version reader
To use a header instead - or to accept several at once - swap the ApiVersionReader:
// Header-only versioningoptions.ApiVersionReader = new HeaderApiVersionReader("X-API-Version");
// Accept URL segment, query string, AND header (most forgiving)options.ApiVersionReader = ApiVersionReader.Combine( new UrlSegmentApiVersionReader(), new QueryStringApiVersionReader("api-version"), new HeaderApiVersionReader("X-API-Version"));ApiVersionReader.Combine is handy during a migration, but do not ship a permanently ambiguous API. Pick the scheme you actually want clients to use, document that one, and pay the smaller per-request cost of a single reader.
What about AssumeDefaultVersionWhenUnspecified?
Plenty of tutorials set AssumeDefaultVersionWhenUnspecified = true as a matter of course. It makes unversioned requests fall back to DefaultApiVersion, which sounds harmless.
It is not a default I would reach for. It exists for retrofitting versioning onto an API that already has callers who are hitting unversioned URLs. On a new API it papers over a misconfigured reader: a client sends a version you never wired up, the reader fails to find it, and instead of a clear 404 the request quietly lands on v1. You find out months later when someone asks why their v2 integration returns v1 data. Asp.Versioning 10.2 flags this with analyzer AV0016 for exactly that reason. Turn it on when you have legacy callers to protect, not by reflex.
Versioning Controllers
For controller-based APIs, you declare versions with attributes and let the route template carry the version segment. Here are two versions of a ProductsController living in the same app.
using Asp.Versioning;using Microsoft.AspNetCore.Mvc;using Versioning.Controllers.Models;
namespace Versioning.Controllers.Controllers;
[ApiController][ApiVersion("1.0", Deprecated = true)][ApiVersion("2.0")][Route("api/v{version:apiVersion}/[controller]")]public class ProductsController : ControllerBase{ private static readonly Product[] Catalog = [ new() { Id = 1, Name = "Keyboard", Price = 79.00m, Sku = "KB-001", LegacyCategory = "Peripherals" }, new() { Id = 2, Name = "Mouse", Price = 39.00m, Sku = "MS-002", LegacyCategory = "Peripherals" } ];
[HttpGet] [MapToApiVersion("1.0")] public ActionResult<Product[]> GetV1() => Ok(Catalog);
[HttpGet] [MapToApiVersion("2.0")] public ActionResult<ProductListResponse> GetV2() => Ok(new ProductListResponse(Catalog, Catalog.Length));}The [ApiVersion] attributes declare which versions this controller serves. The {version:apiVersion} route constraint pulls the version out of the URL. [MapToApiVersion] routes each method to the matching version, so GET /api/v1/products hits GetV1 and GET /api/v2/products hits GetV2. v2 wraps the payload in an envelope with a count instead of returning a bare array, and that shape change is exactly the sort of breaking change a second version exists to contain.
For a cleaner layout once you have several versions, split controllers into Controllers/V1 and Controllers/V2 folders. The version comes from the attribute, not the namespace, so the folders are purely for your own sanity.
Versioning Minimal APIs in .NET 10
Minimal APIs version through NewVersionedApi, which creates a named versioned group. You then hang one route group per version off it and declare the version on the group itself.
var productsApi = app.NewVersionedApi("Products");
var productsV1 = productsApi .MapGroup("api/v{version:apiVersion}/products") .HasDeprecatedApiVersion(1.0);
var productsV2 = productsApi .MapGroup("api/v{version:apiVersion}/products") .HasApiVersion(2.0);
productsV1.MapGet("/", () => TypedResults.Ok(catalog));
productsV2.MapGet("/", () => TypedResults.Ok(new ProductListResponse(catalog, catalog.Length)));NewVersionedApi("Products") declares the versioned API and gives it a name that shows up in the OpenAPI document. Each MapGroup then declares its own version with HasApiVersion, or HasDeprecatedApiVersion if that version is on the way out. The {version:apiVersion} segment in the group route is what turns this into URL-segment versioning.
This reads better than the older NewApiVersionSet() plus WithApiVersionSet() plus MapToApiVersion() combination you will still find in most posts. That API still works, but declaring the version on the group means each version’s endpoints sit in their own block instead of being disambiguated one handler at a time.
Use TypedResults rather than Results here. It is not cosmetic: the typed overload is what tells OpenAPI the response schema, and without it your generated documents come back with empty components.schemas.
Minimal APIs in ASP.NET Core
New to the Minimal API model? This covers route groups, handlers, filters, and how it compares to controllers.
Showing Versions in OpenAPI and Scalar
This is the section every other tutorial skips or fumbles, and it is the one that makes your versioned API usable. In .NET 10 the OpenAPI document is generated by the built-in Microsoft.AspNetCore.OpenApi package, and Asp.Versioning.OpenApi bridges version metadata into it.
With .AddOpenApi() already in the registration chain, producing one document per version is a single call:
app.MapOpenApi().WithDocumentPerVersion();That is it. WithDocumentPerVersion() enumerates the versions your app actually exposes and registers /openapi/v1.json, /openapi/v2.json, and so on, with no cross-document contamination - the v1 document contains only v1 paths.
If you have seen a foreach loop over DescribeApiVersions() calling MapOpenApi() once per version, that was the workaround from before this package went stable. You do not need it anymore.
Point Scalar at every version so the docs UI gets a version switcher instead of a single merged blob:
app.MapScalarApiReference(options =>{ var descriptions = app.DescribeApiVersions();
for (var i = 0; i < descriptions.Count; i++) { var description = descriptions[i];
options.AddDocument( description.GroupName, description.GroupName, isDefault: i == descriptions.Count - 1); }});DescribeApiVersions() still earns its place here: it is how you enumerate versions for the Scalar document list, and isDefault on the last entry makes the newest version the one that opens first. Swashbuckle was dropped from the ASP.NET Core templates in .NET 9, so this OpenAPI-plus-Scalar pairing is the current default, not the old Swagger UI.
ASP.NET Core Dropped Swagger - Here's What Replaced It in .NET 10
The full story on the built-in OpenAPI document and wiring up Scalar as the interactive UI.
Version a Field Without Versioning the Endpoint
This is the part of Asp.Versioning 10.2 that changes the advice, and it is new enough that nothing else written on this topic covers it.
Until now, the rule was simple: adding a field is safe, removing or renaming one is breaking, and breaking means a new version. So a single dropped property forced you to stand up a whole second endpoint, duplicate a DTO, and carry both forever.
[VisibleInApiVersion] breaks that link. You annotate the member, and the library filters it per requested version:
using Asp.Versioning;
public class Product{ public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
// Introduced in v2. Invisible to v1 clients. [VisibleInApiVersion("2.0")] public string? Sku { get; set; }
// Served to v1 only. Dropped from v2. [VisibleInApiVersion("[1.0,2.0)")] public string? LegacyCategory { get; set; }}One class, two shapes, no branching in your handler. Here is what the running API returns:
$ curl http://localhost:5215/api/v1/products[{"id":1,"name":"Keyboard","price":79.00,"legacyCategory":"Peripherals"}]
$ curl http://localhost:5215/api/v2/products{"data":[{"id":1,"name":"Keyboard","price":79.00,"sku":"KB-001"}],"count":2}The argument uses the same interval notation as a NuGet package version, exposed as the new ApiVersionRange type: "2.0" means 2.0 and later, "[1.0]" means exactly 1.0, "[1.0,2.0)" means 1.0 up to but not including 2.0, and "(,1.0]" means 1.0 and earlier. A range matches versions, it does not declare them - you still declare versions on the endpoint.
The generated OpenAPI documents follow the same filtering, which is the detail that makes this genuinely usable. The Product schema in /openapi/v1.json lists id, name, price, legacyCategory. The same schema in /openapi/v2.json lists id, name, price, sku. Your docs stop lying about what each version returns.
The gotcha nobody has written down yet
Filtering applies to incoming requests too, and this is where I would slow down before adopting it broadly.
The release notes describe this as closing the over-posting gap, which it does. But it does not silently drop a hidden member. It rejects the request with a 400:
$ curl -X POST http://localhost:5215/api/v1/products \ -H "Content-Type: application/json" \ -d '{"id":9,"name":"Webcam","price":120.00,"sku":"WC-009"}'
400 Bad RequestThe JSON property 'sku' could not be found on type 'Product'.For over-posting that is the right call - a v1 client should not be able to set a v2-only field, and failing loudly beats failing quietly. But think about the other case. Plenty of clients read an object and post the whole thing back. If a client ever picks up a field from your v2 docs and echoes it to a v1 endpoint, they get a hard 400 rather than a tolerated extra property. That is a behaviour change from the usual permissive JSON binding, and it will surprise people.
My take: reach for [VisibleInApiVersion] on read-mostly response models, and be deliberate about it on request models. It is genuinely excellent for the case it was built for - one field appears, one field goes away, and you would rather not duplicate a DTO and an endpoint over it. It is not a license to skip endpoint versioning. If a version changes the envelope, the status codes, the error contract, or the meaning of a field rather than its presence, that is still a real version. And if you find yourself sprinkling ranges across a dozen properties, the contract is churning too fast and field-level versioning is hiding that rather than fixing it.
Two limits to note: filtering is JSON-only today, and the attribute lives in the core abstractions, so you can annotate models in a shared library without taking an ASP.NET Core dependency.
The Analyzers Will Grade Your Setup
Asp.Versioning 10.2 also shipped 31 Roslyn analyzers, and there is no package to install. The core rules are packed into Asp.Versioning.Abstractions and the API rules into Asp.Versioning.Http, so any project already referencing API versioning picks them up transitively the moment it upgrades.
This is the first upgrade surprise you will hit, because AV0012, AV0018, and AV0019 are errors by default and will fail your build:
| Rule | What it catches |
|---|---|
AV0012 | Invalid default API version |
AV0018 | Every endpoint is version-neutral |
AV0019 | Versioned and version-neutral endpoints mixed inconsistently |
AV0015 | Reader not set explicitly, so every request probes multiple sources |
AV0016 | AssumeDefaultVersionWhenUnspecified set on an API that does not need it |
AV0031 | API explorer not configured, so your OpenAPI document has no versions |
I found this the useful way. The first build of the sample for this article came back with AV0015 and AV0016 on a configuration copied from the previous version of this very guide. Both were fair: the reader was left implicit, and AssumeDefaultVersionWhenUnspecified was on for no reason. Fixing both is what the config section above now shows, and the solution builds clean.
Every rule has a helpLinkUri pointing at its documentation page, and individual rules configure through .editorconfig as usual. To turn the whole set off:
<PropertyGroup> <EnableApiVersioningAnalyzers>false</EnableApiVersioningAnalyzers></PropertyGroup>Note that ExcludeAssets="analyzers" on the PackageReference will not work. The analyzers arrive through several dependency paths and NuGet combines assets from every path, so the MSBuild property is the only reliable switch.
Deprecating a Version the Right Way (Sunset Headers)
Adding a version is easy. Retiring one without breaking trust is the part that separates a professional API from a hobby project. The wrong way is to delete v1 on a Friday. The right way has three steps: mark it deprecated, announce a removal date, then remove it.
Marking a version deprecated is one flag on controllers ([ApiVersion("1.0", Deprecated = true)]) or HasDeprecatedApiVersion(1.0) on a Minimal API group. With ReportApiVersions = true, calls to v1 now return api-deprecated-versions: 1.0, so attentive clients learn it is on the way out without reading your changelog.
The professional touch is a Sunset header (RFC 8594) that tells clients exactly when the version disappears and links to your migration guide. Asp.Versioning exposes this through version policies:
builder.Services.AddApiVersioning(options =>{ options.ReportApiVersions = true;
options.Policies.Sunset(1.0) .Effective(new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)) .Link("https://api.example.com/docs/migrating-to-v2") .Title("Migration Guide") .Type("text/html");});Every v1 response now carries both headers, verified on the running sample:
Sunset: Thu, 31 Dec 2026 00:00:00 GMTLink: <https://api.example.com/docs/migrating-to-v2>; rel="sunset"; title="Migration Guide"; type="text/html"Clients and their monitoring can act on a date instead of discovering the removal when their integration 404s. Use DateTimeOffset with an explicit offset rather than a bare DateTime, or the header renders in the server’s local time and you will ship a date that is a day off for half the world.
Troubleshooting
Six things that actually go wrong, in rough order of how often I have seen them.
1. The build fails with AV0012, AV0018, or AV0019 after upgrading to 10.2. These three analyzer rules are errors by default and arrive transitively with the package. Read the helpLinkUri on the diagnostic and fix the underlying configuration - they are usually pointing at something real. If you need to ship first, set <EnableApiVersioningAnalyzers>false</EnableApiVersioningAnalyzers>, not ExcludeAssets="analyzers", which does not work here.
2. Every request 404s once you add the version segment. The {version:apiVersion} route constraint needs the version declared on the endpoint. If the route template has the segment but no [ApiVersion] or HasApiVersion names that version, nothing matches. Requesting a version you never declared is also a 404 by design, so GET /api/v999/products returning 404 is correct behaviour, not a bug.
3. Scalar shows one merged document with no version switcher. You are missing AddApiExplorer, or GroupNameFormat does not match the document names you register with Scalar. Analyzer AV0031 catches the first case. Check /openapi/v1.json directly - if it returns a document, the problem is in the Scalar wiring; if it 404s, the problem is in MapOpenApi().WithDocumentPerVersion().
4. The OpenAPI document has empty components.schemas. Your handlers return untyped results. Switch Results.Ok(...) to TypedResults.Ok(...) in Minimal APIs, or give controller actions an ActionResult<T> return type. Without a declared type, OpenAPI has nothing to describe and per-version schema filtering has nothing to filter.
5. A v1 client suddenly gets 400s after you add [VisibleInApiVersion]. The member filter rejects hidden properties on input rather than ignoring them. If clients round-trip full objects, either stop hiding that member on the request model or accept a separate request DTO for the older version.
6. Requests hit the wrong version instead of failing. Almost always AssumeDefaultVersionWhenUnspecified = true combined with a reader that cannot see the version the client is sending. Turn the flag off and the misconfiguration becomes an obvious 404 instead of silently wrong data.
API Versioning Best Practices
After wiring versioning into a fair number of APIs, these are the rules I do not break:
- Version from day one. Ship
v1even when there is only one version. Retrofitting versioning onto an unversioned, already-consumed API is the painful path - every existing client assumes the unversioned URL. - Default to URL-segment versioning for public APIs. Visible, cacheable, testable. Reach for headers only when you have a concrete reason.
- Pick one scheme and commit. The reader is a one-way door.
ApiVersionReader.Combineis for migrations, not as a permanent “support everything” posture. - Only bump the major version for breaking changes. Additive, backward-compatible changes ship into the current version. Do not mint
v2because you added an optional field. - Use
[VisibleInApiVersion]for field-level changes, not as a replacement for versions. A property appearing or disappearing is a fine use. A changed envelope, status code, or error contract is still a real version. - Turn on
ReportApiVersions. Free, standards-based signaling of what is supported and deprecated. - Never delete a version silently. Deprecate, set a
Sunsetdate, link a migration guide, then remove - in that order. - Do not over-version. Two or three live versions is a healthy ceiling. If you are carrying
v5, the real problem is an unstable contract, and versioning is hiding it instead of fixing it.
Versioning is one layer of a production API, not the whole thing. A public, versioned API still needs authentication and rate limiting on top - they sit in front of every version you ship. If you are versioning gRPC services rather than HTTP, 10.2 added Asp.Versioning.Grpc in preview, which brings the same model to versioned services and message fields.
Global Exception Handling in ASP.NET Core
Versioned APIs still need consistent error contracts. This sets up ProblemDetails-based handling across every version.
Key Takeaways
- Use
Asp.Versioning10.2, notMicrosoft.AspNetCore.Mvc.Versioning. The latter is the old name of the same project.Asp.Versioning.OpenApiis now GA, so the release-candidate instructions you will find elsewhere are out of date. - URL-segment versioning (
/api/v1/...) is the right default for public REST APIs - most visible, most cacheable, easiest to test. Set the reader explicitly instead of letting it probe. - Both controllers and Minimal APIs are first-class. Controllers use
[ApiVersion]+[MapToApiVersion]; Minimal APIs useNewVersionedApiwithHasApiVersionon each route group. app.MapOpenApi().WithDocumentPerVersion()is the whole OpenAPI integration. The manualDescribeApiVersions()loop is the pre-GA workaround; keep the loop only for the Scalar document list.[VisibleInApiVersion]versions a field without versioning the endpoint - and filters responses, OpenAPI schemas, and request binding. Be aware it rejects hidden members on input with a 400 rather than ignoring them.- The 10.2 analyzers are on by default and three of them fail the build. Expect AV0012, AV0018, and AV0019 on your first upgrade.
- Deprecate with a
Sunsetheader (RFC 8594) and a migration link before you remove a version - announce the date, do not surprise your clients.
Frequently Asked Questions
What are the different ways to version an API in ASP.NET Core?
There are four mainstream strategies: URL-segment versioning (/api/v1/products), query-string versioning (?api-version=1.0), HTTP-header versioning (X-API-Version: 1.0), and media-type versioning (Accept: application/json;v=2.0). The Asp.Versioning package supports all four through an ApiVersionReader, and you can combine them with ApiVersionReader.Combine.
Which API versioning strategy is best?
For public REST APIs, URL-segment versioning is the best default. The version is visible in the path, distinct URLs cache cleanly on CDNs and proxies, and anyone can test a version by pasting a URL into a browser. Header and media-type versioning keep URLs clean but add a testing tax and break HTTP caching, so reserve them for internal or hypermedia-driven APIs.
Which NuGet package should I use for API versioning in .NET 10?
Use the Asp.Versioning family, version 10.2. Install Asp.Versioning.Http 10.2.2 for Minimal APIs or Asp.Versioning.Mvc 10.2.1 for controllers, plus Asp.Versioning.Mvc.ApiExplorer 10.2.1 and Asp.Versioning.OpenApi 10.2.2. Asp.Versioning.OpenApi is now generally available, so you no longer need the 10.0.0-rc.1 prerelease that older guides reference. The older Microsoft.AspNetCore.Mvc.Versioning packages are the pre-rename identity of the same project and should not be used for new .NET 10 apps.
How do I version a Minimal API in .NET 10?
Call app.NewVersionedApi("Products") to create a named versioned API, then hang one route group per version off it with MapGroup("api/v{version:apiVersion}/products").HasApiVersion(2.0). Use HasDeprecatedApiVersion for versions on the way out. This is provided by the Asp.Versioning.Http package and replaces the older NewApiVersionSet plus WithApiVersionSet pattern.
How do I show multiple API versions in Swagger, Scalar, or OpenAPI?
Register AddApiExplorer with GroupNameFormat set to 'v'VVV and chain AddOpenApi() from the Asp.Versioning.OpenApi package, then call app.MapOpenApi().WithDocumentPerVersion(). That single call registers /openapi/v1.json, /openapi/v2.json, and so on. Point Scalar at each document with options.AddDocument inside a loop over app.DescribeApiVersions() to get a version switcher.
Can I remove a field from an API response without creating a new version?
Yes, as of Asp.Versioning 10.2. Annotate the property with [VisibleInApiVersion] and give it a range in NuGet interval notation, for example [VisibleInApiVersion("[1.0,2.0)")] to serve a field to v1 but not v2. The library filters the member out of responses, out of the generated OpenAPI schema for that version, and out of request binding. Use it for single-field changes; a changed envelope, status code, or error contract still needs a real version.
Why does my build fail with AV0012, AV0018, or AV0019 after upgrading Asp.Versioning?
Asp.Versioning 10.2 ships 31 Roslyn analyzers that are enabled by default, and those three rules are errors rather than warnings. They arrive transitively through Asp.Versioning.Abstractions and Asp.Versioning.Http, so there is no package to remove. Fix the configuration they point at, or disable the whole set with the MSBuild property EnableApiVersioningAnalyzers set to false. ExcludeAssets="analyzers" does not work because the analyzers reach the project through multiple dependency paths.
How do I deprecate an API version and tell clients it is going away?
Mark the version with [ApiVersion("1.0", Deprecated = true)] on controllers or HasDeprecatedApiVersion(1.0) on a Minimal API group, and enable ReportApiVersions so responses include api-deprecated-versions. For a removal date, use a version policy: options.Policies.Sunset(1.0).Effective(date).Link(migrationUrl), which emits an RFC 8594 Sunset header and a link to your migration guide on every response for that version.
Should I version my API from day one?
Yes. Ship a v1 even if it is the only version. Adding versioning later to an API that already has clients is painful because every existing caller assumes the unversioned URL. Starting with v1 costs almost nothing and gives you a clean path to v2 when a breaking change arrives.
Is URL or header versioning better?
URL versioning is better for most public APIs because it is visible, cache-friendly, and testable in a browser. Header versioning keeps URLs constant, which some REST purists prefer, but it hurts HTTP caching and forces clients to use tooling to test. Choose URL versioning unless clean, unchanging URLs are a hard requirement for your consumers.
Summary
API versioning is one of those features that looks optional until the first breaking change, at which point it is the only thing standing between you and a fleet of broken integrations. On .NET 10 the path is clear: install Asp.Versioning 10.2, default to URL-segment versioning with an explicit reader, support both controllers and Minimal APIs with the same mental model, wire the versions into OpenAPI with a single WithDocumentPerVersion() call, render them with Scalar, and retire old versions with a Sunset header instead of a silent delete.
The 10.2 release is worth the upgrade on its own. [VisibleInApiVersion] means a single field appearing or disappearing no longer costs you an entire endpoint version, and the analyzers will tell you when your configuration is sloppy before your clients do. Just go in knowing that hidden members are rejected on input, not ignored.
Get the scheme right early, because it is the one decision your clients hard-code and you cannot quietly change. Ship v1 today, and v2 will be a feature you add, not a fire you fight. The complete .NET 10 sample is on GitHub.
Happy Coding :)
What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.