Problem Details for ASP.NET Core APIs: A Beginner's Guide
Learn Problem Details in ASP.NET Core step by step. Give your API one clean error format with RFC 9457, AddProblemDetails, and IExceptionHandler in .NET 10.
A hospital with one token system
Imagine you visit a big government hospital in your city. Many things can go wrong on a busy day. The doctor you wanted is on leave. Your file is missing. The lab is closed for lunch. The medicine is out of stock.
Now picture two kinds of hospitals.
In the first hospital, every counter tells you bad news in its own way. One clerk writes a note on a torn paper. Another just waves you off. A third shouts a number you cannot hear. You leave confused, holding nothing useful.
In the second hospital, every counter uses the same printed slip. The slip always has the same boxes: what went wrong, how serious it is, a short detail, and a token number you can quote later. It does not matter which counter gave it to you. The slip always looks the same. You can read it, keep it, and follow up.
Problem Details is that same printed slip, but for your API. When something goes wrong, your API should not answer in a different messy way each time. It should answer with one standard error shape that every client can read. In this article we will learn what that shape is, why it exists, and how to switch it on in ASP.NET Core with very little code.
What is Problem Details?
Problem Details is a standard format for HTTP error responses. It is written down in a rule book called RFC 9457. This rule book replaced an older one called RFC 7807 in 2023. The good news is that the shape stayed the same, so you do not need to relearn anything.
The idea is simple. When your API returns an error, it returns a small JSON body with a fixed set of fields. Here is what a Problem Details response looks like:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Product with id 42 was not found.",
"instance": "/api/products/42",
"traceId": "00-3f6a...-01"
}Each field has a clear job. Let us look at them in a table.
| Field | What it means | Example |
|---|---|---|
type | A URI that names the kind of problem | https://httpstatuses.io/404 |
title | A short, human label for the problem | Not Found |
status | The HTTP status code, repeated in the body | 404 |
detail | A specific message about this one error | Product 42 was not found. |
instance | The path where the error happened | /api/products/42 |
The first five fields are the official ones. You are also allowed to add your own extra fields. Those extra fields are called extension members. A very common one is traceId, which links the error to a log line on your server.
Why bother with a standard?
You might think, "I can just return any error I like." You can. But mixing formats causes real pain. Let us see the difference clearly.
When every endpoint returns a different shape, the client team has to write special code for each one. One endpoint calls the message msg. Another calls it error. A third returns plain text. This is slow to build and easy to break.
Now look at the standard way.
One shape. One piece of client code. Every error fits the same boxes. This is the whole point of a standard, and it is why ASP.NET Core gives you Problem Details out of the box.
Turning it on in ASP.NET Core
ASP.NET Core has built-in support for Problem Details. You switch it on with one line, then add a little middleware. Here is the smallest useful setup in Program.cs.
var builder = WebApplication.CreateBuilder(args);
// 1. Register the Problem Details service.
builder.Services.AddProblemDetails();
builder.Services.AddControllers();
var app = builder.Build();
// 2. Catch unhandled exceptions and turn them into Problem Details.
app.UseExceptionHandler();
// 3. Turn bare error status codes (like 404) into Problem Details too.
app.UseStatusCodePages();
app.MapControllers();
app.Run();Let us read these three steps slowly, because each one does a different job.
AddProblemDetails() registers the default IProblemDetailsService. This is the service that knows how to build the standard JSON shape. Without it, the other steps have nothing to write with.
UseExceptionHandler() with no arguments hooks into that service. When any code throws an exception that nobody caught, this middleware steps in and writes a Problem Details response instead of a raw stack trace.
UseStatusCodePages() handles a different case. Sometimes there is no exception, but the response is an empty error, like a bare 404 with no body. This middleware fills that empty body with a Problem Details object.
One important rule: put UseExceptionHandler() near the top of your pipeline. Middleware only catches errors from the steps that run after it. If you place it last, it will catch almost nothing.
How a request flows through it
Let us trace what happens when something goes wrong. The frames below walk through the happy path and the error path side by side.
Successful request
Steps
Request
Client calls the API
Endpoint
Code runs with no error
200 OK
Normal JSON is returned
Failed request
Steps
Request
Client calls the API
Throw
Code throws an exception
Handler
UseExceptionHandler catches it
Problem JSON
Standard error is returned
The key idea is that your normal code does not need to know about Problem Details at all. You just write your endpoint logic. When an error happens, the middleware at the top of the pipeline takes over and produces the standard shape for you.
Here is the same flow as a sequence, which makes the order of events clear.
Returning Problem Details on purpose
So far we covered errors that throw. But often you want to return an error on purpose, in a calm and controlled way. For example, the user asked for a product that does not exist. That is not a crash. It is a normal 404.
In a minimal API you can return Problem Details directly using TypedResults. This is clean and easy to test.
app.MapGet("/api/products/{id}", (int id, ProductService service) =>
{
Product? product = service.Find(id);
if (product is null)
{
// Return a standard 404 Problem Details.
return Results.Problem(
title: "Product not found",
detail: $"No product exists with id {id}.",
statusCode: StatusCodes.Status404NotFound);
}
return Results.Ok(product);
});In a controller you have a helper for the same job. The Problem() method and the ValidationProblem() method both produce the standard shape, so your controller errors match the rest of your API.
The nice part is that all of these paths produce the same JSON. Whether the error came from a throw, a bare status code, or an on-purpose return, the client always sees one shape.
Adding your own fields
The five official fields are useful, but real apps usually want more. The most common extra field is a traceId. This is a small id that ties the error the user saw to the exact log line on your server. When a user calls support and reads out the id, you can find the problem in seconds.
You can add extra fields in one place for the whole app using the CustomizeProblemDetails callback. This callback runs for every Problem Details response before it is sent.
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
// Add a trace id so support can find the log line.
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
// Add the time the error happened.
context.ProblemDetails.Extensions["timestamp"] =
DateTimeOffset.UtcNow;
// Make sure the instance is always set.
context.ProblemDetails.Instance ??=
context.HttpContext.Request.Path;
};
});Now every error in your whole app carries a traceId and a timestamp, with no extra work in any endpoint. This is the power of doing it in one place.
A quick word on what to put in detail. Keep it friendly and safe. Never put a raw stack trace, a database password, or an internal file path in detail. Remember the hospital slip. It tells the patient enough to act, but it does not hand over the hospital's private records.
Mapping exception types to status codes
Often you want different exceptions to become different status codes. A "not found" exception should be a 404. A "bad input" exception should be a 400. A custom exception handler is a clean place to do this mapping. It implements IExceptionHandler, which arrived in .NET 8.
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
public GlobalExceptionHandler(IProblemDetailsService problemDetailsService)
{
_problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
int status = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
};
httpContext.Response.StatusCode = status;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails =
{
Title = "An error occurred",
Detail = exception.Message,
Status = status
}
});
}
}Register it next to AddProblemDetails, like this.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();Now the middleware calls your handler whenever an exception bubbles up. Your handler picks the right status code, fills the standard fields, and hands the job to the Problem Details service. This keeps your endpoint code clean. No endpoint needs a try/catch block just for shaping errors.
How the pieces fit together
It helps to see all the moving parts as one picture. The state diagram below shows how a request moves between normal flow and error flow.
And here is a quick comparison of the three ways to produce Problem Details, so you can pick the right tool.
| Approach | Best for | Where it lives |
|---|---|---|
Results.Problem(...) | On-purpose errors in minimal APIs | Inside an endpoint |
ControllerBase.Problem(...) | On-purpose errors in controllers | Inside a controller action |
IExceptionHandler + AddProblemDetails | Unhandled exceptions, app-wide rules | One handler class |
For most apps you use a mix. You return Results.Problem or ControllerBase.Problem for expected errors, and you keep a global IExceptionHandler as the safety net for anything unexpected.
Validation errors come for free
There is one happy bonus. When you use controllers with model binding, ASP.NET Core already returns validation errors in the Problem Details format. If a user posts a body that fails a [Required] rule, the framework sends a 400 with a special kind of Problem Details called a validation problem. It includes an errors object that lists each field and what was wrong with it.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["The Name field is required."],
"Price": ["The Price must be greater than 0."]
}
}This means a lot of your error responses are already in the right shape without you writing anything. You mostly add Problem Details on top for the cases the framework does not handle on its own, like unhandled exceptions and on-purpose business errors.
A note on related libraries
When teams build error handling, they sometimes reach for messaging or mediator libraries to organize their request flow. Two well-known names here are MediatR and MassTransit. Both of these are now under a commercial license for many uses. They are still good tools, but you should check their current license and pricing before adding them to a paid product. The good news is that Problem Details itself needs none of them. It is built into ASP.NET Core, so you can ship clean, standard errors with just the framework.
Common mistakes to avoid
A few small slips trip up beginners. Watch for these.
Do not place UseExceptionHandler at the bottom of the pipeline. It only catches errors from steps that run after it, so it must sit near the top.
Do not leak secrets in detail. Keep messages safe for the public. In development you may show more, but in production keep it short and clean.
Do not forget UseStatusCodePages if you want bare status codes like 404 to also return a body. Without it, an empty 404 stays empty.
Do not return different shapes from different endpoints. The whole value of Problem Details disappears the moment one endpoint returns its own custom error object.
Quick recap
- Problem Details is a standard error shape for APIs, defined in RFC 9457 (which replaced RFC 7807).
- It has five core fields:
type,title,status,detail, andinstance, plus optional extra fields called extension members. - Turn it on with
AddProblemDetails(), thenUseExceptionHandler()andUseStatusCodePages()inProgram.cs. - Put
UseExceptionHandler()near the top of the pipeline so it can catch errors from later steps. - Return errors on purpose with
Results.Problem(...)in minimal APIs orProblem(...)in controllers. - Use
IExceptionHandlerwithAddExceptionHandlerto map exception types to status codes in one clean place. - Add app-wide fields like
traceIdusing theCustomizeProblemDetailscallback. - Controllers already return validation errors as Problem Details, so a lot of it is free.
- Never put secrets or stack traces in
detail.
References and further reading
- Handle errors in ASP.NET Core APIs — Microsoft Learn
- RFC 9457 — Problem Details for HTTP APIs
- Problem Details for ASP.NET Core APIs — Milan Jovanović
- ProblemDetails in ASP.NET Core — codewithmukesh
Related Posts
Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers
Learn global error handling in ASP.NET Core step by step: from try-catch middleware to IExceptionHandler and Problem Details, with simple diagrams and clear code.
Best Practices for Building REST APIs in ASP.NET Core
A friendly, beginner guide to REST API best practices in ASP.NET Core with naming, status codes, validation, ProblemDetails, paging, versioning, security, and code.
90% of APIs Are Not RESTful: What You're Missing and When It Matters
Most APIs called RESTful are really Level 2. Learn what real REST means, the Richardson Maturity Model, HATEOAS in ASP.NET Core, and when it matters.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.
The 5 Most Common REST API Design Mistakes (and How to Avoid Them)
A warm beginner guide to the 5 most common REST API design mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# code examples.