# Model Binding a Form

ASP.NET Core fills an action's parameters, even a whole Product, from the request by matching names. asp-for writes inputs whose names match the model, and ModelState says when a value didn't fit.

Journeyman · ASP.NET Core · Razor · C# · Updated November 3, 2026

## After this lesson you can

- Explain how model binding fills an action's parameters and models from the request by name
- Write a form with asp-for so its fields match the model's properties
- Check ModelState.IsValid and show the form again when a value doesn't fit

## Key takeaways

- Model binding fills an action's parameters by name, from the route, the query string or the form, and fills a class such as `Product` property by property.
- `asp-for` writes a field whose `name` matches the model's property, along with its `id`, `type` and `value`.
- When a value doesn't fit, such as `abc` for the price or an empty `ProductName`, `ModelState.IsValid` is `false`.
- Check `ModelState.IsValid` before saving, and if it's `false`, return the view with the model so the user can fix what they typed.

The POST action in the last lesson took a whole `Product`:

```csharp
public IActionResult Create(Product product)
```

Nobody wrote code to read the form and build that object. ASP.NET Core did it, and that's called **model binding**.

## Binding matches names

Model binding fills each parameter by **name**, from whichever part of the request has that name:

| Comes from | Example | Fills |
|---|---|---|
| The route | `/Products/Details/7` | `int id` |
| The query string | `/Products/Search?name=sauce` | `string? name` |
| Form fields, in a POST | `ProductName=Maple Syrup&UnitPrice=7.50` | `Product product`'s properties |

For a class such as `Product`, binding creates the object, then fills **each property** from the field with the property's name: the `ProductName` field into `ProductName`, the `UnitPrice` field into `UnitPrice`, converting the text to `decimal` on the way. A property with no matching field keeps its default.

So a form's job is to send fields with exactly the model's property names.

## asp-for writes the fields for you

You could type every `name` yourself, but the **`asp-for`** tag helper does it from the model, and more. Here's NorthwindWeb's `Views/Products/Create.cshtml`:

```cshtml
@model Product
@{
    ViewData["Title"] = "Add a Product";
    List<Category> categories = (List<Category>)ViewData["Categories"]!;
}

<h1>Add a Product</h1>

<form method="post" asp-action="Create">
    <div class="mb-3">
        <label asp-for="ProductName" class="form-label">Name</label>
        <input asp-for="ProductName" class="form-control">
        <span asp-validation-for="ProductName" class="text-danger"></span>
    </div>
    <div class="mb-3">
        <label asp-for="CategoryId" class="form-label">Category</label>
        <select asp-for="CategoryId" class="form-select">
            @foreach (Category category in categories)
            {
                <option value="@category.CategoryId">@category.CategoryName</option>
            }
        </select>
    </div>
    <div class="mb-3">
        <label asp-for="UnitPrice" class="form-label">Price</label>
        <input asp-for="UnitPrice" class="form-control">
    </div>
    <div class="mb-3">
        <label asp-for="UnitsInStock" class="form-label">In stock</label>
        <input asp-for="UnitsInStock" class="form-control">
    </div>
    <button type="submit" class="btn btn-primary">Add the product</button>
</form>
```

`asp-for="ProductName"` names a property of the view's model. Here's some of the HTML it becomes:

```html
<label class="form-label" for="ProductName">Name</label>
<input class="form-control" type="text" data-val="true" data-val-required="The ProductName field is required." id="ProductName" name="ProductName" value="">
...
<select class="form-select" id="CategoryId" name="CategoryId">
    <option value="1">Beverages</option>
    <option value="2">Condiments</option>
    ...
</select>
...
<input class="form-control" type="number" id="UnitsInStock" name="UnitsInStock" value="">
```

From one attribute, each field got:

- a **`name`** that matches the property, so binding can fill it;
- an **`id`**, and a label whose **`for`** points at it, so clicking the label puts the cursor in the field;
- a **`type`** from the property's type: `number` for `short?`, `text` for `string`;
- a **`value`** from the model, so a form shown again keeps what the user typed.

The `<select>` sends the chosen option's `value`, a category's id, as `CategoryId`.

## When a value doesn't fit: ModelState

What if the user types `abc` for the price, or leaves the name empty? Binding can't put `abc` into a `decimal?`, so it leaves `UnitPrice` empty, and it records the problem in **`ModelState`**, the action's list of binding results. `ModelState.IsValid` is `false` if anything went wrong.

The name counts too. `ProductName` is a plain `string`, not `string?`, so ASP.NET Core treats it as **required**: an empty name is an error. (That's the `data-val-required` in the HTML above.)

Check `ModelState.IsValid` before saving, and if it's `false`, show the form again with what the user typed:

```csharp
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create(Product product)
{
    if (!ModelState.IsValid)
    {
        ViewData["Categories"] = productDao.GetCategories();
        return View(product);
    }

    int newId = productDao.Add(product);
    return RedirectToAction("Details", new { id = newId });
}
```

Posting an empty name and `abc` for the price now shows the form again, and the `asp-validation-for` span shows the name's message:

```html
<input class="form-control input-validation-error" type="text" ... id="ProductName" name="ProductName" value="">
<span class="text-danger field-validation-error" data-valmsg-for="ProductName" data-valmsg-replace="true">The ProductName field is required.</span>
...
<input class="form-control input-validation-error" type="text" ... id="UnitPrice" name="UnitPrice" value="abc">
```

The price box still shows `abc`, marked with the `input-validation-error` class, ready to be fixed. (The categories are loaded again because `ViewData` lasts only one request.)

You'll add your own rules, such as a price that must be more than zero, with friendlier messages, in *ASP.NET Core MVC in Depth*.

## Common problems

| What you see | What to do |
|---|---|
| A property is always empty after the POST | The field's `name` doesn't match the property. Use `asp-for` instead of typing names. |
| `NullReferenceException` on `categories` when the form is shown again | The POST action returned the view without setting `ViewData["Categories"]` again. |
| A bad value is saved as empty instead of being reported | The action doesn't check `ModelState.IsValid` before saving. |

## Check yourself

3 questions to try on the lesson page.

## So what

Names connect everything: the property's name becomes the field's name, which becomes the request's name, which binding matches back to the property. Write forms with `asp-for`, check `ModelState`, and the round trip from a form to a C# object takes no code at all.

## Key terms

- Model Binding
- asp-for
- ModelState

## Cheat sheets

- [Model Binding a Form](https://www.ask-codey.com/cheat-sheets/mvc-forms-model-binding-a-form-cheat-sheet)

Source: https://www.ask-codey.com/lessons/mvc-forms-model-binding-a-form
