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

Status Codes and Responses in ASP.NET Core: Ok, CreatedAtAction and ProblemDetails

Mert Özen Nov 4, 2026 8 min 2 views
Status Codes and Responses in ASP.NET Core: Ok, CreatedAtAction and ProblemDetails

Returning the right status code with ControllerBase helpers, the surprise 204 on null, CreatedAtAction and the Location header, choosing between 400/404/409, ProblemDetails and content negotiation, with real responses.

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

Guess First

There are three actions in the same ProductsController. All three are called for a product that does not exist, with id set to 99:

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

    return Ok(product);
}

[HttpGet("{id:int}/text-404")]
public ActionResult<Product> GetWithMessage(int id)
{
    return NotFound($"Product {id} was not found.");
}

[HttpGet("{id:int}/null")]
public ActionResult<Product> GetNull(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    return product!;
}

Guess two things for each one: what is the status code, and what is the response's Content-Type? The third one in particular surprises most people.

Reading the Status Code from the Code

In the previous post we derived our controllers from ControllerBase. The part of that class you will use most is its set of helper methods, each standing for a specific status code. Ok() produces 200, CreatedAtAction() 201, NoContent() 204, BadRequest() 400, NotFound() 404 and Conflict() 409. If you need a code that has no named helper, you use the general one, like StatusCode(503).

The nice thing about these helpers is that they make the reader think in HTTP. The line return NotFound(); says the same thing to the compiler and to whoever opens this file six months from now. StatusCode(404) does the same job but does not state the intent as clearly.

Most helpers come in two forms. NotFound() returns a result without a body, while NotFound(value) writes the value you pass into the body. The answer to the first two guesses lies exactly in that difference:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404,"traceId":"00-92e83b6bd36040b7249171e380f03441-6681a330e27d0cdc-00"}

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8

Product 99 was not found.

[ApiController] caught the bodiless NotFound() and turned it into ProblemDetails. When you passed a string, the framework left your body alone and wrote the string as is, as text/plain. From the client's point of view these two responses are very different: one is JSON following the same schema as every other error, the other is a sentence it has to parse.

200 or 204?

Here is the real response from the third action:

HTTP/1.1 204 No Content
Content-Length: 0

You expected 404, didn't you? When null is converted to ActionResult<Product>, the framework prepares a 200 with a JSON body. But HttpNoContentOutputFormatter, which kicks in while the response is being written, sees that the value is null, changes the status code to 204 and writes no body. The client reads "success, nothing to show", while you meant "no such product". The rule is simple: when a resource is not found, always say so explicitly with NotFound().

204 belongs to cases where the operation succeeded but there is nothing to send back. That is why the PUT and DELETE actions in the previous post returned NoContent(). If you prefer to return the updated resource, Ok(existing) is a valid choice for PUT as well; what matters is following the same rule across the whole API.

201 Created and the Location Header

Returning 201 when you create a new resource tells the client two things: the record was created, and this is where it lives. The second piece of information travels in the Location header:

[HttpPost]
public ActionResult<Product> Create(Product product)
{
    if (Products.Any(p => p.Name.Equals(product.Name, StringComparison.OrdinalIgnoreCase)))
    {
        return Conflict();
    }

    product.Id = Products.Max(p => p.Id) + 1;
    Products.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890}

CreatedAtAction takes three parameters. The first is the name of the action that reads the new resource. The second is the set of values needed to fill that action's route; here, id for the {id:int} segment. The third is the object to write into the body. The framework uses the routing information to generate the URL of GetById in reverse and puts it into Location. You never build the address by concatenating strings, and when you change the route, the header follows automatically.

It has two siblings that do the same job. CreatedAtRoute uses a route name you assign, such as [HttpGet("{id:int}", Name = "GetProductById")], instead of an action name. Created(uri, value) expects you to supply the address yourself; it is for the rare cases where you point to a resource in another service.

The 4xx Family: BadRequest, NotFound, Conflict

If you try to add a second product with the same name through the Create above, you get this:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.8","title":"Conflict","status":409,"traceId":"00-7202d8f82a1e4c2ea5ecd4cb22901cdb-8bab389715b77eef-00"}

When choosing between 4xx codes, ask yourself one question: where is the problem? If the request itself is broken, meaning the JSON cannot be parsed or a required field is missing, it is 400. If the requested resource does not exist, 404. If the request is fine and the resource exists but the request clashes with the server's current state, 409. A product with the same name, cancelling an order that is already cancelled, trying to overwrite an outdated version: these are typical 409 cases.

Think of a post office. No address on the envelope, the parcel comes back: 400. The address is written but the street does not exist: 404. The street and the house both exist, but the owner already received the same parcel yesterday: 409.

ProblemDetails: A Common Language for Errors

Every error body you have seen so far follows the same schema. That schema is the ProblemDetails format defined in RFC 7807. type is a URI describing the kind of error, title a short heading, status the status code itself, detail an explanation specific to this occurrence, and instance the resource where the error happened. traceId is a field ASP.NET Core adds on top of the standard; it lets you find this request in the logs.

The default body has no detail. If you want one, there is the Problem() helper:

[HttpGet("{id:int}/problem-404")]
public ActionResult<Product> GetWithProblem(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return Problem(
            title: "Product not found",
            detail: $"There is no product with id {id}.",
            statusCode: StatusCodes.Status404NotFound);
    }

    return product;
}
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Product not found","status":404,"detail":"There is no product with id 99.","traceId":"00-524a0585737aed691a3c16fabf7bc562-20d46596e41a980c-00"}

You gave the client a readable message and kept the schema intact. This is the path to prefer over NotFound("...") with a string body.

If you wonder how [ApiController] turns bodiless results into ProblemDetails, the answer is the ApiBehaviorOptions.ClientErrorMapping dictionary. On .NET 6 it holds ready-made title and type values for 400, 401, 403, 404, 406, 409, 415, 422 and 500. Look at what happens when you return a code that is not on the list:

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json; charset=utf-8

{"status":503,"traceId":"00-0c4670cff98695f81c7be03511b9582c-1d60162a5355e679-00"}

StatusCode(StatusCodes.Status503ServiceUnavailable) still produced ProblemDetails, but type and title stayed empty. You can add the missing code to the dictionary yourself:

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.ClientErrorMapping[StatusCodes.Status503ServiceUnavailable] = new ClientErrorData
        {
            Title = "Service Unavailable",
            Link = "https://tools.ietf.org/html/rfc7231#section-6.6.4"
        };
    });

With this setting the same request comes back with "type":"https://tools.ietf.org/html/rfc7231#section-6.6.4","title":"Service Unavailable". On .NET 10 the same code returns a filled-in title even without the setting, and the type addresses have moved to RFC 9110; we will see how ProblemDetails evolved in later versions in the version posts. If you want to switch this conversion off entirely, there is options.SuppressMapClientErrors = true, but you need a good reason to give up a shared error schema.

Content Negotiation in Brief

The client says which format it wants with the Accept header. This negotiation is called content negotiation. Let's start with the default behavior: even if you send Accept: application/xml, the response is 200 OK with application/json. The framework cannot write XML, so it quietly falls back to the first formatter it has, which is JSON.

Two settings change this behavior:

builder.Services.AddControllers(options =>
{
    // If the requested format is not supported, don't fall back to JSON; return 406
    options.ReturnHttpNotAcceptable = true;
})
.AddXmlSerializerFormatters();

With only ReturnHttpNotAcceptable on, the same request returns 406 Not Acceptable. Add AddXmlSerializerFormatters() as well and the API starts speaking XML:

HTTP/1.1 200 OK
Content-Length: 179
Content-Type: application/xml; charset=utf-8

<Product xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><Id>1</Id><Name>Mechanical Keyboard</Name><Price>1450</Price></Product>

Error responses follow the format too; this time the 404 arrives as application/problem+xml. Most modern APIs speak only JSON, and that is perfectly reasonable. Still, when you run into an old integration that expects XML, it helps to know you can add a format without touching a single line of controller code.

Common Pitfalls

The sneakiest one is CreatedAtAction in async actions. We will talk about async in its own post, but learn this trap now:

[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetByIdAsync(int id)
{
    await Task.Delay(10);
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

[HttpPost]
public async Task<ActionResult<Product>> CreateAsync(Product product)
{
    await Task.Delay(10);
    product.Id = Products.Count + 1;
    Products.Add(product);
    return CreatedAtAction(nameof(GetByIdAsync), new { id = product.Id }, product);
}

The POST request returns 500 Internal Server Error and the log contains this line: System.InvalidOperationException: No route matches the supplied values. The reason is that ASP.NET Core strips the Async suffix from action names by default. To the framework the action is called GetById, while you are looking for GetByIdAsync. Worse, the exception is thrown while the response is being written; the product has already been added to the list. So when the client thinks it failed and retries, you end up with two records. The fix is to add [ActionName(nameof(GetByIdAsync))] to the GET action, or to set MvcOptions.SuppressAsyncSuffixInActionNames to false.

The second pitfall is returning 200 for everything and burying the error in the body: {"success":false,"error":"not found"}. Every client of such an API has to ignore the status code and parse the body, and caches and monitoring tools think everything succeeded. Status codes are HTTP's own error language; use it.

Third, mixing formats in error bodies. If one action returns a string via NotFound("...") and another returns ProblemDetails via NotFound(), the client has to write a separate error parser per endpoint. If you want to give a message, use Problem().

A Small Experiment

★☆☆ Write GetById as return product!;, request an id that does not exist and see the 204 with your own eyes. Then go back to NotFound().

★★☆ Replace the Conflict() in the Create action with a Problem() call that names the conflicting product in the detail field. Keep the status code at 409.

★★★ Through ClientErrorMapping, change the Link for 404 to your own documentation address, then add AddXmlSerializerFormatters() and request the same 404 with both Accept: application/json and Accept: application/xml. Confirm that your address shows up in both responses.

In this post we talked about what we write into the body. But how does the data the client sends reach the action parameters? We said [ApiController] infers that id comes from the route and product from the body; in the next post we look at the rules behind that inference, reading data from the query string, headers and forms, and binding errors.