ASP.NET Core API versioning is a critical practice for maintaining backward compatibility while evolving your API. The moment your API has external consumers -- mobile apps, third-party integrations, partner systems -- you have an obligation to not break them without notice. A versioning strategy lets you introduce breaking changes in a new version while keeping the old version alive long enough for clients to migrate. It signals professional API design. It also forces you to think carefully about what "breaking" means, which is a healthy discipline in itself.
This article covers the full versioning toolkit for .NET 10 ASP.NET Core projects using the Asp.Versioning.Mvc package. You'll see URL segment versioning, query string versioning, header versioning, how to combine multiple strategies, and how to configure OpenAPI documentation for multiple versions.
Why Version Your API?
Most developers know they should version their APIs but skip it at the start because it feels like premature optimization. That reasoning holds until the first time you need to change a response shape or remove a field that a mobile app is reading. At that point, you either break the client or you have to maintain both behaviors in the same endpoint -- which quickly becomes a mess of if (legacyBehavior) branches. Implementing asp.net core api versioning early avoids this pain entirely.
Versioning gives you a clean separation. Breaking changes land in v2. Clients on v1 keep working. You can communicate a deprecation timeline, monitor traffic to know when v1 clients have migrated, and finally decommission v1 when the numbers justify it. This is how public APIs at scale -- GitHub, Stripe, AWS -- manage change without breaking their ecosystems. asp.net core api versioning gives you the same professional-grade change management that top API providers use.
The parallel client support argument is especially important for mobile apps, where you can't force users to update immediately. A v1 response might be in production on app store versions that users haven't updated for months. Your backend needs to support both while you ship improvements in v2.
The Asp.Versioning.Mvc NuGet Package
ASP.NET Core doesn't include API versioning in the framework itself. The Asp.Versioning.Mvc NuGet package (third-party, not part of the framework) -- formerly Microsoft.AspNetCore.Mvc.Versioning before it was moved to a separate project -- is a widely used, mature package for controller-based API versioning. It's maintained by the original author with active development and excellent .NET 10 support.
Install it with:
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
The second package is needed for OpenAPI/Swagger integration. With those in place, you configure versioning in Program.cs:
// Program.cs -- API versioning setup with Asp.Versioning.Mvc in .NET 10
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var app = builder.Build();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
ReportApiVersions = true adds api-supported-versions and api-deprecated-versions response headers to every request. This is a useful signal to API clients -- they can read the headers and notify their users when they're running on a deprecated version. AssumeDefaultVersionWhenUnspecified = true means requests that don't include a version identifier are treated as requesting the default version, which keeps unversioned clients working during a transition.
URL Segment Versioning
URL segment versioning is the most visible and widely understood asp.net core api versioning strategy. The version appears directly in the URL path: /api/v1/products versus /api/v2/products. It's easy to test in a browser, obvious in API documentation, and straightforward to reason about. The downside is that the URL changes with each version, which violates REST purist principles about stable resource identifiers -- but for pragmatic API design, this is rarely a real problem.
To use URL segment versioning, set the version reader to UrlSegmentApiVersionReader (as shown in the setup above) and configure your controllers with the version in the route template:
// URL segment versioning -- v1 and v2 controllers side by side
// V1 Products Controller
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/products")]
public sealed class ProductsV1Controller : ControllerBase
{
private readonly IProductService _productService;
public ProductsV1Controller(IProductService productService)
{
_productService = productService;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var products = await _productService.GetAllAsync();
// V1 returns a flat list
return Ok(products.Select(p => new
{
p.Id,
p.Name,
p.Price
}));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _productService.GetByIdAsync(id);
return product is null ? NotFound() : Ok(new
{
product.Id,
product.Name,
product.Price
});
}
}
// V2 Products Controller -- enriched response shape
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/products")]
public sealed class ProductsV2Controller : ControllerBase
{
private readonly IProductService _productService;
private readonly ICategoryService _categoryService;
public ProductsV2Controller(
IProductService productService,
ICategoryService categoryService)
{
_productService = productService;
_categoryService = categoryService;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var products = await _productService.GetAllAsync();
// V2 includes category details and inventory
return Ok(products.Select(p => new
{
p.Id,
p.Name,
p.Price,
p.CategoryId,
CategoryName = p.Category?.Name,
p.StockQuantity,
p.IsAvailable
}));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _productService.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
}
Notice that v1 and v2 are entirely separate controllers. This is the cleanest approach -- each version has its own controller with its own logic, and you can evolve them independently. Some teams prefer to put both versions in a single controller using [MapToApiVersion], which is covered below.
This kind of version-by-behavior design maps well to broader architectural patterns. When you're thinking about how features and versions interact with your system boundaries, modular monolith architecture offers a useful lens for organizing the internal structure.
Query String Versioning
Query string versioning appends the version as a URL parameter: /api/products?api-version=1.0. It's less visually prominent than URL segment versioning, which some teams prefer. It keeps the base resource path stable across versions and is easy to add to existing requests without restructuring URLs.
// Query string versioning configuration
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");
});
// Controller attributes remain the same -- only the route template changes
[ApiController]
[ApiVersion("1.0")]
[Route("api/products")] // No version in route -- version comes from query string
public sealed class ProductsController : ControllerBase
{
// ...
}
The default parameter name is api-version, which is conventional. You can rename it by passing a different string to QueryStringApiVersionReader. One trade-off: the route template doesn't include the version, so both v1 and v2 controllers point to the same path. The routing framework disambiguates them using the [ApiVersion] attribute and the query string value.
Header Versioning
Header versioning reads the API version from a custom HTTP header, typically X-Api-Version. The URL stays completely clean -- /api/products -- and the version is a protocol-level concern expressed in the headers. This is the most "RESTful" approach in the strict sense, but it's the least discoverable. Browsers can't version requests through headers without developer tools, and tools like Postman require an explicit header configuration step.
// Header versioning configuration
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
});
Header versioning pairs well with API gateway setups where the gateway can inject or rewrite version headers based on routing rules. Internal microservices behind an API gateway often use header versioning because the URL namespace is already managed by the gateway, and headers are a natural extension point for routing metadata.
Combining Multiple Version Readers
Limiting your API to a single versioning strategy means clients are forced to use that exact mechanism. A more flexible approach is to support multiple strategies simultaneously. The ApiVersionReader.Combine() method accepts multiple readers and uses whichever one the incoming request provides. If multiple readers find a version identifier, the first match wins.
// Supporting URL, query string, and header versioning simultaneously
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-Api-Version")
);
});
This is useful during migrations. If you're moving from query string versioning to URL segment versioning, running both readers simultaneously gives clients time to update their requests without a hard cutover.
MapToApiVersion: Multiple Versions in One Controller
Sometimes you want to handle two API versions in a single controller -- for example, when the change between v1 and v2 is minor and doesn't justify a separate class. The [MapToApiVersion] attribute lets you map individual actions to specific versions.
// Single controller handling both v1 and v2 with [MapToApiVersion]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/orders")]
public sealed class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
// This action handles BOTH v1 and v2 requests (same behavior)
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await _orderService.GetAllAsync());
// V1 response -- simple flat structure
[HttpGet("{id:int}")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> GetByIdV1(int id)
{
var order = await _orderService.GetByIdAsync(id);
if (order is null) return NotFound();
return Ok(new { order.Id, order.Status, order.Total });
}
// V2 response -- includes line items and shipping info
[HttpGet("{id:int}")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetByIdV2(int id)
{
var order = await _orderService.GetByIdAsync(id);
if (order is null) return NotFound();
return Ok(order); // Full object with all fields
}
}
This pattern keeps related logic co-located when the difference between versions is small. Use it judiciously -- if the version difference grows significantly, the controller becomes harder to read, and separate controllers are cleaner.
Design patterns can help manage the complexity of multiple versions sharing behavior. The facade design pattern is useful for exposing a simplified interface that internally delegates to different version-specific implementations, keeping the controller action thin.
Version Deprecation
Marking a version as deprecated tells clients it will eventually be removed without removing it immediately. The [ApiVersion] attribute accepts a Deprecated property:
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/customers")]
public sealed class CustomersController : ControllerBase
{
// ...
}
When ReportApiVersions = true is set and a client requests a deprecated version, the response includes both api-supported-versions and api-deprecated-versions headers. This lets client developers see which versions are available and which are on their way out. Pair this with communication through your API changelog and documentation to give clients adequate notice before decommissioning.
Swagger/OpenAPI Multi-Version Configuration
Having multiple API versions without corresponding documentation creates confusion. Before diving into the wiring, it's worth distinguishing three separate pieces that work together here:
- Built-in ASP.NET Core OpenAPI metadata -- the
Microsoft.AspNetCore.OpenApipackage handles metadata generation from your controllers and endpoints. Built-in OpenAPI support first arrived in .NET 9 and is supported in .NET 10. - Swashbuckle (third-party, not part of the framework) -- provides the Swagger UI and the
SwaggerGenmiddleware that renders your OpenAPI metadata as an interactive documentation page. Asp.Versioning.Mvc.ApiExplorer-- the companion package that teaches the API explorer about your version groups so each version gets its own generated document.
Asp.Versioning.Mvc.ApiExplorer enables the API explorer to generate separate OpenAPI documents per version. The Swashbuckle integration needs a small amount of wiring:
// OpenAPI multi-version setup with Swashbuckle in .NET 10
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
// In Program.cs after AddApiVersioning():
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.ConfigureOptions<ConfigureSwaggerOptions>();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI(options =>
{
// Dynamically add a Swagger UI endpoint per API version
var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerEndpoint(
$"/swagger/{description.GroupName}/swagger.json",
$"My API {description.GroupName.ToUpperInvariant()}");
}
});
// ConfigureSwaggerOptions generates one Swagger doc per API version
public sealed class ConfigureSwaggerOptions
: IConfigureNamedOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
{
_provider = provider;
}
public void Configure(SwaggerGenOptions options)
{
foreach (var description in _provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = "My API",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated
? "This API version is deprecated. Please migrate to a newer version."
: "Current stable version."
});
}
}
public void Configure(string? name, SwaggerGenOptions options) =>
Configure(options);
}
With this setup, Swagger UI shows a dropdown to switch between API versions. Each version shows only the endpoints relevant to that version -- v1 shows the v1 endpoints, v2 shows the v2 endpoints. This is a significant improvement over a single flat document that includes everything.
Default Version and Assumed Default
Two settings work together to handle unversioned requests gracefully in asp.net core api versioning. DefaultApiVersion sets which version is returned when the system needs to fall back. AssumeDefaultVersionWhenUnspecified = true means a request that provides no version identifier is treated as requesting the default version rather than returning a 400 error.
These settings are your safety net during migration. If you add versioning to an existing API that has clients with hardcoded URLs (no version in the path or query string), setting the default to v1 and enabling assumed default means those clients continue to work transparently. They're effectively pinned to v1 until they explicitly request v2.
The logging infrastructure that sits beneath your versioned API matters for diagnosing which versions clients are using. Serilog in .NET covers structured logging that lets you log the API version on each request and query that data in your log aggregation tool -- critical for understanding migration progress across your client base.
Conclusion
ASP.NET Core API versioning with Asp.Versioning.Mvc gives you a clean, declarative system for managing change in your API. There is no single universal best strategy -- URL segment, query string, and header versioning each suit different client types and organizational conventions. URL segment versioning is the most visible and broadly understood starting point. Query string and header versioning offer alternatives that suit specific client types or organizational preferences. Combined readers let you support multiple strategies simultaneously, and [MapToApiVersion] keeps related version logic co-located when separation isn't warranted.
The full OpenAPI multi-version setup completes the picture -- your versioned API is well-documented and easy for new clients to explore. Add deprecation markers early and communicate them clearly, and you'll have a versioning system that actually helps clients migrate rather than just announcing changes after the fact.
For a deeper look at how version management fits into broader application architecture decisions, the complete guide to monolith architecture in C# and modular monolith patterns provide useful framing for where versioning fits in the larger picture.
Frequently Asked Questions
What is the best versioning strategy for ASP.NET Core APIs?
URL segment versioning is the most commonly recommended starting point because it's immediately visible, easy to test in any HTTP client, and unambiguous in logs and analytics. When you see /api/v2/orders in a log entry, you know exactly which version handled that request. There's no header inspection or query string parsing required.
That said, there's no universal "best" strategy -- it depends on your client types and constraints. If your API is primarily consumed by mobile apps where URLs are hardcoded into app store versions, query string versioning gives you more flexibility without forcing URL changes. If you're building a microservice behind an API gateway, header versioning is often cleaner because the gateway can manage version routing transparently.
The practical recommendation is to start with URL segment versioning unless you have a specific reason not to, and use ApiVersionReader.Combine() if you need to support multiple strategies during a transition.
Can I add versioning to an existing API without breaking existing clients?
Yes -- this is exactly the use case for AssumeDefaultVersionWhenUnspecified = true combined with setting DefaultApiVersion to match the version your existing endpoints implicitly implement. Existing clients that don't send a version identifier continue to receive the v1 behavior. You can add v2 alongside it without touching any existing routes.
The key is to introduce versioning attributes to your existing controllers without changing their behavior. Add [ApiVersion("1.0")] to your controllers, configure the defaults, and deploy. Existing clients see no difference. Then you can introduce v2 at your own pace, communicate the change to clients, and eventually deprecate v1.
Should I version every endpoint or just breaking changes?
Version the API surface as a whole rather than individual endpoints. Versioning at the endpoint level creates an inconsistent experience where different resources of the same API are at different versions, which is confusing for clients. When v2 of your products endpoint is ready, it's cleaner to release a v2 of the API that includes the new products behavior along with the unchanged endpoints from v1.
The practical approach is to build v2 as a full copy of v1 that includes your breaking changes. Non-breaking additions -- new optional fields, new endpoints -- can be added to v1 directly. Breaking changes -- removed fields, changed response shapes, different semantics -- go in v2. This keeps v1 stable for existing clients while v2 gets the new behavior.
How do I deprecate an API version and notify clients?
Mark the version with [ApiVersion("1.0", Deprecated = true)] on your controllers. This causes the framework to include it in the api-deprecated-versions response header when ReportApiVersions = true is set. Clients that check this header can detect they're on a deprecated version.
Beyond the header, communication matters more than the technical mechanism. Document the deprecation timeline in your API changelog and on the Swagger UI page (include a note in the OpenApiInfo.Description). If you have client contact information, direct outreach is more reliable than expecting clients to monitor response headers. Set a realistic sunset date -- six to twelve months is common -- and stick to it.
How does versioning interact with OpenAPI documentation?
With Asp.Versioning.Mvc.ApiExplorer and Swashbuckle, each API version gets its own generated OpenAPI document. Swagger UI shows a dropdown to switch between versions, and each document contains only the endpoints and schemas relevant to that version. Deprecated versions can include a note in the OpenApiInfo description.
The wiring requires implementing IConfigureNamedOptions<SwaggerGenOptions> to iterate over the available version descriptions and create a SwaggerDoc entry for each, as shown in the code example above. Once that's in place, the endpoint filtering happens automatically -- Swashbuckle reads the ApiVersion attributes and assigns each endpoint to the appropriate document.
What happens when a client requests a version that doesn't exist?
By default, the framework returns a 400 Bad Request with a problem details response indicating that the requested API version is unsupported. The response includes the api-supported-versions header so the client knows which versions are available. This is much more informative than a 404 or a generic error message.
You can customize this behavior by implementing a custom IErrorResponseProvider if your API has a specific error format requirement. The default behavior follows the RFC 9110 problem details format, which is appropriate for most APIs and consistent with what the ASP.NET Core Web API fundamentals docs recommend.
Can I use API versioning with minimal APIs?
Yes. Asp.Versioning.Http provides versioning support for minimal APIs. The configuration in Program.cs is similar, but instead of [ApiVersion] attributes on controllers, you call .WithApiVersionSet() and .HasApiVersion() extension methods on endpoints or route groups.
The minimal API versioning syntax looks like this: create an ApiVersionSet using app.NewApiVersionSet("My API").Build(), then apply it to endpoints with .WithApiVersionSet(versionSet).HasApiVersion(1, 0). Route groups can apply a version set at the group level, which cascades to all endpoints in the group. The framework can handle version negotiation and routing the same way regardless of whether you're using controllers or minimal APIs.

