
Learn C# exception handling best practices: custom exceptions, global handlers with IExceptionHandler, and structured logging in .NET. Read the guide now!
C# exception handling is one of the first things you learn in .NET and one of the last things you master. Most developers can write a try-catch block. Fewer can build a system where errors are caught in the right place, logged with enough context to fix them, and shown to users as safe, consistent responses. In this guide we cover C# exception handling best practices for .NET 8, 9 and 10: when to throw, when to catch, how to design custom exceptions, how to set up a global exception handler in ASP.NET Core, and how to log exceptions so they help you in production.
Whether you're a beginner looking up "try catch C#" or a senior engineer standardizing error handling across microservices, you'll find runnable code and the reasoning behind each recommendation.
What Is Exception Handling in C#?
An exception is an object derived from System.Exception that signals a method could not do what its name promises. When you throw one, the runtime unwinds the call stack looking for a matching catch block. If nothing catches it, the process (or the current request, in ASP.NET Core) fails.
The key idea is that exceptions are for exceptional conditions. They are not a replacement for if statements. Throwing is relatively expensive because the runtime captures a stack trace and unwinds frames. More importantly, using exceptions for normal control flow hides your program's real logic.
The Basics: try, catch, finally
using System;
using System.IO;
public static class ConfigReader
{
public static string ReadConfig(string path)
{
try
{
return File.ReadAllText(path);
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"Config file missing: {ex.FileName}");
return "{}"; // sensible default: recovery is possible here
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("No permission to read the config file.");
throw; // cannot recover, so rethrow and keep the stack trace
}
finally
{
Console.WriteLine("ReadConfig finished."); // always runs
}
}
}
Notice the pattern: catch specific exceptions, handle only the ones you can actually recover from, and rethrow the rest. Put catch blocks in order from most specific to least specific. The compiler reports an error if a general catch comes before a more specific one.
C# Exception Handling Best Practices Every Developer Should Follow
1. Use throw;, Never throw ex;
This is the most common mistake in C# code reviews. throw ex; resets the stack trace to the current line, so you lose the information about where the error actually started. throw; keeps the original stack trace.
try
{
ProcessOrder(order);
}
catch (Exception ex)
{
logger.LogError(ex, "Order processing failed");
// throw ex; // BAD: stack trace now points here
throw; // GOOD: original stack trace preserved
}
If you need to rethrow an exception later, for example after capturing it on another thread, use ExceptionDispatchInfo.Capture(ex).Throw() from System.Runtime.ExceptionServices.
2. Don't Catch Exception Unless You're at a Boundary
Catching System.Exception deep inside business logic swallows bugs such as NullReferenceException and InvalidOperationException that you should fix, not hide. Catch broad exceptions only at application boundaries: the global handler, a background worker's main loop, or a message consumer, where the goal is to log the error and keep the process alive.
3. Use Exception Filters with when
Exception filters let you catch an exception only when a condition is true. Unlike catching and rethrowing, a filter that returns false does not unwind the stack, so debuggers and crash dumps still show the original failure point.
using System.Net;
using System.Net.Http;
try
{
var response = await httpClient.GetStringAsync("https://api.example.com/data");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Only 404s land here; other HTTP errors propagate untouched
return null;
}
4. Use Guard Clauses and Built-In Throw Helpers
Validate arguments early with the static throw helpers added in recent .NET versions. They're shorter, and they fill in the parameter name for you through CallerArgumentExpression.
public void Transfer(Account from, Account to, decimal amount)
{
ArgumentNullException.ThrowIfNull(from); // .NET 6+
ArgumentNullException.ThrowIfNull(to);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); // .NET 8+
// ... transfer logic
}
5. Prefer the Try Pattern for Expected Failures
If failure is a normal outcome, such as a user typing "abc" into a number field, don't use exceptions. Use a TryXxx method instead:
// Avoid: exceptions as control flow
try { int age = int.Parse(input); }
catch (FormatException) { /* ... */ }
// Prefer
if (!int.TryParse(input, out int age))
{
Console.WriteLine("Please enter a valid number.");
}
6. Always Clean Up with using
Rather than writing finally blocks by hand to dispose resources, use using declarations. They're guaranteed to call Dispose() even when an exception is thrown.
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
// connection is disposed automatically, even on exceptions
How to Create a Custom Exception in C#
A C# custom exception is worth creating when callers need to react to a specific failure differently, or when you want to carry domain data such as an order ID or an error code. Don't create one per method. A small, meaningful hierarchy works best.
Designing a Custom Exception Hierarchy
using System;
// Base type for all domain errors in your application
public abstract class DomainException : Exception
{
public string ErrorCode { get; }
protected DomainException(string message, string errorCode, Exception? innerException = null)
: base(message, innerException)
{
ErrorCode = errorCode;
}
}
public sealed class OrderNotFoundException : DomainException
{
public Guid OrderId { get; }
public OrderNotFoundException(Guid orderId)
: base($"Order '{orderId}' was not found.", "ORDER_NOT_FOUND")
{
OrderId = orderId;
}
}
public sealed class InsufficientStockException : DomainException
{
public string Sku { get; }
public int Requested { get; }
public int Available { get; }
public InsufficientStockException(string sku, int requested, int available)
: base($"Insufficient stock for '{sku}': requested {requested}, available {available}.",
"INSUFFICIENT_STOCK")
{
Sku = sku;
Requested = requested;
Available = available;
}
}
Custom Exception Rules of Thumb
- End the name with "Exception", for example
PaymentDeclinedException. - Derive from
Exception(or your own base type), notApplicationException. Microsoft's guidelines sayApplicationExceptionadds no value. - Accept an
innerExceptionwhen you wrap lower-level errors, so the root cause is never lost. - Skip the binary serialization constructor. The
(SerializationInfo, StreamingContext)constructor is obsolete since .NET 8 (SYSLIB0051), andBinaryFormatteris gone. You don't need[Serializable]for modern apps. - Reuse built-in exceptions such as
ArgumentException,InvalidOperationExceptionorNotSupportedExceptionwhen they fit.
Wrapping Exceptions Without Losing Context
public async Task<Order> GetOrderAsync(Guid id)
{
try
{
return await _repository.LoadAsync(id)
?? throw new OrderNotFoundException(id);
}
catch (SqlException ex)
{
// Translate infrastructure errors into something meaningful,
// keeping the original as InnerException
throw new DataAccessException($"Failed to load order {id}.", ex);
}
}
Global Exception Handler in ASP.NET Core
Wrapping every controller action in try-catch repeats the same code everywhere and makes error responses inconsistent. Instead, handle unhandled exceptions once, globally. Since .NET 8, the recommended approach is the IExceptionHandler interface combined with the Problem Details standard (RFC 9457, which replaced RFC 7807).
Step 1: Implement IExceptionHandler
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IProblemDetailsService _problemDetailsService;
public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService)
{
_logger = logger;
_problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (status, title) = exception switch
{
OrderNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
InsufficientStockException => (StatusCodes.Status409Conflict, "Business rule violation"),
ArgumentException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
if (status >= 500)
_logger.LogError(exception, "Unhandled exception for {Method} {Path}",
httpContext.Request.Method, httpContext.Request.Path);
else
_logger.LogWarning(exception, "Handled domain exception: {Message}", exception.Message);
httpContext.Response.StatusCode = status;
var problem = new ProblemDetails
{
Status = status,
Title = title,
// Never leak internal details for 500s
Detail = status >= 500 ? null : exception.Message,
Instance = httpContext.Request.Path
};
if (exception is DomainException domainEx)
problem.Extensions["errorCode"] = domainEx.ErrorCode;
problem.Extensions["traceId"] = httpContext.TraceIdentifier;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problem,
Exception = exception
});
}
}
Step 2: Register It in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddControllers();
var app = builder.Build();
app.UseExceptionHandler(); // must come early in the pipeline
app.UseStatusCodePages(); // ProblemDetails for empty 4xx/5xx responses too
app.MapControllers();
app.MapGet("/orders/{id:guid}", (Guid id) =>
{
throw new OrderNotFoundException(id); // returns a clean 404 ProblemDetails
});
app.Run();
You can register several IExceptionHandler implementations. They run in registration order, and the first one that returns true ends the chain. This lets you keep a ValidationExceptionHandler separate from your catch-all handler.
Global Handlers for Console Apps and Worker Services
Outside ASP.NET Core, subscribe to the runtime's last-chance events so crashes always get logged:
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
var ex = (Exception)e.ExceptionObject;
Console.Error.WriteLine($"FATAL: {ex}");
// Flush your logger here; the process is about to terminate
};
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Console.Error.WriteLine($"Unobserved task exception: {e.Exception}");
e.SetObserved();
};
These events are for logging, not recovery. When UnhandledException fires, the process will still terminate.
Logging Exceptions in .NET the Right Way
An exception that isn't logged properly can't be fixed. Good .NET exception logging depends on three things: passing the exception object, using structured logging, and logging each exception once.
Pass the Exception as the First Argument
// BAD: loses stack trace and inner exceptions
_logger.LogError($"Payment failed: {ex.Message}");
// GOOD: structured, searchable, full stack trace attached
_logger.LogError(ex, "Payment failed for order {OrderId} and customer {CustomerId}",
orderId, customerId);
Use message templates with named placeholders, not string interpolation. Providers such as Serilog, OpenTelemetry, Application Insights and Seq store OrderId as a queryable field, so you can search for every failure related to one order in seconds.
High-Performance Logging with Source Generators
On hot paths, use the [LoggerMessage] source generator. It avoids boxing and template parsing on every call:
public static partial class LogMessages
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Error,
Message = "Failed to process payment for order {OrderId}")]
public static partial void PaymentFailed(this ILogger logger, Exception ex, Guid orderId);
}
// Usage
_logger.PaymentFailed(ex, orderId);
Avoid the "Log and Throw" Anti-Pattern
If every layer logs an exception and then rethrows it, one failure produces five identical log entries and your alerts become noise. The rule is simple: either handle it (and log), or let it propagate. Don't do both. Your global exception handler is the single place where unhandled exceptions get logged. Only log in lower layers when you're adding context that would otherwise be lost, and wrap the exception in that case instead of logging it.
Common Pitfalls in C# Exception Handling
- Empty catch blocks.
catch { }hides failures completely. If you truly must ignore an exception, add a comment explaining why and at least log it at Debug level. async voidmethods. Exceptions thrown fromasync voidcan't be caught by the caller and can crash the process. Useasync Taskeverywhere except event handlers.- Blocking on async code.
.Resultand.Wait()wrap failures inAggregateExceptionand risk deadlocks.awaitunwraps the first exception for you. - Treating cancellation as an error. An
OperationCanceledExceptionafter a client disconnects is expected. Filter it out:catch (OperationCanceledException) when (ct.IsCancellationRequested). - Leaking stack traces to users. Return generic messages for 500 errors in production. Detailed errors belong in logs, linked by a
traceId. - Throwing
Exceptiondirectly.throw new Exception("error")forces callers to catch everything. Throw a specific type. - Throwing from
finallyorDispose. This replaces the original exception, and the real cause disappears.
Advanced C# Exception Handling: Exceptions vs. the Result Pattern
Many senior teams now return a Result<T> type for expected business failures such as validation errors or "not found", and keep exceptions for truly unexpected problems like a database outage or a bug. This makes failure paths visible in method signatures and avoids the cost of throwing on common paths.
public readonly record struct Result<T>(T? Value, string? Error)
{
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(string error) => new(default, error);
}
public Result<Order> PlaceOrder(Cart cart) =>
cart.Items.Count == 0
? Result<Order>.Fail("Cart is empty.")
: Result<Order>.Ok(new Order(cart));
You don't have to pick one approach. A practical rule: if the caller is expected to handle the failure, return a Result; if the failure means something is broken, throw.
Conclusion: Key Takeaways for C# Exception Handling
Good C# exception handling means building a clear, predictable error strategy, not scattering try-catch blocks through your code. To recap the most important C# exception handling best practices:
- Use exceptions for exceptional conditions; use
TryParse-style methods or a Result type for expected failures. - Catch specific exceptions, and catch
Exceptiononly at application boundaries. - Always rethrow with
throw;to keep the stack trace, and usewhenfilters for conditional handling. - Create a small, meaningful custom exception hierarchy that carries domain data and inner exceptions.
- Centralize error handling with
IExceptionHandlerand ProblemDetails in ASP.NET Core. - Log with
ILoggerstructured templates, pass the exception object, and log each exception once. - Never expose internal details to end users. Link responses to logs with a trace ID instead.
Start by adding a global exception handler to your current project today. It takes about 15 minutes and immediately improves your API's reliability and debuggability. For more hands-on .NET guides, browse the rest of our C# Tutorials on 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
Post a Comment