Updated on
An ASP.NET Core Web API controller action can return four things: a specific type such as Employee, an IActionResult, an ActionResult<T>, or one of the HttpResults types through TypedResults.
The choice comes down to one question. If the action has a single outcome, return the specific type. If it has more than one, the return type has to be able to carry a status code as well as a payload, and the three wrappers differ in how much of that they put in the method signature.
VIDEO: Different Web API Return Types You Can Use in Your Apps.
When Should We Return a Specific Type From a Controller Action?
A specific return type means the action signature names the data and nothing else: Employee, List<Employee>, string. There is no wrapper, and no place to put a status code.
That is enough when the action has one outcome. ASP.NET Core serialises what we return and sends 200 OK.
It stops being enough the moment a second outcome exists. A method whose return type is Employee has no way to produce 404 Not Found, so any action with a validation check or a lookup that can miss needs one of the three wrappers instead.
An unhandled exception still produces 500 Internal Server Error, but what the client receives depends on the environment. In Development the developer exception page returns the stack trace. In Production the default response is a bare 500 with an empty body.
builder.Services.AddProblemDetails() plus app.UseExceptionHandler() is how a specific-type action gets a machine-readable error body instead. Neither call works alone.
On the older ASP.NET Web API 2 the equivalents were IHttpActionResult and OkNegotiatedContentResult<T>; neither exists in ASP.NET Core, and the types below are the replacements.
Let’s consider a simple controller action method that returns the list of all employees:
[HttpGet]
public IEnumerable<Employee> Get() =>
_repository.GetEmployees();
The signature names the payload and nothing else, so there is no return path here that can produce anything but a 200 Ok response with a collection of Employee objects.
IEnumerable<T> vs IAsyncEnumerable<T>
It is a common practice to return a collection from controller actions using the IEnumerable<T> type. However, there is an important behavior of ASP.NET Core that we need to consider before choosing this type. ASP.NET Core buffers the result of the action endpoint that returns IEnumerable<T> before writing them into the response. This means even if we get the underlying data part by part asynchronously, ASP.NET Core will wait till it receives the complete data and then send the response at once.
For instance, let’s inspect an action method that uses the yield return statement to return elements one at a time:
[HttpGet("active")]
public IEnumerable<Employee> GetActive()
{
foreach (var employee in _repository.GetActiveEmployees())
{
yield return employee;
}
}
Here, even if the repository supports returning data part by part asynchronously, our action endpoint will still wait till it receives all the data and then returns everything together.
So what if we want to support asynchronous iteration? Well, for that, we need to use the IAsyncEnumerable<T> with the await foreach syntax:
[HttpGet("activeasync")]
public async IAsyncEnumerable<Employee> GetActiveAsync()
{
await foreach (var employee in _repository.GetActiveEmployeesAsync())
{
yield return employee;
}
}
With IAsyncEnumerable<T> and await foreach syntax, the action will return each element as it arrives.
We have explained the concept of IAsyncEnumerable in detail in our IAsyncEnumerable with yield in C# article and it will be a good reference to learn more about this topic.
What Does the IActionResult Return Type Do?
IActionResult is the interface every MVC result type implements, so an action that declares it can return any of them, from any code path.
Each result type maps to one HTTP response. NotFoundResult is 404, BadRequestResult is 400, OkObjectResult is 200 with a body.
ControllerBase provides a helper method for each one, which is why real code says return NotFound(); rather than return new NotFoundResult();. The helpers overload on whether we pass a value: Ok() gives an OkResult, Ok(employee) gives an OkObjectResult.
CreatedAtAction(nameof(GetById), new { id = employee.Id }, employee) is the one worth knowing in full. It returns 201 Created, puts the object in the body, and builds the Location header from the route of the action we name.
What we give up is the signature. The reader, Swagger and the built-in OpenAPI generator all see only the interface, so every possible response needs its own [ProducesResponseType] attribute to appear in the document.
Those attributes are what a documentation tool reads, and we cover the generator itself in Configuring and Using Swagger UI in ASP.NET Core Web API.
| Helper method | Result type | Status code |
|---|---|---|
Ok() | OkResult | 200 |
Ok(value) | OkObjectResult | 200 |
NoContent() | NoContentResult | 204 |
Created(uri, value) | CreatedResult | 201 |
CreatedAtAction(action, routeValues, value) | CreatedAtActionResult | 201 |
CreatedAtRoute(route, routeValues, value) | CreatedAtRouteResult | 201 |
Accepted(uri, value) | AcceptedResult | 202 |
BadRequest() | BadRequestResult | 400 |
BadRequest(value) | BadRequestObjectResult | 400 |
Unauthorized() | UnauthorizedResult | 401 |
Unauthorized(value) | UnauthorizedObjectResult | 401 |
Forbid() | ForbidResult | set by the authentication scheme, normally 403 |
NotFound() | NotFoundResult | 404 |
NotFound(value) | NotFoundObjectResult | 404 |
Conflict() | ConflictResult | 409 |
Conflict(value) | ConflictObjectResult | 409 |
UnprocessableEntity() | UnprocessableEntityResult | 422 |
UnprocessableEntity(value) | UnprocessableEntityObjectResult | 422 |
Problem() | ObjectResult carrying ProblemDetails | 500 unless we pass one |
StatusCode(code) | StatusCodeResult | the code we pass |
StatusCode(code, value) | ObjectResult | the code we pass |
Writing one attribute per response is easy to forget, and the ASP.NET Core Web API analyzers, which flag a missing [ProducesResponseType] for you exist for exactly that.
To see this in action, first, let’s create a synchronous action method for fetching an employee that returns the IActionResult return type:
[HttpGet("{id}")]
[ProducesResponseType<Employee>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetById(int id)
{
if (!_repository.TryGetEmployee(id, out var employee))
{
return NotFound();
}
return Ok(employee);
}
Note that there are two possible return types in this action. If the employee with the specified id is not found, it returns a 404 Not Found response. On the other hand, once it finds the employee with the specified id, it returns a 200 Ok status code with the employee object.
Also, note how we have specified the [ProducesResponseType] attribute multiple times to indicate all possible response status codes and types.
Now let’s create an asynchronous action method for creating an employee record that returns the IActionResult type:
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateAsync(Employee employee)
{
if (employee.Name is not { Length: >= 3 and <= 30 })
{
return BadRequest("Name should be between 3 and 30 characters.");
}
await _repository.AddEmployeeAsync(employee);
return CreatedAtAction(nameof(GetById), new { id = employee.Id }, employee);
}
We have two possible return types in this action as well. In case the validation rule fails on the employee name field, it returns a 400 Bad Request response. On the other hand, once the employee record is created successfully, it returns a 201 Created Success status code. Note that we are using the CreatedAtAction() shorthand method which will return the newly created employee record along with the response.
Automatic HTTP 400 Response
While using ASP.NET Core Web API, if we mark a controller with the [ApiController] attribute, it will automatically trigger an HTTP 400 response if there is a model validation error.
For instance, let’s say we have marked some of the attributes of our Employee class with the [Required] attribute:
public class Employee
{
public int Id { get; set; }
[Required]
public string? Name { get; set; }
public bool IsActive { get; set; }
}
Now if we do not provide a value for the Name property in the request, it will automatically return a 400 Bad request response provided the [ApiController] attribute is applied to the EmployeeController class.
You can read more about this attribute in our ApiController attribute article.
How Does ActionResult<T> Combine Both Approaches?
ActionResult<T> declares both halves at once. The action may return a T or any ActionResult, and implicit conversions handle each case.
Two things follow from that. [ProducesResponseType] no longer needs its Type property, because T already supplies it. And return employee; compiles where return new OkObjectResult(employee); was previously necessary.
The conversion is an implicit operator, and C# does not permit those on interfaces. So ActionResult<IEnumerable<Employee>> will not accept an IEnumerable<Employee>: call .ToList() first, or declare the return type as ActionResult<List<Employee>>.
This is the default choice for a controller action with more than one outcome. The signature still documents the success payload, any status code still gets through, and the generated document comes out right with fewer attributes than IActionResult needs.
One thing to watch with nullable reference types enabled: a lookup that can return null has to be handled before the return, because ActionResult<Employee> will not quietly take an Employee?.
Let’s convert the previous examples using IActionResult to return ActionResult<T>. First, let’s modify the synchronous method:
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Employee> GetById(int id)
{
if (!_repository.TryGetEmployee(id, out var employee))
{
return NotFound();
}
return employee;
}
Note that we have removed the Type property from the [ProducesResponseType] attribute. Apart from that, we have modified the return type to ActionResult<Employee> and we return the employee object directly. If the employee record is not found, it still returns the 404 ActionResult response. Here ASP.NET Core will cast both the ActionResult and the Employee type to the ActionResult<Employee> type.
Next, let’s see how to convert the asynchronous action using the IActionResult to ActionResult<T> type:
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Employee>> CreateAsync(Employee employee)
{
if (employee.Name is not { Length: >= 3 and <= 30 })
{
return BadRequest("Name should be between 3 and 30 characters.");
}
await _repository.AddEmployeeAsync(employee);
return CreatedAtAction(nameof(GetById), new { id = employee.Id }, employee);
}
Here, we just need to change the return type from IActionResult to ActionResult<Employee> . Since this action returns ActionResult type responses in both cases, ASP.NET Core will cast it to ActionResult<Employee> type.
How Do We Return HttpResults From a Controller Action?
HttpResults are the result types minimal APIs use, and controller actions accept them too. They live in Microsoft.AspNetCore.Http.HttpResults and we create them through the static TypedResults class.
There are two shapes. Returning IResult behaves like IActionResult and needs the same attributes. Returning Results<T1, T2> names the outcomes in the signature, and that is the form worth using.
Results<NotFound, Ok<Employee>> buys three things. The compiler rejects any result the action did not declare. The generated OpenAPI document gets its 404 and its 200 with no [ProducesResponseType] attribute at all. And the method body can move into a minimal API unchanged.
The union type takes two to six outcomes; an action with more paths than that goes back to IResult.
The trade is content negotiation. HttpResults bypass the configured output formatters, so the format is whatever the result type writes, in practice JSON. An ActionResult<T> action honours the Accept header and answers with whatever the registered formatters support; a Results<...> action does not.
Shaping that document is a subject of its own, and we cover the built-in OpenAPI generator and how to keep an endpoint out of the document separately.
Let’s write the same GetById action a third time, now with the outcomes in the signature:
[HttpGet("{id}")]
public Results<NotFound, Ok<Employee>> GetById(int id)
{
if (!_repository.TryGetEmployee(id, out var employee))
{
return TypedResults.NotFound();
}
return TypedResults.Ok(employee);
}
Notice what is not there: the two [ProducesResponseType] attributes the IActionResult version needed, and the Type property the ActionResult<T> version dropped.
It is also why the body travels: we can reuse the same result types in a minimal API without touching a single return statement.
The content-negotiation cost is documented rather than folklore. Microsoft’s documentation on controller action return types says of HttpResults that “Some features like Content negotiation aren’t available”.
One trap is worth naming before we get there. TypedResults has no CreatedAtAction. The equivalents are TypedResults.Created(uri, value) and TypedResults.CreatedAtRoute(route, routeValues, value), so an action moving off CreatedAtAction either names a route or builds the URI itself.
Which Web API Return Type Should We Use?
Start from how many outcomes the action has, then from where the code has to run.
One outcome and one payload: return the specific type. Nothing is simpler and the generated document is already correct.
More than one outcome, and the action stays in a controller: return ActionResult<T>. It is the shortest form that keeps the payload type in the signature, and content negotiation keeps working.
More than one outcome, and either the code is shared with minimal APIs or the attributes have become noise: return Results<T1, T2>. It is the only option the compiler checks and the only one that documents itself.
Plain IActionResult is the fallback rather than the default. Reach for it when an action genuinely returns unrelated shapes on different paths, and accept that each of them then needs a [ProducesResponseType] attribute before it appears in the document at all.
| Return type | More than one outcome | OpenAPI metadata | Content negotiation | Checked by the compiler |
|---|---|---|---|---|
Specific type, for example Employee | No | Taken from the signature | Yes | Not applicable |
IActionResult | Yes | One [ProducesResponseType] per response, including the type | Yes | No |
ActionResult<T> | Yes | T inferred, status codes still declared | Yes | No |
Results<T1, T2> through TypedResults | Yes | Automatic, from the union type | No | Yes |
None of the four is the right answer for a failure that is part of the domain rather than an HTTP condition, and for those we would reach for returning a domain failure without an exception, using the result pattern.
Specific types are the simplest ones to use if we do not have multiple paths and return types in an action. While using specific types, we do not have to define the [ProducesResponseType] attribute as the return type is visible from the action signature and documentation tools like swagger can read it. However, specific types cannot support multiple return types. Hence we cannot use them if we want to return different status codes like NotFound, BadRequest, Redirect, etc., which are very common with Web API actions.
IActionResult type can solve the limitation of specific types by supporting returning multiple types along with status codes. However, as it returns multiple types, the documentation tools like swagger would not be able to infer its output type directly. Hence it is very important to use the [ProducesResponseType] attribute explicitly for every possible return type. This can be a limitation of using this type.
ActionResult<T> is a combination of ActionResult and specific types and it allows us to return either an ActionResult or a specific type. While using this type, we can exclude the Type property of the [ProducesResponseType] attribute as the return type can be inferred from the T in the ActionResult<T> type. On top of that, C# supports implicit casting of both T and ActionResult to ActionResult<T>. Results<T1, T2> goes further and takes the status codes into the signature as well, at the cost of content negotiation.
Conclusion
In this article, we explored the four return types that are possible with the ASP.NET Core Web API Controller actions: a specific type, IActionResult, ActionResult<T> and the HttpResults types. Additionally, we learned the benefits and limitations of each type and how to use each of them synchronously and asynchronously.
Tested with .NET 10.0.10.

Is IAsyncEnumerable really buffered by ASP.NET Core?
Looking at this, it looks like you really can stream output:
https://youtu.be/2TT3suofNlo?t=511
Didn’t test it by myself though, …
Hi Thomas. That is something else. It is MediatR, and it has its own CreateStream functionality. So, it is not a native ASP.NET Core Web API behavior. You need to install MediatR to have those features. We have a good article about mediatR and CQRS, though we don’t cover this CreateStream functionality there: https://code-maze.com/cqrs-mediatr-in-aspnet-core/
Hi Marinko. Thanks for your reply.
Yes, the video is demonstrating MediatR but it uses only standard ASPnet Core controllers.
Just add a simple controller endpoint:
[HttpGet("/api/counter")] public async IAsyncEnumerable<int> Get() { for (var i = 0; i < 10; i++) { await Task.Delay(1000); yield return i; } }It will stream the results 🙂
(Postman/Insomnia will probably buffer the results, but curl/browsers do not)
You are correct. But I don’t see how your example is different from ours. You are using IAsyncEnumerable and yield, and this is what we showed. Without that, if you use just simple IEnumerable, ASP.NET Core will buffer the results. This is the sentence from the article: “ASP.NET Core buffers the result of the action endpoint that returns IEnumerable<T> before writing them into the response.“
You’re correct. Looks I just missed this part:
“With
IAsyncEnumerable<T>andawait foreachsyntax, the action will return each element as it arrives. “