Skip to main content

How to Return Problem Details Errors in ASP.NET Core

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

Out of the box, an ASP.NET Core API's errors are all different: an empty 404, a stack trace as plain text in Development, and a blank 500 in Production. Problem details is the standard JSON format for errors, and three lines in Program.cs give every failure that one shape. This guide adds them, with the real responses before and after.

Prerequisites​

  • An ASP.NET Core API. The examples use a .NET 10 minimal API, without problem details to begin with.

Step 1: See the Errors Without It​

A category that doesn't exist. The body is empty, so a client gets nothing to show:

Response
HTTP/1.1 404 Not Found

An exception, in Development. It's plain text with the stack trace, which is useful to you and useless to a client:

Response (the first lines)
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8

System.InvalidOperationException: Sequence contains no elements.

Broken JSON, also in Development, is plain text too:

Response (the first lines)
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8

Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "CategoryRequest request" from the request body as JSON.

In Production, the same exception gives a client nothing at all to go on:

Response (the app running in Production)
HTTP/1.1 500 Internal Server Error

Step 2: Register the Service​

src/Dewiride.Inventory.Api/Program.cs (excerpt)
// Answers every error with the same JSON shape, the standard "problem details" format.
builder.Services.AddProblemDetails();

ASP.NET Core's error handling page says this "registers the default IProblemDetailsService implementation", and lists the middleware that then produces the format. It does nothing without that middleware. With only this line, the same exception in Production still answers an empty 500:

Response (AddProblemDetails, and no middleware yet)
HTTP/1.1 500 Internal Server Error

Step 3: Add the Two Middleware​

src/Dewiride.Inventory.Api/Program.cs (excerpt)
var app = builder.Build();

// Turn unhandled exceptions into a problem response, with no stack trace outside Development.
app.UseExceptionHandler(new ExceptionHandlerOptions
{
// In Development, a request the endpoint can't read, such as broken JSON, throws
// BadHttpRequestException. That's the client's mistake, so keep its 400 instead of a 500.
StatusCodeSelector = exception => exception is BadHttpRequestException badRequest
? badRequest.StatusCode
: StatusCodes.Status500InternalServerError,
});

// Give error responses without a body, such as a plain 404, a problem details body.
app.UseStatusCodePages();

The page describes each one: ExceptionHandlerMiddleware "Generates a problem details response when a custom handler is not defined", and StatusCodePagesMiddleware "Generates a problem details response by default". Put them first, so they wrap everything after them.

StatusCodeSelector is there for one reason. Without it, broken JSON in Development answers 500 instead of 400.

Step 4: See the Same Errors​

The missing category:

Response
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"traceId": "00-e1846e6eeb2c7155228ca0df363e1733-63ad741a1bb8d4ed-00"
}

A terminal comparing two responses: a 404 with an empty body before, and a 404 with application/problem+json and a JSON body with type, title, status and traceId after.

The exception, now with the app running in Production. There's no stack trace, and the traceId lets you find the matching line in the log:

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-c08d8d010cf9b9a6b5d6d90b1bcc73ce-cb1b2923ebe7bc6c-00"
}

Broken JSON:

Response
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"traceId": "00-4c2f4f24c6aabc5896b8928b2872ed5e-d47cf9ee69f97006-00"
}

Step 5: Write Your Own Errors​

For errors your code decides on, TypedResults.Problem uses the same format and adds your words:

src/Dewiride.Inventory.Api/Features/Categories/CategoryEndpoints.cs (excerpt)
return TypedResults.Problem(
title: "A category with this name already exists.",
detail: $"Choose a name other than '{name}'. Names that differ only in upper and lower case count as the same name.",
statusCode: StatusCodes.Status409Conflict);
Response
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "A category with this name already exists.",
"status": 409,
"detail": "Choose a name other than 'Cables'. Names that differ only in upper and lower case count as the same name.",
"traceId": "00-a799cbbcf3e0186618c7497522bb3aec-e3714fbd1c0dfb02-00"
}

Common Mistakes​

MistakeWhat happensDo this instead
AddProblemDetails with no middlewareErrors stay empty, as Step 2 showsAdd UseExceptionHandler and UseStatusCodePages
Adding UseExceptionHandler after other middlewareExceptions from the earlier middleware miss itAdd it right after Build()
Putting exception messages in detailInternal details reach clientsWrite your own text
A client whose Accept header leaves out JSONThe page says the format applies only when application/json is acceptedSend Accept: application/json, or */* as curl does

FAQ​

What's the format based on?​

RFC 9457, which replaced RFC 7807. The default type links point to the status code's section in RFC 9110, as you can see above.

Where does the validation error shape come from?​

AddValidation uses the same format, with an extra errors object: How to Validate Minimal API Requests in .NET 10.

Conclusion​

AddProblemDetails, UseExceptionHandler and UseStatusCodePages give every failure the same application/problem+json shape, with no stack trace and a traceId for the log. TypedResults.Problem adds your own errors to the same format, so clients handle them all with one piece of code.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.