Custom Model Binders in ASP.NET Core

Model binding maps HTTP request data — route values, query strings, headers, and request bodies — to action method parameters. The built-in binders handle most cases, but sometimes your API needs to accept data in formats the defaults can't parse. That's where custom model binders come in.

When You Need Custom Binding

Consider an endpoint that accepts multiple IDs as a comma-separated query parameter:

GET /api/products?ids=1,5,12,37

The default binder expects repeated parameters (?ids=1&ids=5&ids=12). A custom binder lets you support the comma-separated format instead.

Building a Model Binder

Implement IModelBinder:

CommaSeparatedModelBinder.cs
public class CommaSeparatedModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);

        if (valueProviderResult == ValueProviderResult.None)
        {
            return Task.CompletedTask;
        }

        var value = valueProviderResult.FirstValue;

        if (string.IsNullOrWhiteSpace(value))
        {
            bindingContext.Result = ModelBindingResult.Success(Array.Empty<int>());
            return Task.CompletedTask;
        }

        var values = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        var parsed = new List<int>();

        foreach (var item in values)
        {
            if (int.TryParse(item, out var id))
            {
                parsed.Add(id);
            }
            else
            {
                bindingContext.ModelState.AddModelError(modelName, $"'{item}' is not a valid integer.");
                return Task.CompletedTask;
            }
        }

        bindingContext.Result = ModelBindingResult.Success(parsed.ToArray());
        return Task.CompletedTask;
    }
}

Apply it to a parameter:

Example.cs
[HttpGet]
public IActionResult GetByIds(
    [ModelBinder(BinderType = typeof(CommaSeparatedModelBinder))] int[] ids)
{
    var products = _repository.GetByIds(ids);
    return Ok(products);
}

Model Binder Providers

If you want a binder to apply automatically based on type rather than using the attribute every time, create a provider:

CommaSeparatedModelBinderProvider.cs
public class CommaSeparatedModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(int[])
            && context.BindingInfo.BindingSource == BindingSource.Query)
        {
            return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder));
        }

        return null;
    }
}

Register the provider — order matters, as the first matching provider wins:

Example.cs
builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
});

Binding Complex Types from Route Values

A more interesting use case is binding a composite key from a route segment:

ProductVariantKeyBinder.cs
public record ProductVariantKey(int ProductId, string Colour);

public class ProductVariantKeyBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).FirstValue;

        if (value is null)
        {
            return Task.CompletedTask;
        }

        // Expect format: "123-red"
        var parts = value.Split('-', 2);

        if (parts.Length != 2 || !int.TryParse(parts[0], out var productId))
        {
            bindingContext.ModelState.AddModelError(
                bindingContext.ModelName, "Expected format: {productId}-{colour}");
            return Task.CompletedTask;
        }

        var key = new ProductVariantKey(productId, parts[1]);
        bindingContext.Result = ModelBindingResult.Success(key);
        return Task.CompletedTask;
    }
}

Apply it via an attribute on the type itself:

Example.cs
[ModelBinder(BinderType = typeof(ProductVariantKeyBinder))]
public record ProductVariantKey(int ProductId, string Colour);

[HttpGet("variants/{key}")]
public IActionResult GetVariant(ProductVariantKey key)
{
    // key.ProductId = 123, key.Colour = "red" for /variants/123-red
    return Ok(key);
}

Binding from Headers

Custom binders can pull data from any source. Here's one that binds a correlation ID from a header:

CorrelationIdBinder.cs
public class CorrelationIdBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var httpContext = bindingContext.HttpContext;
        var correlationId = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault()
                         ?? Guid.NewGuid().ToString();

        bindingContext.Result = ModelBindingResult.Success(correlationId);
        return Task.CompletedTask;
    }
}

Using IParsable in .NET 7+

For minimal APIs, .NET 7 introduced support for IParsable<T>. If your type implements it, minimal API binding works automatically:

Example.cs
public record DateRange(DateOnly From, DateOnly To) : IParsable<DateRange>
{
    public static DateRange Parse(string s, IFormatProvider? provider)
    {
        var parts = s.Split("..");
        return new DateRange(DateOnly.Parse(parts[0]), DateOnly.Parse(parts[1]));
    }

    public static bool TryParse(string? s, IFormatProvider? provider, out DateRange result)
    {
        result = default!;
        if (s is null) return false;

        var parts = s.Split("..");
        if (parts.Length != 2) return false;

        if (DateOnly.TryParse(parts[0], out var from) && DateOnly.TryParse(parts[1], out var to))
        {
            result = new DateRange(from, to);
            return true;
        }

        return false;
    }
}

// Works automatically: GET /orders?range=2025-01-01..2025-03-31
app.MapGet("/orders", (DateRange range) => Results.Ok($"From {range.From} to {range.To}"));

Key Points