Skip to main content

ASP.NET Core Global Error Handling: Complete Guide 2026

Learn ASP.NET Core global error handling with middleware, Problem Details, and logging. Master exception handling best practices with runnable C# examples today.

ASP.NET Core global error handling is the single most important thing you can add to a production API, yet it is the piece most tutorials skip. When an unhandled exception bubbles up in a poorly configured app, your users see an ugly stack trace, your logs are noisy and inconsistent, and attackers learn far too much about your internals. In this guide you'll learn how to implement global error handling in ASP.NET Core the right way — using centralized exception handling middleware, the RFC 7807 Problem Details standard, and structured logging — with practical, runnable C# examples you can drop straight into your project.

Whether you are a beginner searching "how to handle exceptions in ASP.NET Core", an intermediate developer looking for best practices, or a senior engineer wiring up advanced error handling in C#, this article covers the full picture for .NET 8 and .NET 9.

Why You Need Global Error Handling in ASP.NET Core

Without a centralized strategy, exception handling gets scattered across every controller and service. You end up with try/catch blocks duplicated everywhere, inconsistent error responses, and the constant risk that one forgotten catch leaks a raw 500 Internal Server Error with a full stack trace. Global error handling solves this by catching every unhandled exception in one place, before the response leaves your application.

The benefits are concrete:

  • Consistency — every error returns the same predictable JSON shape, which your frontend and API consumers can rely on.
  • Security — you never leak stack traces or internal details in production.
  • Observability — every exception is logged once, with context, in a structured format your log aggregator can query.
  • Maintainability — your controllers stay focused on business logic instead of drowning in try/catch noise.

The Problem Details Standard (RFC 7807)

Before writing any middleware, decide what an error response should look like. The modern answer for ASP.NET Core is Problem Details, defined by RFC 7807 (and its update RFC 9457). It's a machine-readable JSON format for HTTP API errors that every serious .NET API should adopt.

A Problem Details response looks like this:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "The 'email' field is required.",
  "instance": "/api/users",
  "traceId": "00-abc123..."
}

To enable it globally, register the Problem Details service in Program.cs. This makes ASP.NET Core produce Problem Details automatically for status-code errors and gives you a factory you can reuse in your own middleware.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails(options =>
{
    // Enrich every problem response with useful diagnostic context.
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Instance =
            $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";

        context.ProblemDetails.Extensions.TryAdd(
            "traceId", context.HttpContext.TraceIdentifier);
    };
});

builder.Services.AddControllers();

var app = builder.Build();

The Modern Way: IExceptionHandler (.NET 8+)

Since .NET 8, the recommended approach for global exception handling in ASP.NET Core is the IExceptionHandler interface combined with the built-in exception handler middleware. This is cleaner than writing raw middleware and integrates naturally with dependency injection and Problem Details.

Start by creating a global exception handler that logs the error and writes a Problem Details response:

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;

public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(
        IProblemDetailsService problemDetailsService,
        ILogger<GlobalExceptionHandler> logger)
    {
        _problemDetailsService = problemDetailsService;
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        // Log once, with structured context, at the boundary.
        _logger.LogError(
            exception,
            "Unhandled exception for {Method} {Path}",
            httpContext.Request.Method,
            httpContext.Request.Path);

        var (status, title) = MapException(exception);
        httpContext.Response.StatusCode = status;

        return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = status,
                Title = title,
                Type = $"https://httpstatuses.io/{status}"
            }
        });
    }

    private static (int Status, string Title) MapException(Exception exception) => exception switch
    {
        ValidationException => (StatusCodes.Status400BadRequest, "Validation failed"),
        KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
        UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
        _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
    };
}

Now wire it up in Program.cs. Two lines register the handler and enable the middleware pipeline:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

// This middleware invokes every registered IExceptionHandler in order.
app.UseExceptionHandler();

app.MapControllers();
app.Run();

Why this design wins: the MapException switch translates domain exceptions into the correct HTTP status codes, so you can throw new KeyNotFoundException() deep in a service and trust the boundary to turn it into a clean 404. Your business logic throws meaningful exceptions; the handler owns the HTTP translation.

Chaining Multiple Exception Handlers

You can register more than one IExceptionHandler. The middleware calls each in order until one returns true. This lets you build focused handlers — for example, one dedicated to validation errors and a catch-all for everything else.

// Order matters: the validation handler runs first,
// and the catch-all handles anything it doesn't claim.
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

A handler that returns false passes the exception along to the next handler; if none handle it, the framework falls back to its default behavior.

The Classic Approach: Custom Exception Handling Middleware

Before .NET 8, most developers wrote a custom exception handling middleware class. It's still worth knowing — you'll see it in countless codebases, and it gives you full control over the pipeline. The pattern wraps the request in a try/catch:

public sealed class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception on {Path}", context.Request.Path);
            await WriteProblemDetailsAsync(context, ex);
        }
    }

    private static async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
    {
        var status = ex switch
        {
            KeyNotFoundException => StatusCodes.Status404NotFound,
            UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
            _ => StatusCodes.Status500InternalServerError
        };

        var problem = new ProblemDetails
        {
            Status = status,
            Title = "An error occurred while processing your request.",
            Instance = context.Request.Path
        };

        context.Response.StatusCode = status;
        context.Response.ContentType = "application/problem+json";
        await context.Response.WriteAsJsonAsync(problem);
    }
}

Register it as the first middleware in the pipeline so it wraps everything downstream:

// Must be early so it catches exceptions from all later middleware.
app.UseMiddleware<ExceptionHandlingMiddleware>();

For new projects on .NET 8 or 9, prefer IExceptionHandler. Reach for custom middleware only when you need behavior the built-in pipeline doesn't support.

Structured Logging for Exceptions

Logging is half the value of global error handling. The goal is to log each exception exactly once, at the boundary, with enough structured context to diagnose it later. Two rules matter most:

  • Pass the exception as the first argument to LogError — never ex.Message as a string. The logger captures the full stack trace and inner exceptions only when you pass the exception object itself.
  • Use message templates, not interpolation. Write LogError(ex, "Failed for user {UserId}", id) so structured properties reach Serilog, Seq, or Application Insights as queryable fields.
// Good: structured, queryable, full stack trace preserved.
_logger.LogError(ex, "Order {OrderId} failed for {UserId}", orderId, userId);

// Bad: loses the stack trace and produces unqueryable text.
_logger.LogError($"Order {orderId} failed: {ex.Message}");

Correlate logs with responses by including the TraceId in both. When a user reports "error at 3pm", you can search your logs for that exact trace ID and find the full exception instantly. This single practice will save you hours of debugging in production.

Best Practices and Common Pitfalls

Getting ASP.NET Core error handling right is as much about what you avoid as what you add. Here are the practices that separate robust APIs from fragile ones.

  • Never expose stack traces in production. Use app.UseExceptionHandler() in production and only show developer details when app.Environment.IsDevelopment(). Leaking internals is a genuine security risk.
  • Don't catch and swallow exceptions locally unless you can meaningfully recover. Let them bubble up to the global handler. Duplicate try/catch blocks defeat the entire purpose of centralized handling.
  • Place exception handling middleware first. If it runs after other middleware, exceptions thrown earlier in the pipeline escape it entirely.
  • Don't log the same exception twice. If you log in a service and again at the boundary, you double your log volume and confuse investigations. Log once, at the top.
  • Map domain exceptions to status codes. A 404 for a missing record and a 500 for a real fault are very different signals for clients and monitoring alike.
  • Return application/problem+json as the content type so clients know they're receiving a Problem Details payload.
  • Add a correlation/trace ID to every error response for support and debugging.

Handling Validation Errors Separately

Validation failures aren't really "errors" in the exceptional sense — they're expected outcomes of bad input. The ValidationProblemDetails type is purpose-built for these, returning a 400 with a per-field error dictionary. Keeping validation distinct from your catch-all handler produces far more useful responses for API consumers building forms against your endpoints.

Conclusion: Key Takeaways

ASP.NET Core global error handling transforms a fragile API into a production-ready one. By centralizing exception handling with IExceptionHandler, standardizing responses with Problem Details, and logging every fault once with structured context, you gain consistency, security, and observability in one coherent design.

Remember these key points:

  • Use IExceptionHandler with AddExceptionHandler and UseExceptionHandler for global error handling on .NET 8 and 9.
  • Adopt Problem Details (RFC 7807/9457) so every error returns a consistent, machine-readable shape.
  • Map domain exceptions to the correct HTTP status codes at the boundary, not in your controllers.
  • Log exceptions once, with the exception object and structured message templates, and correlate them with a trace ID.
  • Never leak stack traces in production, and keep validation errors separate from unexpected faults.

Implement this pattern once and every future endpoint inherits clean, secure, well-logged error handling for free. Start by adding AddProblemDetails() and a single GlobalExceptionHandler to your project today — your future self, and everyone consuming your API, will thank you.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...