Error Handling in C#
try/catch/finally, custom exceptions, AggregateException, and global error handlers in C#.
try / catch / finally
Exceptions are the primary mechanism in C# for signaling and handling error conditions that break normal program flow. Without structured error handling, a single unexpected failure can crash the whole application and leave resources in an inconsistent state. The try/catch/finally block lets you recover from known errors, react differently to different failure types, and always clean up — even when things go wrong.
try
{
string text = File.ReadAllText("data.json");
var config = JsonSerializer.Deserialize<Config>(text);
Console.WriteLine($"Loaded: {config?.Name}");
}
catch (FileNotFoundException ex)
{
// Specific catch — only handles missing file errors
Console.WriteLine($"File not found: {ex.FileName}");
}
catch (JsonException ex)
{
// Separate handler for malformed JSON
Console.WriteLine($"Invalid JSON: {ex.Message}");
}
catch (Exception ex) // catch-all — last resort only
{
Console.WriteLine($"Unexpected error: {ex.Message}");
throw; // re-throw preserves the original stack trace — don't swallow unknowns
}
finally
{
// Always runs — whether an exception was thrown, caught, or the method returned early
Console.WriteLine("Done");
}
The finally block runs regardless of whether an exception was thrown, caught, or the method returned early. Use it to release resources not managed by using.
using for Automatic Disposal
Many objects in .NET — database connections, file streams, HTTP responses — hold unmanaged resources that must be released explicitly. Forgetting to call Dispose() causes resource leaks. The using statement guarantees Dispose() is called even if an exception is thrown, making it the safest way to work with IDisposable objects. C# 8 introduced the using declaration, which is even more concise.
// using statement — disposes when the block exits, even on exception
using (var connection = new SqlConnection(connectionString))
using (var command = connection.CreateCommand())
{
connection.Open();
command.CommandText = "SELECT COUNT(*) FROM Orders";
int count = (int)command.ExecuteScalar()!;
Console.WriteLine(count);
}
// Dispose() called automatically on both objects here
// using declaration (C# 8+) — disposes at end of the enclosing scope
using var reader = new StreamReader("data.csv");
string line;
while ((line = reader.ReadLine()) != null)
Process(line);
// reader.Dispose() called here when the method exits
Exception Filters
Exception filters let you catch only the specific cases you can handle, without unwinding the stack for cases you cannot. They use the when keyword and evaluate a boolean condition before deciding whether to enter the catch block. This is particularly useful for HTTP status codes, error codes from external APIs, or retry logic — you can handle a 429 (rate limited) differently from a 500 (server error) using the same exception type.
try
{
await CallExternalApiAsync();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// Only catches 429 — other HttpRequestException types propagate normally
await Task.Delay(TimeSpan.FromSeconds(5));
await CallExternalApiAsync(); // retry once after back-off
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
{
// Wrap in a domain exception with a clearer message for the caller
throw new ServiceTemporarilyUnavailableException("External API is down", ex);
}
Creating Custom Exceptions
Built-in exceptions like InvalidOperationException are generic. Custom exceptions let callers catch and handle your specific error cases programmatically, and they let you attach domain-relevant data (like an order ID or a field name) to the exception. Always provide at least two constructors — one with just a message, and one that also accepts an innerException to preserve the original cause.
// Follow the standard exception pattern — message + innerException constructors
public class ValidationException : Exception
{
public string FieldName { get; }
public object? AttemptedValue { get; }
public ValidationException(string fieldName, object? attemptedValue, string message)
: base(message)
{
FieldName = fieldName;
AttemptedValue = attemptedValue;
}
public ValidationException(string fieldName, object? attemptedValue, string message, Exception innerException)
: base(message, innerException)
{
FieldName = fieldName;
AttemptedValue = attemptedValue;
}
}
// Domain exception hierarchy — base + specializations
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
public DomainException(string message, Exception inner) : base(message, inner) { }
}
public class OrderNotFoundException : DomainException
{
public int OrderId { get; }
public OrderNotFoundException(int orderId)
: base($"Order {orderId} was not found")
=> OrderId = orderId;
}
public class InsufficientInventoryException : DomainException
{
public string ProductId { get; }
public int Requested { get; }
public int Available { get; }
public InsufficientInventoryException(string productId, int requested, int available)
: base($"Insufficient inventory for {productId}: requested {requested}, available {available}")
{
ProductId = productId;
Requested = requested;
Available = available;
}
}
// Usage — callers can catch specific domain exceptions and respond appropriately
try
{
await orderService.PlaceOrderAsync(order);
}
catch (OrderNotFoundException ex)
{
logger.LogWarning("Order not found: {OrderId}", ex.OrderId);
return NotFound();
}
catch (InsufficientInventoryException ex)
{
logger.LogInformation("Inventory short: {Product}", ex.ProductId);
return Conflict(new { ex.Message, ex.Available });
}
throw Expressions
C# 7 made throw an expression, meaning you can use it in contexts where only expressions were previously allowed: the right side of ??, a ternary operator, or an expression-bodied member. This eliminates boilerplate guard clauses and makes argument validation concise and readable.
// In null-coalescing — concise null guard in a constructor
public string Name { get; }
public Customer(string name)
=> Name = name ?? throw new ArgumentNullException(nameof(name));
// In a ternary expression
string value = condition ? "yes" : throw new InvalidOperationException("Shouldn't happen");
// In an expression-bodied method — require a non-null value
public T GetRequired<T>(T? value) where T : class
=> value ?? throw new InvalidOperationException($"{typeof(T).Name} is required");
AggregateException
When multiple tasks run in parallel and more than one fails, you need a way to collect all the failures together. AggregateException is a container that wraps multiple inner exceptions from parallel or async operations. Task.WhenAll and Parallel.ForEach both use it. Call .Flatten() to collapse nested AggregateExceptions into a single flat list before handling them.
// Task.WhenAll collects all exceptions — even ones from tasks that finished
var tasks = new[]
{
Task.Run(() => throw new InvalidOperationException("Task 1 failed")),
Task.Run(() => throw new ArgumentException("Task 2 failed")),
Task.Run(() => Console.WriteLine("Task 3 OK")),
};
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex) // WhenAll re-throws the first exception when awaited...
{
// ...but each faulted Task holds its own AggregateException
if (tasks.Any(t => t.IsFaulted))
{
foreach (var task in tasks.Where(t => t.IsFaulted))
{
var agg = task.Exception!;
agg.Flatten().Handle(inner =>
{
Console.WriteLine($" Error: {inner.Message}");
return true; // mark as handled
});
}
}
}
// Parallel.ForEach throws AggregateException directly
try
{
Parallel.ForEach(items, item => ProcessItem(item));
}
catch (AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
Console.WriteLine(inner.Message);
}
Global Exception Handlers
Every application should have a last-resort handler to catch unhandled exceptions, log them, and fail gracefully rather than silently. Without one, exceptions on background threads or fire-and-forget tasks disappear without a trace. Global handlers are a safety net — they complement, not replace, proper exception handling throughout the codebase.
Console Applications
// Catch unhandled exceptions on the main thread
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
var ex = (Exception)e.ExceptionObject;
Console.Error.WriteLine($"FATAL: {ex.Message}");
Console.Error.WriteLine(ex.StackTrace);
// Log to file, Sentry, Application Insights, etc.
};
// Catch unhandled exceptions on thread pool threads
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Console.Error.WriteLine($"Unobserved task exception: {e.Exception.Message}");
e.SetObserved(); // prevent process crash
};
ASP.NET Core
// UseExceptionHandler middleware catches all unhandled exceptions from the pipeline
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
var ex = exceptionFeature?.Error;
// Map domain exceptions to appropriate HTTP status codes
context.Response.StatusCode = ex switch
{
ValidationException => StatusCodes.Status400BadRequest,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
DomainException => StatusCodes.Status422UnprocessableEntity,
_ => StatusCodes.Status500InternalServerError
};
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Unhandled exception");
await context.Response.WriteAsJsonAsync(new
{
error = ex?.Message ?? "An error occurred"
});
});
});
Exception Best Practices
Good exception handling is as much about discipline as syntax. These patterns prevent the most common mistakes: swallowed errors, lost stack traces, and exceptions used for normal control flow.
// 1. Include actionable context in the message — who, what, why
throw new InvalidOperationException(
$"Cannot process order {orderId}: status is {order.Status}, expected Pending");
// 2. Preserve the original cause with inner exception
try { db.SaveChanges(); }
catch (DbUpdateException ex)
{ throw new OrderPersistenceException("Failed to save order", ex); }
// 3. Never catch and silently ignore — this hides bugs permanently
try { riskyOperation(); }
catch { } // BAD — the error vanishes without a trace
// 4. Log before re-throwing when you need to add context
catch (Exception ex)
{
logger.LogError(ex, "Failed to process payment for order {OrderId}", orderId);
throw; // bare throw — preserves the original stack trace
}
// 5. Don't use exceptions for expected, normal flow — use TryParse patterns
// Bad — exceptions as control flow are slow and noisy
bool ParseBad(string s) {
try { int.Parse(s); return true; } catch { return false; }
}
// Good — TryParse is designed for this
bool ParseGood(string s) => int.TryParse(s, out _);