Reading the Request
How ASP.NET Core fills a handler's parameters from the route, query string, headers and body, and how to override its guess.
app.MapPost("/orders/{id:int}", (int id, [FromQuery] string? coupon, [FromBody] Order order) => ...);An HTTP request arrives as text: a method, a path, some headers, and sometimes a body. Model binding is the step that turns that text into the parameters of your handler. You write int id, and ASP.NET Core finds the value, converts it, and calls your code.
Where each parameter comes from
Every parameter gets a binding source: the part of the request its value is read from. When you do not say which, the framework picks one.
| The parameter | Source chosen |
|---|---|
Its name matches a {placeholder} in the route | Route value |
A string, a number, or any type with a static TryParse | Query string |
| A type registered in dependency injection | That service |
HttpContext, HttpRequest, CancellationToken, ClaimsPrincipal, IFormFile | Taken from the context |
| Anything else | The body, read as JSON |
Saying it in full
Those guesses are a shorthand. The attributes [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromForm] and [FromServices] state the same choice in longhand, and they beat the guess. A header is the one source never guessed, so it always needs [FromHeader]. Reach for [FromBody] when the guess is wrong, such as a string parameter that is meant to be the whole body.
What trips people up
A parameter is required unless its type is nullable or it has a default value. A missing one is a 400 and your handler never runs. A value that will not parse is also a 400, even for int?. A body sent as anything other than application/json is a 415.
Example
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// id matches {id} in the route, so it is read from the path.
// page is a number with no match in the route, so it is read from the query string.
// tenant is a header, which is never guessed, so it is named in full.
app.MapGet("/orders/{id:int}", (
int id,
int page,
[FromHeader(Name = "X-Tenant")] string? tenant) =>
TypedResults.Ok(new { id, page, tenant }));
// order is not a number, a service or a special type, so it comes from the body as JSON.
app.MapPost("/orders", (Order order) =>
TypedResults.Created($"/orders/{order.Id}", order));
// The same two sources, written out instead of guessed. This compiles to the same binding.
app.MapPost("/orders/search", ([FromQuery] string term, [FromBody] Order order) =>
TypedResults.Ok(new { term, order.Product }));
app.Run();
record Order(int Id, string Product, int Quantity);When to use it
- Read a page number from the query string and fall back to page 1 when the client omits it.
- Accept a JSON body on a PUT while also reading a tenant id from a request header.
- Collect a dozen filter parameters into one request type instead of a long parameter list.
- Take the body as a raw stream for an endpoint that receives CSV rather than JSON.
More examples
One parameter instead of ten, with [AsParameters]
[AsParameters] binds each property of a type from the request, using the same rules as parameters. The two endpoints accept identical requests. Reach for it when a handler has grown a long parameter list, or when several endpoints share the same set of filters and you want to declare them once. A struct is used here because it avoids an allocation per request; a record or class binds the same way.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Longhand: every value is its own parameter.
app.MapGet("/search", (
string term,
int page,
int pageSize,
[FromHeader(Name = "X-Tenant")] string? tenant) =>
TypedResults.Ok(new { term, page, pageSize, tenant }));
// Shorthand: the same four values, grouped into one type.
app.MapGet("/search2", ([AsParameters] SearchRequest request) =>
TypedResults.Ok(new
{
request.Term,
request.Page,
request.PageSize,
request.Tenant
}));
app.Run();
// Each property binds by exactly the rules the parameters used.
struct SearchRequest
{
public string Term { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
[FromHeader(Name = "X-Tenant")]
public string? Tenant { get; set; }
}A custom type that binds itself with TryParse
Adding a static TryParse to your own type makes it bindable from the route or query string, which keeps the parsing in one place instead of repeated in every handler. Returning false produces a 400 for the caller. Use this for small value types that arrive as one string: a coordinate, a date range, a sort expression.
using System.Globalization;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Point is not a number and not a service, but it has a static TryParse,
// so the framework reads it from the query string: /nearest?location=51.5,-0.12
app.MapGet("/nearest", (Point location) =>
TypedResults.Ok($"lat {location.Lat}, lon {location.Lon}"));
app.Run();
readonly struct Point
{
public double Lat { get; init; }
public double Lon { get; init; }
// The framework looks for exactly this shape: name, format provider, out value.
public static bool TryParse(string? value, IFormatProvider? provider, out Point point)
{
point = default;
string[] parts = value?.Split(',') ?? [];
if (parts.Length != 2
|| !double.TryParse(parts[0], NumberStyles.Float, provider, out double lat)
|| !double.TryParse(parts[1], NumberStyles.Float, provider, out double lon))
{
return false;
}
point = new Point { Lat = lat, Lon = lon };
return true;
}
}The same work done by hand
Taking an HttpRequest parameter turns binding off for the rest of the handler: you read the values yourself and decide what a missing or malformed one means. Worth doing when the request does not fit the binding model, for example when the body format depends on a header, or when you need the raw bytes. Note that every conversion and every missing-value check is now yours.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Binding is a convenience. This is what it saves you writing.
app.MapPost("/orders/{id:int}", async (HttpRequest request) =>
{
int id = int.Parse((string)request.RouteValues["id"]!);
string? coupon = request.Query["coupon"];
Order? order = await request.ReadFromJsonAsync<Order>();
if (order is null)
{
return Results.BadRequest("A JSON body is required.");
}
return Results.Ok(new { id, coupon, order.Product });
});
app.Run();
record Order(string Product, int Quantity);
Discussion