
Problem Details ASP.NET Core – How to Return Standardized API Error Responses
If you’ve worked on more than one ASP.NET Core API, you’ve probably seen this pattern: one endpoint returns a plain error string, another throws back a raw exception message, and a third just sends an empty 404 with no explanation at all. Every consumer of your API ends up writing custom logic just to figure out what went wrong — and that logic breaks the moment your error format changes.
This is exactly the problem that Problem Details was built to solve. It gives ASP.NET Core APIs a single, predictable shape for error responses, so clients always know where to look for the status, the title, and the details of a failure — no guessing required.
In this article, we’ll break down what Problem Details actually is, how to wire it up in an ASP.NET Core project, and how to return and customize it across both Minimal APIs and controller-based APIs.
What Is Problem Details in ASP.NET Core?
Problem Details is a standardized, machine-readable format for describing errors in HTTP APIs. It’s formally defined by RFC 9457, which replaced the earlier RFC 7807, and it’s typically served with the application/problem+json content type.
Instead of every team inventing its own error JSON structure, Problem Details gives you a shared contract that any client — a frontend app, a mobile client, or another backend service — can parse in a predictable way.
A typical Problem Details response looks like this:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Order with id 5821 was not found.",
"instance": "/orders/5821"
}The standard fields break down as follows:
- type – a URI identifying the category of the problem
- title – a short, human-readable summary of the error
- status – the HTTP status code for this occurrence
- detail – a more specific, human-readable explanation
- instance – a URI pointing to the specific request that triggered the error
Beyond these five fields, Problem Details also supports extensions — custom key-value pairs you can add for things like a trace ID, an internal error code, or validation-specific data.
Note: ASP.NET Core has supported Problem Details natively since .NET 7, so there’s no need to install a third-party NuGet package to get RFC-compliant error responses. It’s built into the framework via
IProblemDetailsService.
What Is RFC 9457?
RFC 9457 is an IETF standard, published in 2023, that defines the “Problem Details” JSON object for HTTP APIs. It replaced the older RFC 7807 and describes a common, machine-readable structure — type, title, status, detail, and instance — that any HTTP API can use to represent an error consistently, regardless of the framework or language behind it.
Because it’s an open, framework-agnostic standard, RFC 9457 is what lets a .NET API, a Node.js API, and a Java API all return errors that a shared frontend or gateway can parse with the exact same logic.
Setting Up Problem Details in Your Project
Getting Problem Details working requires just one service registration and two middleware calls:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Run();Here’s what each piece is doing:
- AddProblemDetails() registers the default
IProblemDetailsService, which ASP.NET Core uses internally to serialize RFC-compliant error bodies. - UseExceptionHandler() intercepts unhandled exceptions and converts them into a Problem Details response instead of letting a raw stack trace or a generic 500 page leak out.
- UseStatusCodePages() catches responses that have a client or server error status code but no body — like a bare
404— and fills them in with a Problem Details payload.
With these three lines in place, your API already has a consistent baseline for error handling, even before you write any custom logic.
Returning Problem Details from Your Endpoints
For errors you expect — a missing resource, an invalid request — you should return Problem Details explicitly rather than relying on the global handler.
In Minimal APIs, use Results.Problem (or TypedResults.Problem for the strongly-typed version):
app.MapGet("/orders/{id:int}", async (int id, AppDbContext db) =>
{
var order = await db.Orders.FindAsync(id);
return order is null
? Results.Problem(
title: "Order not found",
detail: $"Order with id {id} was not found.",
statusCode: StatusCodes.Status404NotFound)
: Results.Ok(order);
});For validation errors specifically, Results.ValidationProblem is the better fit — it returns a ValidationProblemDetails object that includes a structured errors dictionary:
app.MapPost("/orders", (CreateOrderRequest request) =>
{
if (request.Quantity <= 0)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Quantity"] = ["Quantity must be greater than zero."]
});
}
return Results.Ok();
});In controller-based APIs, ControllerBase exposes the equivalent helpers:
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var order = await _db.Orders.FindAsync(id);
return order is null
? Problem(
title: "Order not found",
detail: $"Order with id {id} was not found.",
statusCode: StatusCodes.Status404NotFound)
: Ok(order);
}The rule of thumb: expected failures get handled right at the endpoint, while unexpected exceptions should flow through a centralized handler — which we’ll cover shortly.
Customizing Problem Details Responses
The default five fields won’t always be enough. Most production APIs want to attach something like a trace ID or an internal error code so support teams can correlate a client-reported error with server logs.
ASP.NET Core exposes a CustomizeProblemDetails delegate for exactly this:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});Once configured, every Problem Details response generated by the framework — whether from an unhandled exception or a status code page — automatically includes the extra field.
You can also attach extensions manually on a per-response basis:
return Results.Problem(new ProblemDetails
{
Title = "Order not found",
Detail = $"Order with id {id} was not found.",
Status = StatusCodes.Status404NotFound,
Extensions =
{
["errorCode"] = "order.not_found"
}
});This keeps you inside the RFC-compliant contract while still giving clients any extra context they need to handle the error programmatically.
Handling Unexpected Exceptions Globally
Expected errors belong in your endpoints. Unexpected ones — a null reference, a database timeout, a bug you haven’t caught yet — should be handled in exactly one place, so you’re not duplicating error-formatting logic across every controller and endpoint.
ASP.NET Core’s IExceptionHandler interface, combined with IProblemDetailsService, gives you a clean way to do this:
public sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception occurred");
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Title = "Server Error",
Detail = "An unexpected error occurred while processing your request.",
Status = StatusCodes.Status500InternalServerError
}
});
}
}Register it alongside AddProblemDetails, and keep UseExceptionHandler in your middleware pipeline:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();Because this approach goes through IProblemDetailsService, any customizations you set up with CustomizeProblemDetails — like that trace ID extension — still get applied automatically, keeping every error response in your API consistent, whether it came from an explicit Results.Problem call or an unhandled exception.
Problem Details vs. Generic Error Responses
| Aspect | Without Problem Details | With Problem Details |
|---|---|---|
| Response shape | Varies per endpoint (string, object, empty body) | Consistent JSON structure across the whole API |
| Standard | Custom / undocumented | RFC 9457 compliant |
| Client parsing | Requires per-endpoint handling logic | One shared parser works everywhere |
| Debugging | Often just a status code, no context | Includes title, detail, and instance for context |
| Extensibility | Ad hoc, inconsistent | Structured extensions (trace ID, error code, etc.) |
| Setup effort | None, but technical debt accumulates | Minimal — built into the framework |
Real-World Example: Before and After
Before: An order-processing API returns different shapes depending on the failure. A missing order returns 404 with an empty body. A validation failure returns 400 with a plain string like "Quantity is required". An unhandled exception in production leaks a full stack trace to the client. The frontend team ends up writing three separate error-handling branches, and any change to the error format on the backend silently breaks the UI.
After: The same API is wired up with AddProblemDetails, UseExceptionHandler, and a GlobalExceptionHandler. Now every failure — missing order, invalid quantity, or an unexpected database error — comes back as application/problem+json with a title, status, detail, and a traceId extension. The frontend writes a single error-handling function that reads problem.title and problem.detail, and support engineers can grep logs for the traceId a user reports. Error handling goes from “different for every endpoint” to “the same everywhere.”
Summary
Consistent error handling is one of those things that seems minor until an API grows past a handful of endpoints — and then it becomes one of the biggest sources of friction for anyone consuming your API. Problem Details solves this by giving ASP.NET Core a standard, RFC-compliant shape for every error response, from validation failures you catch explicitly to exceptions you never saw coming.
Once you enable it with AddProblemDetails, wire up exception handling, and add any custom extensions your team needs, every error your API returns follows the same predictable contract — making your API easier to consume, easier to debug, and easier to evolve over time.
