Mert Özen Further With Every Line
Backend .NET Core Web API — #08

Model Binding in ASP.NET Core: FromRoute, FromQuery, FromBody and FromHeader

Mert Özen Nov 4, 2026 8 min 2 views
Model Binding in ASP.NET Core: FromRoute, FromQuery, FromBody and FromHeader

How request data binds to action parameters from the route, query string, body, headers, forms and services, the inference rules of [ApiController], and why you get 415 and 400 errors, with real responses.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

We wrote a query class to filter products and made it a plain action parameter. To avoid breaking the real controller, we run this experiment in a separate BadBindingController:

[HttpGet("filter")]
public ActionResult<string> Filter(ProductQuery query)
{
    return query.Category ?? "none";
}

ProductQuery has the properties Category, MinPrice, MaxPrice and Page. If you send curl -i "http://localhost:5080/api/bad-binding/filter?category=books", what comes back: books, none, or an error? If it is an error, which status code?

What Does Model Binding Do?

An HTTP request is really a pile of text: the URL path, the query string, the headers and the body. An action, on the other hand, has typed parameters such as int id, decimal? minPrice and CreateProductRequest request. The bridge between the two is called model binding. For each parameter the framework looks for the value in the right part of the request, converts the string to the parameter's type and records what happened in ModelState.

Think of a parcel sorting center. When a parcel arrives, the clerk reads the label and puts it on the right belt. [FromRoute], [FromQuery], [FromBody], [FromHeader], [FromForm] and [FromServices] are those labels. When there is no label, the clerk guesses from the shape of the parcel.

The inference rules of [ApiController] go like this: a parameter whose name appears in the route template comes from the route, file types like IFormFile come from the form, the remaining complex types come from the body, and simple types come from the query string. The answer to the opening question is right here. ProductQuery is a complex type, so it is expected in the body. A GET request has neither a body nor a Content-Type, and the result is:

HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.13","title":"Unsupported Media Type","status":415,"traceId":"00-11780475237c24c0dfae09fd7e7b60a9-283422157a662172-00"}

Put the label on, meaning make the parameter [FromQuery] ProductQuery query, and the same request works. We will see that shortly.

Route and Query String

Let's start with the two sources you use most:

[HttpGet]
public ActionResult<IEnumerable<Product>> Search(string? category, decimal? minPrice, int page = 1)
{
    IEnumerable<Product> result = Products;
    if (!string.IsNullOrWhiteSpace(category))
    {
        result = result.Where(p => p.Category == category);
    }

    if (minPrice is not null)
    {
        result = result.Where(p => p.Price >= minPrice);
    }

    return result.Skip((page - 1) * 10).Take(10).ToList();
}

[HttpGet("{id:int}")]
public ActionResult<Product> GetById([FromRoute] int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

Search has no attributes at all; all three parameters are simple types, so they come from the query string. You can express an optional parameter in two ways: by making it nullable (decimal? minPrice, null when missing) or by giving it a default value (int page = 1). The [FromRoute] on GetById is actually unnecessary; id appears in the route template, so the inference would get it right anyway. But stating the intent explicitly makes life easier for whoever reads the code.

Now let's look at two bad requests: ?page=abc and /api/products/abc.

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-3182cdd7b30c339c9d4ab309603e83b6-cccf6b75b2290928-00","errors":{"page":["The value 'abc' is not valid."]}}

HTTP/1.1 404 Not Found
Content-Length: 0

The first is a model binding error: abc could not be converted to an int, the error was written to ModelState and [ApiController] returned 400 without ever running the action. The second never reached binding. The {id:int} constraint rejected abc during routing, and since no endpoint matched, a bodiless 404 came back. The same "wrong type" mistake produces two different status codes depending on where the check happens.

The query parameter name does not have to match the C# name, and you can bind arrays too:

[HttpGet("by-ids")]
public ActionResult<IEnumerable<Product>> GetByIds([FromQuery] int[] ids)
{
    return Products.Where(p => ids.Contains(p.Id)).ToList();
}

[HttpGet("sorted")]
public ActionResult<IEnumerable<string>> GetSorted([FromQuery(Name = "sort_by")] string? sortBy)
{
    IEnumerable<Product> result = sortBy == "price"
        ? Products.OrderBy(p => p.Price)
        : Products.OrderBy(p => p.Name);
    return result.Select(p => p.Name).ToList();
}

The request ?ids=1&ids=3 returns two products; repeating the same key fills the array. ?sort_by=price sorts by price. ?sortBy=price, however, raises no error; it is silently ignored and you get the list sorted by name. Unknown keys in a query string bother nobody, which is both a convenience and a trap.

Binding a Complex Type from the Query

As filter parameters pile up, the action signature grows. Collecting them in a class is cleaner:

namespace ProductCatalog.Models;

public class ProductQuery
{
    public string? Category { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
    public int Page { get; set; } = 1;
}
[HttpGet("filter")]
public ActionResult<IEnumerable<Product>> Filter([FromQuery] ProductQuery query)
{
    IEnumerable<Product> result = Products;
    if (query.Category is not null)
    {
        result = result.Where(p => p.Category == query.Category);
    }

    if (query.MinPrice is not null)
    {
        result = result.Where(p => p.Price >= query.MinPrice);
    }

    if (query.MaxPrice is not null)
    {
        result = result.Where(p => p.Price <= query.MaxPrice);
    }

    return result.Skip((query.Page - 1) * 10).Take(10).ToList();
}

With the [FromQuery] label the framework looks for a query string key with the same name as each property of the class. The request ?category=peripherals&maxPrice=1000 returns only Wireless Mouse. Matching is case-insensitive, so ?Category=books works as well. We will build pagination and sorting on top of this class in a later post.

Body: JSON and Input Formatters

When creating a new product we take the data from the body. Using a separate request class for this keeps the client from sending fields like Id:

namespace ProductCatalog.Models;

public class CreateProductRequest
{
    public string Name { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public decimal Price { get; set; }
}
[HttpPost]
public ActionResult<Product> Create(
    [FromBody] CreateProductRequest request,
    [FromHeader(Name = "X-Correlation-Id")] string? correlationId)
{
    Product product = new()
    {
        Id = Products.Max(p => p.Id) + 1,
        Name = request.Name,
        Category = request.Category,
        Price = request.Price
    };
    Products.Add(product);

    if (correlationId is not null)
    {
        Response.Headers["X-Correlation-Id"] = correlationId;
    }

    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

The body works differently from the other sources. Route and query values are small string fragments converted one by one. The body is handed as a whole to an input formatter; the default formatter reads the JSON with System.Text.Json and turns it into a CreateProductRequest. That is why an action can have only one parameter read from the body. Add a second complex parameter and the app stops at startup with this exception:

Unhandled exception. System.InvalidOperationException: Action 'ProductCatalog.Controllers.TwoBodyController.Create (ProductCatalog)' has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use 'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route, and 'FromBodyAttribute' for parameters to be bound from body:
CreateProductRequest request
Product product

Which formatter gets picked depends on the Content-Type header. If you send a request with curl -d and no header, curl marks the body as application/x-www-form-urlencoded and the JSON formatter never kicks in: the result is 415 Unsupported Media Type. With property names, on the other hand, the framework is forgiving: {"NAME":"Desk Lamp","Category":"office","PRICE":300} binds just fine, because ASP.NET Core ignores case when reading JSON.

Headers and Forms

In the Create above, the second parameter comes from a header. HTTP header names contain dashes, so they cannot match a C# parameter name; we give the real name through Name. A request sent with -H "X-Correlation-Id: abc-123" gets the same header back in the response:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/4
X-Correlation-Id: abc-123

{"id":4,"name":"USB-C Hub","category":"peripherals","price":890}

For form data there is [FromForm]:

[HttpPost("form")]
public ActionResult<Product> CreateFromForm([FromForm] CreateProductRequest request)
{
    Product product = new()
    {
        Id = Products.Max(p => p.Id) + 1,
        Name = request.Name,
        Category = request.Category,
        Price = request.Price
    };
    Products.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

The request curl -F name="Desk Lamp" -F category=office -F price=300 returns 201 with the right product. In JSON APIs you will mostly use forms for file uploads; we will cover that in its own post.

[FromServices]: Getting a Service from a Parameter

Sometimes what an action needs is not in the request but inside the application. [FromServices] asks the dependency injection container for the parameter:

[HttpGet("environment")]
public ActionResult<string> GetEnvironment([FromServices] IWebHostEnvironment environment)
{
    return environment.EnvironmentName;
}

The response is Production or Development, depending on which environment you run the app in. Remove the attribute and something interesting happens on .NET 6: IWebHostEnvironment is a complex type, so it is expected in the body, and the GET request returns 415. The same code returns 200 on .NET 10, because starting with .NET 7 types registered in the container are treated as services automatically. In the core series we write the attribute explicitly; we will look at this behavior change in the version posts later on. How the container itself works is the subject of the dependency injection post.

Binding Errors and ModelState

Let's send a truncated JSON: -d '{"name":"USB-C Hub",'

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-5eef9b1719aa1f3e14859cbd7eb33647-f6ea6efa4a057f62-00","errors":{"$":["Expected start of a property name or value, but instead reached end of data. Path: $ | LineNumber: 0 | BytePositionInLine: 19."],"request":["The request field is required."]}}

There are two entries in errors, and both are worth reading. The $ key shows the JSON path; $ means the root of the document. Had a property had a type error, the key would look like $.price. The second entry is a consequence of the first: since the body could not be read, the request parameter stayed null. Nullable reference types are enabled in the project, so a non-nullable parameter counts as required, which produces a separate error.

The query side has the opposite behavior. If a parameter like int page is never sent, no error occurs; it just gets its default value. If you want a value to be mandatory, use [BindRequired]; a request without it then returns 400 with the message "A value for the 'page' parameter or property was not provided." Rules about the content of a value, in other words validation, are the topic of the next post.

Common Pitfalls

An unlabeled complex parameter on a GET action is at the top of the list. You get a 415 as in the opening example, and the error message says nothing about the query. Whenever you see a complex type on a GET, your hand should reach for [FromQuery] on its own.

Second, sending JSON to an action that expects [FromForm]. Here you don't even get a 415: a request to CreateFromForm with Content-Type: application/json returns 201 with {"id":7,"name":"","category":"","price":0} in the body. The form reader does not understand JSON, creates an empty object, and the action saves it.

Third, name mismatches. As you saw with sortBy versus sort_by, a wrong key in the query string produces no error. Bugs that go unnoticed for a long time are usually born this way.

Fourth, forgetting Content-Type while testing. When you see a 415, the request headers should be the first place you look.

A Small Experiment

★☆☆ Add a maxPrice parameter to the Search action and confirm that ?minPrice=400&maxPrice=1000 returns only Wireless Mouse.

★★☆ Add a string[] Tags property to ProductQuery and show, through a value returned from the action, that ?tags=wireless&tags=sale fills the array.

★★★ Write a PUT api/products/{id} action: id from the route, the product from the body, and the If-Match header through [FromHeader]. Return 428 if the header is missing and 204 if it is present. Then remove [FromBody] and see what changes.

In this post we saw how data reaches the action, but we never checked whether that data is correct. A product with a negative price or an empty name is saved without complaint right now. In the next post we take on model validation with Data Annotations, custom attributes, IValidatableObject and FluentValidation.