AL.
🇪🇸 ES
Back to blog
.NET & C# · 9 min read

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:

  1. Break existing clients (they’ll hate you)
  2. 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/products with X-API-Version: 2.0 header

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.0v2.0: Breaking change
  • v2.0v2.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.