Learn API versioning in ASP.NET Core with practical C# examples. Master URL, header & query string strategies. Start versioning your APIs the right way today.
API versioning in ASP.NET Core is one of those topics every backend developer eventually runs into — usually the hard way, right after a "small change" breaks a mobile app in production. If you build REST APIs with C# and .NET, learning how to version your endpoints properly is not optional; it is the difference between shipping confidently and dreading every deployment. In this complete guide, we'll cover the main API versioning strategies in ASP.NET Core, show runnable code examples for both controller-based and minimal APIs, and explain the why behind each decision so you can pick the right approach for your project.
Why API Versioning in ASP.NET Core Matters
An API is a contract. The moment a client — a web app, a mobile app, or a third-party integration — starts consuming your endpoints, you have made a promise about how those endpoints behave. The problem is that requirements change. You need to rename a field, change a response shape, remove a deprecated property, or alter validation rules. Any of these can be a breaking change for existing consumers.
Without a versioning strategy, you have only two bad options: break your existing clients, or never evolve your API. API versioning gives you a third, far better path. It lets you introduce new behavior under a new version (for example v2) while keeping the old version (v1) alive and stable for clients that haven't migrated yet. This is especially critical for public APIs and microservices, where you do not control — and often cannot even contact — every consumer.
The good news: the official Asp.Versioning libraries (formerly Microsoft.AspNetCore.Mvc.Versioning) make this remarkably clean in modern .NET. Let's get them installed and explore each strategy.
Getting Started: Installing the Versioning Package
Modern ASP.NET Core API versioning lives in the Asp.Versioning family of NuGet packages, maintained by the .NET team. For controller-based APIs you typically need two packages:
// Run these in your project directory
// dotnet add package Asp.Versioning.Mvc
// dotnet add package Asp.Versioning.Mvc.ApiExplorer
Then register versioning in Program.cs. This single block of configuration is the foundation everything else builds on:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
// Assume v1.0 when the client doesn't specify a version
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
// Return supported/deprecated versions in response headers
options.ReportApiVersions = true;
})
.AddApiExplorer(options =>
{
// Formats the version as "v1", "v2", etc. for Swagger
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var app = builder.Build();
app.MapControllers();
app.Run();
Two options here deserve attention. AssumeDefaultVersionWhenUnspecified keeps old clients working when they send no version — useful when you add versioning to an existing API. ReportApiVersions adds api-supported-versions and api-deprecated-versions response headers, which is a free, low-effort way to communicate deprecation to consumers.
API Versioning Strategies in ASP.NET Core
There are four common ways to tell the server which API version a client wants. Each has trade-offs, and the right answer depends on your audience and constraints.
1. URL Path Versioning (Most Popular)
URL path versioning puts the version directly in the route, like /api/v1/products. This is by far the most popular and discoverable approach — the version is visible, easy to test in a browser, and trivial to document. Most large public APIs (GitHub, Stripe-style designs) lean toward explicit, visible versions.
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[ApiVersion(1.0)]
[ApiVersion(2.0)]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion(1.0)]
public IActionResult GetV1()
{
return Ok(new { version = "1.0", products = new[] { "Keyboard", "Mouse" } });
}
[HttpGet]
[MapToApiVersion(2.0)]
public IActionResult GetV2()
{
// v2 returns richer objects instead of plain strings
return Ok(new
{
version = "2.0",
products = new[]
{
new { name = "Keyboard", price = 49.99 },
new { name = "Mouse", price = 19.99 }
}
});
}
}
The v{version:apiVersion} route token and the [MapToApiVersion] attributes let a single controller serve multiple versions. A request to /api/v1/products hits GetV1; /api/v2/products hits GetV2. Why prefer this? Clarity. Anyone reading a log, a curl command, or browser history immediately knows which version was called.
2. Query String Versioning
Query string versioning keeps URLs clean of path segments and passes the version as a parameter: /api/products?api-version=2.0. It's easy to add to an existing API because routes don't change.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
// Read the version from the "api-version" query string parameter
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");
});
The downside: versions hidden in query strings are easy to forget in documentation and can get stripped by aggressive caching layers or proxies. It's convenient, but less self-documenting than the URL approach.
3. HTTP Header Versioning
Header versioning passes the version in a custom request header, keeping the URL completely free of versioning concerns. Purists like this because the resource URL stays stable across versions — arguably more "RESTful."
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
// Clients send: x-api-version: 2.0
options.ApiVersionReader = new HeaderApiVersionReader("x-api-version");
});
The trade-off is discoverability and testing friction. You can't paste a URL into a browser to hit v2 — you need a tool like Postman, curl, or code that sets the header. For internal services and SDKs where clients are generated, this is fine. For public, human-facing APIs, it raises the barrier to entry.
4. Media Type (Content Negotiation) Versioning
The most "academically correct" approach embeds the version in the Accept header via content negotiation, e.g. Accept: application/json;v=2.0. You can enable it with MediaTypeApiVersionReader. It's powerful but the least common in practice because it's the hardest for casual consumers to use. Reserve it for hypermedia-driven APIs where you're already deep into content negotiation.
Combining Multiple Readers
You don't have to choose just one. A common production pattern is to accept the version from several places, giving clients flexibility:
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("x-api-version"),
new QueryStringApiVersionReader("api-version"));
API Versioning for Minimal APIs in .NET
Minimal APIs are now a first-class citizen for many .NET teams, and versioning works just as cleanly there. Install Asp.Versioning.Http and use a version set:
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();
ApiVersionSet versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.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);
app.Run();
The mental model is identical to controllers: declare which versions exist, then map each endpoint to a specific version. This consistency across the two programming models is one of the nicest things about the official versioning library.
Deprecating Old Versions Gracefully
Versioning isn't only about adding new versions — it's about retiring old ones responsibly. Mark a version as deprecated so clients get a heads-up in the response headers:
[ApiVersion(1.0, Deprecated = true)]
[ApiVersion(2.0)]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
// ...
}
With ReportApiVersions = true, responses now include api-deprecated-versions: 1.0. Combine this with clear documentation and a published sunset date, and you give consumers a fair, professional migration window instead of a nasty surprise.
API Versioning Best Practices and Common Pitfalls
Once the mechanics are in place, the harder questions are strategic. Here are the practices that separate a maintainable API from a painful one.
- Version only when you must. Additive, non-breaking changes (adding a new optional field or a brand-new endpoint) do not require a new version. Reserve new versions for genuine breaking changes — removed fields, renamed properties, changed types, or altered behavior. Over-versioning multiplies maintenance cost.
- Pick one primary strategy and stay consistent. Choose URL path versioning for public APIs (discoverability wins) and stick with it everywhere. Mixing strategies across teams confuses consumers and complicates tooling.
- Always set a default version. Use
AssumeDefaultVersionWhenUnspecifiedso that adding versioning to a live API doesn't instantly break clients who send no version header. - Document every version in Swagger/OpenAPI. The
AddApiExplorerconfiguration shown earlier integrates with Swashbuckle so each version gets its own document. Undocumented versions are versions no one can safely adopt. - Avoid leaking versioning into your domain layer. Keep version-specific shaping in DTOs and controllers, not in your business logic or database. Your core services should be version-agnostic; map to the right response shape at the edge.
- Communicate deprecation early. Use deprecated flags, response headers, and a published timeline. Never silently remove a version.
The most common pitfall? Treating versioning as a code problem when it's really a communication problem. The library handles routing in minutes; the real work is deciding what counts as breaking and keeping consumers informed.
Conclusion: Key Takeaways
API versioning in ASP.NET Core is straightforward to implement with the official Asp.Versioning packages, and getting it right protects both you and your consumers from painful breaking changes. To recap the essentials:
- Use
AddApiVersioningwith a sensible default version andReportApiVersionsenabled. - URL path versioning (
/api/v1/...) is the most discoverable and is the safest default for public APIs; header and query string versioning suit internal services and SDK-driven clients. - The same patterns work cleanly for both controller-based and minimal APIs.
- Version only for true breaking changes, mark old versions deprecated, and document everything in Swagger.
- Remember that versioning is as much about clear communication with consumers as it is about code.
Start by adding the package to your next .NET API project, pick URL path versioning, and ship a v1 today — your future self, and every developer consuming your API, will thank you. Happy coding!
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment