All articles

ASP.NET Core MVC Model Binding

How ASP.NET Core turns route values, query strings, forms, and JSON bodies into typed action parameters — binding sources, complex types, collections, and the classic empty-model gotcha.

0 · log in to like, save & follow Share on LinkedIn Share on X

Model binding is how ASP.NET Core turns raw request data — route values, query strings, form fields, JSON bodies — into the typed parameters of your action methods. You write public IActionResult Get(int id) and the framework finds id, converts it, and hands it over. This tutorial shows each binding source with a verified request and response, then covers complex objects, collections, and what happens when binding fails.

cover

Where bound values come from

For each action parameter, MVC searches sources in order: route values → query string → form fields (and, in [ApiController]s, complex types default to the JSON body). You can always name the source explicitly:

Attribute Binds from Example request part
[FromRoute] URL segments /demo/route-bind/42
[FromQuery] query string ?id=7&name=pixel
[FromForm] form post Id=3&Name=AirPods
[FromBody] JSON body {"id":4,"name":"SSD"}
[FromHeader] HTTP header X-Api-Key: …
[FromServices] DI container injected service

Simple types, verified

public IActionResult FromQuery(int id, string? name) => Json(new { id, name });
GET /demo/fromquery?id=7&name=pixel → {"id":7,"name":"pixel"}

The string "7" became int 7 — conversion is part of binding. A non-numeric value (?id=abc) fails binding: in MVC pages id stays 0 and ModelState records the error; in an [ApiController] the request is rejected with 400 automatically.

Route binding with a constraint:

[Route("demo/route-bind/{id:int}")]
public IActionResult RouteBind(int id) => Json(new { boundFromRoute = id });
GET /demo/route-bind/42 → {"boundFromRoute":42}

The :int constraint means a non-numeric URL never even reaches the action — routing returns 404 first. More on constraints in ASP.NET Core MVC routing.

Complex types

Binding walks public properties by name and fills them from the same sources:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

Form post (a Razor <form method="post">):

[HttpPost]
public IActionResult PostForm([FromForm] Product product) => Json(product);
POST Id=3&Name=AirPods&Price=200 → {"id":3,"name":"AirPods","price":200}

JSON body (fetch/API clients):

[HttpPost]
public IActionResult PostBody([FromBody] Product product) => Json(product);
POST {"id":4,"name":"SSD","price":80}  (Content-Type: application/json)
→ {"id":4,"name":"SSD","price":80}

The classic gotcha: sending JSON to an action without [FromBody] (outside [ApiController]) gives you a model with default values — MVC looked for form fields and found none. If your model arrives empty, check the attribute and the Content-Type header first.

Collections and dictionaries

Repeat the parameter name for lists:

?tags=api&tags=efcore   →   public IActionResult Filter(List<string> tags)

Indexed names bind lists of complex types from forms: items[0].Name, items[1].Name — this is what Razor's tag helpers generate when you render an editable list.

Binding and validation are partners

Binding populates the model; DataAnnotations validation then judges it. Both report into ModelState:

[HttpPost]
public IActionResult Save(Product product)
{
    if (!ModelState.IsValid) return View(product);   // MVC pages: redisplay with errors
    ...
}

In [ApiController]s you skip the check — invalid models are answered with a 400 problem+json before your code runs.

Binding uploaded files

File inputs bind to IFormFile (or List<IFormFile> for multi-file inputs):

[HttpPost]
public async Task<IActionResult> Upload(IFormFile document)
{
    if (document.Length == 0) return BadRequest("Empty file");
    if (document.Length > 5 * 1024 * 1024) return BadRequest("Max 5 MB");

    var safeName = $"{Guid.NewGuid():N}{Path.GetExtension(document.FileName)}";
    await using var stream = File.Create(Path.Combine(uploadRoot, safeName));
    await document.CopyToAsync(stream);
    return Ok(new { stored = safeName });
}

The matching form needs enctype="multipart/form-data". Never trust document.FileName for storage — it's client-controlled; generate your own name and validate the extension and size server-side.

Restricting what binds: [Bind] and binding prefixes

[Bind] whitelists properties for a single action — one defense against over-posting when you must bind an entity directly:

[HttpPost]
public IActionResult Create([Bind(nameof(Product.Name), nameof(Product.Price))] Product product)

A dedicated request DTO is the better fix, but [Bind] earns its keep in legacy code. Related: when a page posts two objects, prefixes keep the fields apart — [Bind(Prefix = "billing")] binds billing.Street, billing.City into one parameter while shipping.* fills another.

Custom model binders

When a value arrives in a shape the default binder can't parse — say, a comma-separated id list ?ids=3,7,12 — implement IModelBinder:

public class CsvIdListBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext context)
    {
        var raw = context.ValueProvider.GetValue(context.ModelName).FirstValue;
        if (string.IsNullOrEmpty(raw)) { context.Result = ModelBindingResult.Success(new List<long>()); return Task.CompletedTask; }

        var ids = new List<long>();
        foreach (var part in raw.Split(','))
        {
            if (!long.TryParse(part, out var id))
            {
                context.ModelState.AddModelError(context.ModelName, $"'{part}' is not a valid id");
                context.Result = ModelBindingResult.Failed();
                return Task.CompletedTask;
            }
            ids.Add(id);
        }
        context.Result = ModelBindingResult.Success(ids);
        return Task.CompletedTask;
    }
}
public IActionResult Compare([ModelBinder(typeof(CsvIdListBinder))] List<long> ids) => Json(ids);

Register globally with a IModelBinderProvider if several actions share the format. Custom binders are the escape hatch — reach for them only after confirming a type converter or a different parameter shape can't do it.

Binding outside action parameters

Two more places binding shows up:

  • [BindProperty] on controller (or Razor Page) properties binds them on POST without a parameter — the standard Razor Pages pattern.
  • TryUpdateModelAsync binds onto an object you already have — the classic safe-update flow: load the entity, then copy over only approved fields:
var product = await db.Products.FindAsync(id);
if (await TryUpdateModelAsync(product, prefix: "",
        p => p.Name, p => p.Price))   // only these two can change
{
    await db.SaveChangesAsync();
}

Practical guidance

  • Let convention work: route/query/form for simple values needs no attributes. Reach for [From…] when the source is ambiguous or you're binding JSON.
  • One [FromBody] per action — the request body can only be read once.
  • Bind to a dedicated request model, not your EF entity — otherwise a crafted POST can set columns you never exposed (over-posting). The repository pattern post shows the DTO layering.
  • Use nullable types (int?) when "not supplied" must be distinguishable from zero.
Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.