Updated on
ModelState is a dictionary on every MVC controller that records what went wrong while ASP.NET Core turned an HTTP request into our action’s parameters. ModelState.IsValid is false when anything did.
Two subsystems fill it. Model binding records conversion failures, and model validation records broken rules such as a missing [Required] property. Everything else is a decision about what our API returns once IsValid comes back false.
VIDEO: Use ModelState Validation in .NET Core Properly to Clean Your Actions.
What Is ModelState in C#?
ModelState is a property on the ControllerBase class, of type ModelStateDictionary, that records everything the framework learned while turning an HTTP request into our action’s parameters.
It fills in two stages. Model binding runs first and records conversion failures: a number where the action wants a string, a malformed date, a body that will not parse. Model validation runs second and records broken rules, such as a [Required] property that arrived empty or a [StringLength] that was exceeded.
Each entry is keyed by the field name and holds the attempted value along with the errors recorded against it. ModelState.IsValid is the shortcut that returns false when any entry holds an error.
One thing catches people out. ModelState belongs to MVC controllers, so a minimal API endpoint never sees it and validates its input a different way.
Everything else here is a decision about what our API returns once IsValid is false.
Let’s explain that theory with one small example:
public class CreateBookInputModel
{
public string? Title { get; set; }
public string? Description { get; set; }
public string? ISBN { get; set; }
}
For example, the model binding logic will check that proper value types are assigned to each property. That means we can’t bind the following JSON to our CreateBookInputModel since it is going to fail because the Title is not a number:
{
"Title": 100,
"Description": "Book description",
"ISBN": "123456789"
}
On the other hand, checking that the ISBN string is a 10 or 13 digit number that conforms to a particular digit calculation could be part of the model validation logic.
ModelState Validation Setup
We create the sample with the ASP.NET Core Web API template, using controllers rather than minimal APIs.
After creating the app, we are going to rename WeatherForecastController to BooksController and change the boilerplate code:
[ApiController]
[Route("[controller]")]
public class BooksController : ControllerBase
{
}
Furthermore, we are going to place our CreateBookInputModel class in the Models folder and delete WeatherForecast class.
Also, let’s create one POST endpoint in our BooksController:
[HttpPost]
public IActionResult Post([FromBody] CreateBookInputModel createBookInputModel)
{
return Ok(createBookInputModel);
}
For the sake of simplicity, our controller method is going to return the OK result with the same object it has received. We are not going to have an infrastructure layer in this article or work with 201 status codes for the POST action. The main focus is on the ModelState validation.
Since our BookController inherits ControllerBase we are able to access ModelState property in our Post method, but we are not going to do that yet. Before going further with our example, let’s explain how validation works.
What Does ModelState Validation Validate?
Model binding and model validation occur before executing a controller action in our APIs. Moreover, the ModelState object has an IsValid property where we can check its state. This property is exposed since it should be an application’s responsibility to review ModelState and act accordingly. We are going to see later how the framework can help us with that.
Adding the Validation Rules to the Model
Until now, we have seen just one example of model binding validation. And that was the one we got with defining our properties in the model binding class.
That said, let’s add more validation rules to our CreateBookInputModel class:
public class CreateBookInputModel
{
[Required]
public string? Title { get; set; }
[MaxLength(250)]
public string? Description { get; set; }
[Required]
[StringLength(13, MinimumLength = 13)]
public string? ISBN { get; set; }
}
[Required], [MaxLength(250)] and [StringLength(13, MinimumLength = 13)] are validation attributes from the System.ComponentModel.DataAnnotations namespace, and let us specify validation rules for model properties. If we can’t find any appropriate built-in attributes we can write custom validation attributes as well, such as a validation attribute that depends on another property.
It is worth mentioning that we could also define our Title without [Required] attribute:
public string Title { get; set; }
Since non-nullable properties are treated as if they had a [Required(AllowEmptyStrings = true)] attribute, missing Title in the request will also result in a validation error. We can override this behavior in our Program class:
builder.Services.AddControllers(
options => options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true);
Sometimes, it is good to separate validation logic from the binding models. We could achieve that by using the FluentValidation library. You can read more about it in our FluentValidation in ASP.NET Core article.
Getting the Validation Errors for Invalid Requests
Let’s see validation in action. We are going to run our solution and make a POST request via Postman with the request body:
{
"Description": "Book description",
"ISBN": "123456789"
}
After sending the request, we get validation errors with 400 Bad Request status code in the response:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-e98123452c30c41dcb42a9b6c0c61f00-87ee6b6464b24a46-00",
"errors": {
"ISBN": [
"The field ISBN must be a string with a minimum length of 13 and a maximum length of 13."
],
"Title": [
"The Title field is required."
]
}
}
Validation attributes let us specify the error messages we want to return for invalid input. Since we didn’t specify any for the ISBN property, we have got the one that the attribute generated for us. Let’s change the error message to a more appropriate one:
public class CreateBookInputModel
{
[Required]
public string? Title { get; set; }
[MaxLength(250)]
public string? Description { get; set; }
[Required]
[StringLength(13, MinimumLength = 13, ErrorMessage = "The ISBN must be a string with the exact length of 13.")]
public string? ISBN { get; set; }
}
After resending the same request we now get the response with the changed error message:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-b80c1c2a49c3c63ab6d34dd87cc4bdb4-d10e4537a96b88ad-00",
"errors": {
"ISBN": [
"The ISBN must be a string with the exact length of 13."
],
"Title": [
"The Title field is required."
]
}
}
We have stated that it is an application’s responsibility to check ModelState and act accordingly. But in our code, we don’t check ModelState anywhere. Yet, we’ve got validation errors.
That’s possible because our controller has an [ApiController] attribute. Microsoft’s ASP.NET Core Web API documentation states it plainly: “The [ApiController] attribute makes model validation errors automatically trigger an HTTP 400 response.”
However, sometimes we don’t want to rely on automatic responses. For example, when the model is invalid, the proper status code should be 422 Unprocessable Entity.
Let’s see how we can achieve that.
How Do We Check ModelState.IsValid Ourselves?
ModelState.IsValid returns false when binding or validation recorded at least one error, and reading it inside the action is the direct way to decide the response ourselves.
The [ApiController] attribute already reads it for us. It short-circuits an invalid request with a 400 before our code runs, so checking by hand only changes anything once that automatic response is out of the way.
There are three places to put the check, and they differ in how much of the request is in scope. Inside the action is the most explicit and gets duplicated across every endpoint. Inside an action filter removes the duplication and runs before the action body, with both the model state and the bound arguments available. Through the framework’s own response factory changes what the automatic check returns without switching it off.
This section builds the first two. The third comes after it.
[HttpPost]
public IActionResult Post([FromBody] CreateBookInputModel createBookInputModel)
{
if (!ModelState.IsValid)
{
return UnprocessableEntity(ModelState);
}
return Ok(createBookInputModel);
}
We can do better. If our controller ends up having, e.g., five endpoints, we will have some duplicated code. That leads us to the second way of doing the validation, using action filters. Let’s implement one simple action filter:
public class ValidationFilterAttribute : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new UnprocessableEntityObjectResult(context.ModelState);
}
}
public void OnActionExecuted(ActionExecutedContext context) {}
}
We can use that filter on our Post action after we register it as a scoped service in our Program class:
builder.Services.AddScoped<ValidationFilterAttribute>();
Now we are ready to remove the validation code from our method and use our validation filter as a service:
[HttpPost]
[ServiceFilter(typeof(ValidationFilterAttribute))]
public IActionResult Post(CreateBookInputModel createBookInputModel)
{
return Ok(createBookInputModel);
}
But if we try to run our solution and make a request, we will still get an automatic 400 error response. One idea to fix this could be to remove the [ApiController] attribute, and yes, our code will work. However, we shouldn’t do that since this attribute also brings other functionalities to our controller.
So, we are going to fix this differently by disabling the automatic validation in our Program class:
...
builder.Services.Configure<ApiBehaviorOptions>(options
=> options.SuppressModelStateInvalidFilter = true);
...
Now, if we run the same request as before via Postman:
{
"ISBN": [
"The ISBN must be a string with the exact length of 13."
],
"Title": [
"The Title field is required."
]
}
We can see that our input was validated, and we receive the 422 Unprocessable Entity status code.
Adding the Custom Validation Logic to the Controller
Apart from the IsValid property we can also access ModelState.AddModelError method from the controller. It allows us to implement custom business logic for our model. For example, we could add interdependency between the Title and Description properties:
[HttpPost]
public IActionResult Post([FromBody] CreateBookInputModel createBookInputModel)
{
if (createBookInputModel.Title != null
&& createBookInputModel.Description != null
&& !createBookInputModel.Description.Contains(createBookInputModel.Title))
{
ModelState.AddModelError(nameof(createBookInputModel.Description), "Book description should contain book title!");
}
if (!ModelState.IsValid)
{
return UnprocessableEntity(ModelState);
}
return Ok(createBookInputModel);
}
How Do We Return 422 Instead of the Automatic 400 Response?
SuppressModelStateInvalidFilter is not the only lever ApiBehaviorOptions gives us. It also exposes InvalidModelStateResponseFactory, a function that builds the result the automatic check returns, so a 400 becomes a 422 across the whole API in one place.
The difference between the two matters. Suppressing the filter removes the check, which makes every action responsible for validating again. Replacing the factory leaves the check exactly where it is and changes only what it hands back.
The factory receives the ActionContext, so it can read context.ModelState and shape whatever response body we want.
By default that body is a ValidationProblemDetails: a problem-details document served as application/problem+json, with an errors dictionary keyed by field name. Returning a bare object throws that shape away, so the better move is usually to keep it and change the status code alone.
Use a filter when one action needs different handling. Use the factory when the whole API should answer the same way.
The component doing the automatic check has a name: ModelStateInvalidFilter, an ordinary action filter whose Order is -2000. That large negative number is what puts it ahead of the filters we write ourselves. It sits between model validation and our action, which is why suppressing it hands the check back to us and replacing its factory does not.
The document it returns by default is worth knowing before we replace it. Our article on the ProblemDetails class and the shape ASP.NET Core returns by default covers the fields and where they come from.
We configure the factory alongside the controllers, in Program:
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = context.HttpContext.RequestServices
.GetRequiredService<ProblemDetailsFactory>()
.CreateValidationProblemDetails(context.HttpContext, context.ModelState,
StatusCodes.Status422UnprocessableEntity);
return new UnprocessableEntityObjectResult(problemDetails);
});
Nothing is suppressed here, so the check still runs before our action, only its result changed. Asking ProblemDetailsFactory to build the body is what keeps the application/problem+json content type, the title, the errors dictionary and the traceId; passing context.ModelState straight into UnprocessableEntityObjectResult instead returns a plain application/json dictionary and loses all four.
The sample project for this article builds the action-filter approach, so this configuration has no counterpart in the download.
What Does ModelStateDictionary Give Us?
ModelStateDictionary maps field names to entries, and each entry holds the attempted value plus the errors recorded against that field.
IsValid and ErrorCount report the state. AddModelError() records an error of our own, which is how a business rule that no attribute can express ends up in the same response as the attribute failures.
TryAddModelError() does the same thing but returns false once the dictionary has capped itself. MaxAllowedErrors defaults to 200, and once that cap is reached the framework stops recording per-field errors and records a single TooManyModelErrorsException against the empty-string key instead. That marker entry counts against the cap itself.
Remove(), Clear() and ClearValidationState() take errors back out, which matters when we correct a model inside the action and want to validate it again.
One property is read-only and surprises people: ActionContext.ModelState has a getter and no setter, so a filter or an action changes the dictionary in place rather than assigning a new one.
| Member | What it does |
|---|---|
IsValid | true when no entry holds an error |
ErrorCount | Total number of recorded errors |
Count | Number of keys in the dictionary |
MaxAllowedErrors | Cap on recorded errors; defaults to 200 |
HasReachedMaxErrors | true once the cap is hit |
AddModelError(key, message) | Records our own error against a field |
AddModelError(key, exception, metadata) | Records an exception as an error |
TryAddModelError(key, message) | Same, returning false once the cap is reached instead of void |
Remove(key) | Drops one field's entry |
Clear() | Empties the dictionary |
ClearValidationState(key) | Keeps the entry, drops its errors and validation state |
MarkFieldValid(key) | Marks a field valid without re-running validation |
MarkFieldSkipped(key) | Marks a field as not validated |
GetFieldValidationState(key) | Validation state for one field and its children |
SetModelValue(key, value, rawValue) | Records the attempted value for a field |
Conclusion
In this article, we have explained what is a ModelState validation. Also, we have shown different ways of doing validation in our API and how to override automatic validation provided by [ApiController] attribute. Once a request has passed validation, handling the exceptions that get past validation is the next piece of the same pipeline.
Tested with .NET 10.

Hey Marinko thanks for the article. One query – When asp.net mvc does the model binding exception handling itself, then what is the advantage of using model state to do this ? In case of model state validation there is a risk as I have to implement it in every method and a programmer can accidently forget to leave it out in one of the method.
Hi Hassan. For me, showing a “400 response” is not acceptable for the model validation as this code is not a good representation of what really happened. If the model is wrong, we should return 429 instead. Also, you don’t have to write it in each action, you can create an action filter to do that for you and then simply reuse it. You can check my article about action filters.
Great Article !! Thanks a lot..
I have a doubt this may sound silly, Is it possible to find any additional parameters passed in request model ? JSON request allow us to pass invalid parameter even though it will not bind to model. its causing issue in API security.. API Mass Assignment.
Thanks Gomz. Well, if you use the validation attribute with action filters, you will have to implement methods that accept the context parameter. Try inspecting that parameter, you can find all sorts of information there.
Cool… Thank you !!
This article is awesome, thanks a lot!
But if you want to have the compatible filter response it should be a bit different:
Hello VictorNS. Thanks for both the comment and the suggestion.
Great article!
How would you go about validating controller “get” parameters?
Could this article https://code-maze.com/aspnetcore-required-query-string-parameters/ be helpful for you on that matter?
Can’t believe I’m the first to comment. Great article. Well organized and very helpful.
Thanks a lot. Well, someone has to be the first one, and thank you for that 🙂