C# Exception Handling — throw, custom exceptions, and exception filters
Basic try/catch/finally handles most exceptions, but production code needs more: meaningful custom exception types, correct rethrowing, and exception filters to avoid catching what you can't handle.
throw vs rethrow — Preserving the Stack Trace
Rethrowing an exception with throw ex resets the stack trace, losing the original call site. Use bare throw to preserve it.
// BAD — stack trace is reset to this catch block
try { DoWork(); }
catch (Exception ex) { throw ex; } // ❌ loses original stack trace
// GOOD — original stack trace preserved
try { DoWork(); }
catch (Exception ex) { throw; } // ✅
// ALSO GOOD — wrap with context, pass original as inner exception
try { DoWork(); }
catch (Exception ex)
{
throw new InvalidOperationException("DoWork failed during startup.", ex); // ✅
}Custom Exception Classes
Create custom exceptions when callers need to handle your specific failure mode separately from general errors.
// Minimal custom exception
public class OrderNotFoundException : Exception
{
public int OrderId { get; }
public OrderNotFoundException(int orderId)
: base($"Order {orderId} was not found.")
{
OrderId = orderId;
}
// Always implement this constructor for serialization support
public OrderNotFoundException(int orderId, Exception inner)
: base($"Order {orderId} was not found.", inner)
{
OrderId = orderId;
}
}Throw and catch the custom exception:
Order GetOrder(int id)
{
var order = _db.Orders.Find(id);
if (order is null)
throw new OrderNotFoundException(id);
return order;
}
try
{
var order = GetOrder(42);
}
catch (OrderNotFoundException ex)
{
Console.WriteLine($"Caught: {ex.Message}"); // Caught: Order 42 was not found.
Console.WriteLine($"Order ID: {ex.OrderId}"); // Order ID: 42
}Exception Filters — when Clause
Exception filters let you catch only when a condition is true, without catching and rethrowing. The stack trace stays intact and other handlers get a chance to run.
try
{
ProcessRequest(request);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// Only catches 429 — other HttpRequestExceptions fall through
await Task.Delay(TimeSpan.FromSeconds(5));
await ProcessRequest(request); // retry
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
await RefreshTokenAsync();
}
catch (HttpRequestException)
{
// Catches any remaining HttpRequestException
throw;
}Exception filters also work with logging side-effects — the when clause runs even if the exception is not caught:
try { DoWork(); }
catch (Exception ex) when (LogException(ex))
{
// LogException should return false so this block never executes
// but the logging side-effect always runs
}
bool LogException(Exception ex)
{
_logger.LogError(ex, "Unhandled exception in DoWork");
return false; // let the exception propagate
}Exception Hierarchy — Only Catch What You Can Handle
try
{
string text = File.ReadAllText(path);
int value = int.Parse(text.Trim());
Process(value);
}
catch (FileNotFoundException ex)
{
Console.Error.WriteLine($"Config file missing: {ex.FileName}");
// Recoverable — use default config
Process(defaultValue);
}
catch (FormatException)
{
Console.Error.WriteLine("Config file contains invalid number.");
throw; // Not recoverable here — let it bubble up
}
// Don't catch IOException or Exception unless you can handle every subtypeAggregateException — async and Task.WhenAll
When using Task.WhenAll, exceptions from multiple tasks are wrapped in AggregateException.
try
{
await Task.WhenAll(taskA, taskB, taskC);
}
catch (Exception ex) when (ex is not AggregateException)
{
// await unwraps the first inner exception automatically
Console.WriteLine($"First failure: {ex.Message}");
}
// Inspect all failures
if (taskA.IsFaulted || taskB.IsFaulted || taskC.IsFaulted)
{
foreach (var t in new[] { taskA, taskB, taskC }.Where(t => t.IsFaulted))
foreach (var inner in t.Exception!.InnerExceptions)
Console.WriteLine(inner.Message);
}Real-World Example: Validation Exception with Error Details
public class ValidationException : Exception
{
public IReadOnlyList<string> Errors { get; }
public ValidationException(IEnumerable<string> errors)
: base("One or more validation errors occurred.")
{
Errors = errors.ToList().AsReadOnly();
}
}
void ValidateOrder(Order order)
{
var errors = new List<string>();
if (order.Quantity <= 0)
errors.Add("Quantity must be greater than zero.");
if (string.IsNullOrWhiteSpace(order.CustomerEmail))
errors.Add("Customer email is required.");
if (order.TotalAmount < 0)
errors.Add("Total amount cannot be negative.");
if (errors.Count > 0)
throw new ValidationException(errors);
}
try
{
ValidateOrder(new Order { Quantity = -1, TotalAmount = -5 });
}
catch (ValidationException ex)
{
Console.WriteLine(ex.Message);
foreach (var error in ex.Errors)
Console.WriteLine($" - {error}");
}
// One or more validation errors occurred.
// - Quantity must be greater than zero.
// - Customer email is required.
// - Total amount cannot be negative.Quick Reference
| Pattern | When to use |
|---|---|
catch (MyEx ex) { throw; } | Log then rethrow without losing stack trace |
throw new WrapEx("msg", ex) | Add context, preserve inner exception |
catch (Ex ex) when (condition) | Catch only specific cases without resetting stack |
| Custom exception class | Callers need to distinguish your error from others |
AggregateException.InnerExceptions | Inspect all failures after Task.WhenAll |
| Catch the most specific type first | Prevents swallowing exceptions you can't handle |