How to Implement API Versioning in .NET Core Web APIs: Best Practices and Migration Guide
Learn how to implement API versioning in .NET Core Web APIs, avoid breaking changes, and migrate existing services safely.
You shipped version 1 of your API. Users are happy, integrations are working. Then you realize you need to make a breaking change. Now what?
Without versioning, you’re stuck between breaking existing clients or maintaining a messy API full of workarounds. With versioning, you can evolve your API gracefully while keeping existing clients working.
In this article, we’ll cover everything you need to know about API versioning in .NET Core: different versioning strategies, how to implement them using the Asp.Versioning.Mvc library, migration patterns, and best practices for deprecating old versions.
Why Version Your API?
Breaking changes are inevitable:
- You need to rename a property for clarity
- A new business requirement changes response structure
- You discover a design flaw in your endpoints
- Security requires changing authentication flow
Without versioning, you have two bad options:
- Break existing clients (they’ll hate you)
- Never make breaking changes (your API becomes a mess)
Versioning gives you a third option: introduce changes in a new version while maintaining the old version for existing clients.
What Counts as a Breaking Change?
Breaking changes (require a new version):
- Removing or renaming fields
- Changing field types
- Removing endpoints
- Adding required parameters
- Changing HTTP methods
- Changing status codes for existing scenarios
- Changing error response format
Non-breaking changes (safe to add):
- Adding optional parameters
- Adding new fields to responses
- Adding new endpoints
- Adding new optional headers
Installing the Versioning Library
First, install the package:
dotnet add package Asp.Versioning.Mvc
For API Explorer (Swagger) support:
dotnet add package Asp.Versioning.Mvc.ApiExplorer
Strategy 1: URL Path Versioning
The most visible and explicit approach: version in the URL path.
GET /api/v1/products
GET /api/v2/products
Configuration
// Program.cs
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.MapControllers();
app.Run();
Controller Implementation
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<IEnumerable<ProductV1Dto>> GetV1()
{
return Ok(new[]
{
new ProductV1Dto { Id = 1, Name = "Product 1", Price = 29.99m }
});
}
[HttpGet]
[MapToApiVersion("2.0")]
public ActionResult<IEnumerable<ProductV2Dto>> GetV2()
{
return Ok(new[]
{
new ProductV2Dto
{
Id = 1,
Name = "Product 1",
PriceInfo = new PriceDto { Amount = 29.99m, Currency = "USD" }
}
});
}
}
public record ProductV1Dto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public decimal Price { get; init; }
}
public record ProductV2Dto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public PriceDto PriceInfo { get; init; } = new();
}
public record PriceDto
{
public decimal Amount { get; init; }
public string Currency { get; init; } = string.Empty;
}
Alternative: Separate Controllers per Version
For cleaner separation, use different controllers:
namespace MyApi.Controllers.V1;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
public ActionResult<IEnumerable<ProductV1Dto>> Get()
{
return Ok(GetProductsV1());
}
}
namespace MyApi.Controllers.V2;
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
public ActionResult<IEnumerable<ProductV2Dto>> Get()
{
return Ok(GetProductsV2());
}
}
Pros and Cons
Pros:
- Very explicit and discoverable
- Easy to understand for API consumers
- Works well with API gateways and routing
- Clear separation in documentation
Cons:
- URL changes between versions
- Can’t version individual endpoints easily
- Bookmarks break when version changes
Strategy 2: Query String Versioning
Version specified as a query parameter:
GET /api/products?api-version=1.0
GET /api/products?api-version=2.0
Configuration
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");
});
Controller Implementation
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<IEnumerable<ProductV1Dto>> GetV1()
{
return Ok(GetProductsV1());
}
[HttpGet]
[MapToApiVersion("2.0")]
public ActionResult<IEnumerable<ProductV2Dto>> GetV2()
{
return Ok(GetProductsV2());
}
}
Pros and Cons
Pros:
- URL stays stable
- Easy to test in browser
- Simple to add version to existing queries
Cons:
- Less visible than URL versioning
- Can be ignored by clients accidentally
- Query strings can be stripped by proxies
Strategy 3: Header Versioning
Version specified in HTTP header:
GET /api/products
X-API-Version: 2.0
Or using content negotiation:
GET /api/products
Accept: application/json; version=2.0
Configuration
// Custom header
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new HeaderApiVersionReader("X-API-Version");
});
// Or content negotiation
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new MediaTypeApiVersionReader();
});
Pros and Cons
Pros:
- Clean URLs
- RESTful (resources don’t change)
- Can version independently of URL structure
Cons:
- Not discoverable (can’t test in browser easily)
- Harder for clients to implement
- Caching can be tricky
Strategy 4: Combining Multiple Strategies
You can support multiple versioning methods:
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")
);
});
Now clients can use any method:
/api/v2/products/api/products?api-version=2.0/api/productswithX-API-Version: 2.0header
Configuring Default Versions
Control what happens when no version is specified:
builder.Services.AddApiVersioning(options =>
{
// Use version 1.0 when not specified
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
// Return available versions in response headers
options.ReportApiVersions = true;
});
With ReportApiVersions = true, responses include:
api-supported-versions: 1.0, 2.0
api-deprecated-versions: 1.0
Deprecating Old Versions
Mark versions as deprecated while still supporting them:
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<IEnumerable<ProductV1Dto>> GetV1()
{
return Ok(GetProductsV1());
}
[HttpGet]
[MapToApiVersion("2.0")]
public ActionResult<IEnumerable<ProductV2Dto>> GetV2()
{
return Ok(GetProductsV2());
}
}
Response headers will show:
api-supported-versions: 2.0
api-deprecated-versions: 1.0
You can also add custom deprecation warnings:
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<IEnumerable<ProductV1Dto>> GetV1()
{
Response.Headers.Append("X-API-Warn", "Version 1.0 is deprecated. Migrate to version 2.0 by 2026-12-31.");
return Ok(GetProductsV1());
}
Migration Strategies
Strategy 1: Adapter Pattern
Keep business logic separate, adapt for each version:
public class ProductService
{
public List<Product> GetProducts()
{
// Core business logic
return _repository.GetProducts();
}
}
[ApiController]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
private readonly ProductService _service;
public ProductsController(ProductService service)
{
_service = service;
}
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<IEnumerable<ProductV1Dto>> GetV1()
{
var products = _service.GetProducts();
return Ok(products.Select(p => new ProductV1Dto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
}));
}
[HttpGet]
[MapToApiVersion("2.0")]
public ActionResult<IEnumerable<ProductV2Dto>> GetV2()
{
var products = _service.GetProducts();
return Ok(products.Select(p => new ProductV2Dto
{
Id = p.Id,
Name = p.Name,
PriceInfo = new PriceDto
{
Amount = p.Price,
Currency = p.Currency ?? "USD"
}
}));
}
}
Strategy 2: Feature Flags
Use feature flags for gradual rollout:
public class ProductsController : ControllerBase
{
private readonly IFeatureManager _featureManager;
[HttpGet]
[MapToApiVersion("2.0")]
public async Task<ActionResult<IEnumerable<ProductV2Dto>>> GetV2()
{
if (await _featureManager.IsEnabledAsync("ProductsV2"))
{
return Ok(GetProductsV2());
}
// Fallback to V1 behavior
return Ok(GetProductsV1().Select(AdaptToV2));
}
}
Strategy 3: Sunset Headers
Announce version retirement:
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<IEnumerable<ProductV1Dto>> GetV1()
{
Response.Headers.Append("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT");
Response.Headers.Append("Link", "</api/v2/products>; rel=\"successor-version\"");
return Ok(GetProductsV1());
}
Versioning with Swagger/OpenAPI
Configure Swagger to show multiple versions:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
var provider = builder.Services.BuildServiceProvider()
.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = $"My API {description.ApiVersion}",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated
? "This API version is deprecated."
: "Current API version."
});
}
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerEndpoint(
$"/swagger/{description.GroupName}/swagger.json",
description.GroupName.ToUpperInvariant());
}
});
}
Now Swagger UI shows a dropdown with each version.
Version Negotiation Errors
Handle invalid versions gracefully:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
// Custom error response
options.ErrorResponses = new CustomApiVersionErrorProvider();
});
public class CustomApiVersionErrorProvider : IErrorResponseProvider
{
public IActionResult CreateResponse(ErrorResponseContext context)
{
var error = new
{
Error = "Invalid API version",
Message = context.Message,
SupportedVersions = context.ApiVersions.Select(v => v.ToString())
};
return new ObjectResult(error)
{
StatusCode = StatusCodes.Status400BadRequest
};
}
}
Best Practices
1. Use Semantic Versioning
Major version for breaking changes:
v1.0→v2.0: Breaking changev2.0→v2.1: New features (backward compatible)
2. Don’t Over-Version
Not every change needs a new version. Group related changes:
Bad:
- v1.0: Initial
- v1.1: Added field
- v1.2: Added endpoint
- v2.0: Renamed field
Good:
- v1.0: Initial
- v2.0: Major redesign with multiple improvements
3. Document Migration Paths
Provide clear upgrade guides:
## Migrating from v1 to v2
### Breaking Changes
1. `price` field replaced with `priceInfo` object
- Before: `{ "price": 29.99 }`
- After: `{ "priceInfo": { "amount": 29.99, "currency": "USD" } }`
### Code Example
// V1
var price = product.Price;
// V2
var price = product.PriceInfo.Amount;
var currency = product.PriceInfo.Currency;
4. Set Clear Deprecation Timelines
Give clients time to migrate:
v1.0: Deprecated on 2026-06-01
v1.0: Sunset on 2026-12-31 (6 months notice)
5. Monitor Version Usage
Track which versions are being used:
public class VersionLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<VersionLoggingMiddleware> _logger;
public VersionLoggingMiddleware(RequestDelegate next, ILogger<VersionLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var apiVersion = context.GetRequestedApiVersion();
_logger.LogInformation(
"API request: {Method} {Path} Version: {Version}",
context.Request.Method,
context.Request.Path,
apiVersion?.ToString() ?? "Unspecified");
await _next(context);
}
}
Conclusion
API versioning isn’t optional for production APIs—it’s essential for long-term maintainability. The right strategy depends on your needs:
- URL path versioning: Best for public APIs, maximum clarity
- Query string: Good for internal APIs, maintains URL stability
- Header versioning: Most RESTful, but harder to discover
- Combined approach: Maximum flexibility
Key takeaways:
- Version from day one, even if you’re on v1
- Choose one versioning strategy and stick to it
- Clearly document breaking changes and migration paths
- Use deprecation warnings before retiring versions
- Monitor version usage to inform sunset decisions
The complete example with all versioning strategies is available on GitHub. Pick the strategy that fits your API’s needs and your clients’ expectations.