C# · lesson 18 of 26
Error Handling in C#
C# error handling by example: try/catch/finally, using and Dispose order, exception filters, custom exceptions, throw vs throw ex, and AggregateException.
By SkillByExample · Updated
try / catch / finally
Put the code that can fail in try, handle specific exception types in catch blocks — most specific first — and put cleanup in finally, which always runs.
using System.Text.Json;
File.WriteAllText("good.json", """{ "Name": "prod" }""");
File.WriteAllText("bad.json", """{ "Name": """);
foreach (var path in new[] { "good.json", "missing.json", "bad.json" })
Console.WriteLine($"-> {Load(path)}");
static string Load(string path)
{
try
{
var config = JsonSerializer.Deserialize<Config>(File.ReadAllText(path));
return $"loaded {config?.Name}";
}
catch (FileNotFoundException ex)
{
return $"file not found: {Path.GetFileName(ex.FileName)}";
}
catch (JsonException ex)
{
return $"invalid JSON: {ex.Message.Split('.')[0]}";
}
finally
{
Console.Write($"[finally {path}] ");
}
}
record Config(string Name);
$ dotnet run
[finally good.json] -> loaded prod
[finally missing.json] -> file not found: missing.json
[finally bad.json] -> invalid JSON: Expected depth to be zero at the end of the JSON payload
Each call hit a different path — success, missing file, malformed JSON — and finally ran in all three, before the returned value was printed, because it runs as control leaves the try/catch, even via return. Exception types are matched top to bottom, so a catch (Exception) placed first would swallow everything; the compiler rejects that ordering (CS0160).
using and Dispose Order
using guarantees Dispose runs when the scope ends, even if an exception escapes. Multiple resources are disposed in reverse order of creation.
try
{
using var connection = new Resource("connection");
using var command = new Resource("command");
Console.WriteLine("working...");
throw new InvalidOperationException("query failed");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"caught: {ex.Message}");
}
using (var file = new Resource("file"))
{
Console.WriteLine("using block body");
}
Console.WriteLine("after block");
class Resource : IDisposable
{
private readonly string _name;
public Resource(string name) { _name = name; Console.WriteLine($"open {name}"); }
public void Dispose() => Console.WriteLine($"dispose {_name}");
}
$ dotnet run
open connection
open command
working...
dispose command
dispose connection
caught: query failed
open file
using block body
dispose file
after block
Both resources were disposed before the catch ran — command first, then connection, the reverse of how they were opened, so something opened later (which may depend on something opened earlier) is always cleaned up first. The using var declaration form disposes at the end of the enclosing block; the using (...) statement form disposes at the end of its own block.
Exception Filters
catch (...) when (condition) catches only if the condition is true. If it is false, the exception keeps propagating as if the catch were not there.
using System.Net;
foreach (var status in new[] { HttpStatusCode.TooManyRequests, HttpStatusCode.ServiceUnavailable, HttpStatusCode.NotFound })
{
try
{
Handle(status);
}
catch (HttpRequestException ex)
{
Console.WriteLine($" escaped to caller: {ex.StatusCode}");
}
}
static void Handle(HttpStatusCode status)
{
try
{
throw new HttpRequestException($"HTTP {(int)status}", null, status);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
Console.WriteLine($"{(int)status}: back off and retry");
}
catch (HttpRequestException ex) when (Log(ex))
{
Console.WriteLine("never printed");
}
}
static bool Log(Exception ex)
{
Console.WriteLine($"{ex.Message}: logged by filter");
return false;
}
$ dotnet run
429: back off and retry
HTTP 503: logged by filter
escaped to caller: ServiceUnavailable
HTTP 404: logged by filter
escaped to caller: NotFound
Only the 429 was handled locally. The Log filter shows a well-known trick: a filter that logs and returns false records every exception passing through without catching it, and because filters run before the stack unwinds, the exception continues to the caller untouched.
Custom Exceptions
Derive from Exception when callers need to tell your failure apart from others, and put the data they need to react on properties rather than only in the message.
var stock = new Dictionary<string, int> { ["SKU-1"] = 3 };
foreach (var (id, sku, qty) in new[] { (1, "SKU-1", 2), (2, "SKU-1", 5), (3, "SKU-9", 1) })
{
try
{
PlaceOrder(id, sku, qty);
Console.WriteLine($"order {id}: placed");
}
catch (InsufficientInventoryException ex)
{
Console.WriteLine($"order {id}: 409 Conflict, only {ex.Available} left");
}
catch (DomainException ex)
{
Console.WriteLine($"order {id}: 422 {ex.Message} ({ex.GetType().Name})");
}
}
void PlaceOrder(int id, string sku, int qty)
{
if (!stock.TryGetValue(sku, out int available))
throw new DomainException($"Unknown product {sku}");
if (qty > available)
throw new InsufficientInventoryException(sku, qty, available);
stock[sku] = available - qty;
}
public class DomainException(string message, Exception? inner = null) : Exception(message, inner);
public class InsufficientInventoryException(string productId, int requested, int available)
: DomainException($"Insufficient inventory for {productId}: requested {requested}, available {available}")
{
public int Available { get; } = available;
}
$ dotnet run
order 1: placed
order 2: 409 Conflict, only 1 left
order 3: 422 Unknown product SKU-9 (DomainException)
After order 1 takes two units, order 2’s request for five fails with the specific exception, and the handler reads Available directly rather than parsing the message. A base DomainException lets one catch handle every business-rule failure; because InsufficientInventoryException derives from it, its catch must come first.
throw vs throw ex, and Inner Exceptions
A bare throw; rethrows with the original stack trace. throw ex; restarts the trace at the rethrow, losing where the error really happened. Wrapping in a new exception keeps the original as InnerException.
using System.Diagnostics;
try { Demo.Rethrow(); } catch (Exception ex) { Console.WriteLine("throw; " + Frames(ex)); }
try { Demo.RethrowEx(); } catch (Exception ex) { Console.WriteLine("throw ex; " + Frames(ex)); }
try { Demo.Wrap(); }
catch (Exception ex)
{
Console.WriteLine($"wrapped: {ex.Message} <- {ex.InnerException!.GetType().Name}: {ex.InnerException.Message}");
}
static string Frames(Exception ex) =>
string.Join(" <- ", new StackTrace(ex).GetFrames().Select(f => f.GetMethod()!.Name));
static class Demo
{
static void Fail() => throw new InvalidOperationException("disk full");
public static void Rethrow() { try { Fail(); } catch { throw; } }
public static void RethrowEx() { try { Fail(); } catch (Exception ex) { throw ex; } }
public static void Wrap() { try { Fail(); } catch (Exception ex) { throw new ApplicationException("save failed", ex); } }
}
$ dotnet run
throw; Fail <- Rethrow <- <Main>$
throw ex; RethrowEx <- <Main>$
wrapped: save failed <- InvalidOperationException: disk full
With throw; the trace still starts in Fail, where the problem is. With throw ex; the trace starts in RethrowEx, and Fail has vanished — in a real stack trace, that is the difference between seeing the line that failed and seeing only the line that rethrew. Wrapping adds context (“save failed”) without losing the cause.
Guard Clauses and throw Expressions
throw is an expression, so it fits on the right of ?? or in a ternary. .NET 8 also ships one-line guard helpers.
foreach (var (name, qty) in new (string?, int)[] { ("Ada", 2), (null, 1), ("Bob", -1) })
{
try
{
var line = new OrderLine(name, qty);
Console.WriteLine($"ok: {line.Customer} x{line.Qty}");
}
catch (ArgumentException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
}
public class OrderLine
{
public string Customer { get; }
public int Qty { get; }
public OrderLine(string? customer, int qty)
{
Customer = customer ?? throw new ArgumentNullException(nameof(customer));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(qty);
Qty = qty;
}
}
$ dotnet run
ok: Ada x2
ArgumentNullException: Value cannot be null. (Parameter 'customer')
ArgumentOutOfRangeException: qty ('-1') must be a non-negative and non-zero value. (Parameter 'qty')
Actual value was -1.
ArgumentNullException and ArgumentOutOfRangeException both derive from ArgumentException, so one catch handles both. ThrowIfNegativeOrZero(qty) needs no nameof: it captures the argument expression automatically, which is why the message names qty and quotes the actual value.
AggregateException and Task.WhenAll
When several tasks fail, await Task.WhenAll(...) rethrows only the first exception. The rest are still on the tasks — and on the combined task’s Exception.
var tasks = new[]
{
Task.FromException(new InvalidOperationException("Task 1 failed")),
Task.FromException(new ArgumentException("Task 2 failed")),
Task.Run(() => Console.WriteLine("Task 3 OK")),
};
var all = Task.WhenAll(tasks);
try
{
await all;
}
catch (Exception ex)
{
Console.WriteLine($"await threw: {ex.GetType().Name}: {ex.Message}");
Console.WriteLine($"all.Exception holds {all.Exception!.InnerExceptions.Count}:");
foreach (var inner in all.Exception.InnerExceptions)
Console.WriteLine($" {inner.GetType().Name}: {inner.Message}");
}
try
{
Parallel.Invoke(
() => { },
() => throw new Exception("item 1"),
() => { },
() => throw new Exception("item 3"));
}
catch (AggregateException agg)
{
Console.WriteLine($"Parallel.Invoke: {string.Join(", ", agg.InnerExceptions.Select(e => e.Message).Order())}");
}
$ dotnet run
Task 3 OK
await threw: InvalidOperationException: Task 1 failed
all.Exception holds 2:
InvalidOperationException: Task 1 failed
ArgumentException: Task 2 failed
Parallel.Invoke: item 1, item 3
await unwraps to one exception so ordinary catch blocks work, which means you silently lose the second failure unless you look at all.Exception. The synchronous Parallel APIs throw the AggregateException itself. Parallel.Invoke runs every action even when one throws, so both failures are always reported; the messages are sorted because they can arrive in any order. Parallel.For behaves differently: it stops scheduling new iterations after the first exception, so how many failures it reports varies from run to run. The faulted tasks are created with Task.FromException so the order of all.Exception is the array order; with Task.Run, WhenAll collects them in the order they happen to fail.
The Last-Resort Handler
In a console app, AppDomain.CurrentDomain.UnhandledException fires for any exception nothing caught — your chance to log it before the process dies.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = (Exception)e.ExceptionObject;
Console.Error.WriteLine($"FATAL (logged): {ex.Message}; terminating={e.IsTerminating}");
};
Console.WriteLine("starting");
throw new InvalidOperationException("config missing");
$ dotnet run
starting
FATAL (logged): config missing; terminating=True
Unhandled exception. System.InvalidOperationException: config missing
at Program.<Main>$(String[] args)
The handler ran and logged, but IsTerminating is True: it cannot rescue the process. The runtime then printed its own “Unhandled exception” report to stderr and aborted the process — on Linux the shell sees exit code 134 (128 + SIGABRT) — which is what makes a crashed job visible to a shell script, CI runner or container orchestrator. In ASP.NET Core the equivalent is UseExceptionHandler, shown in ASP.NET Core Basics.
Don’t Use Exceptions for Expected Failures
Throwing is expensive; bad user input is not exceptional. Use the Try... pattern instead.
using System.Diagnostics;
string[] inputs = Enumerable.Range(0, 20_000).Select(i => i % 2 == 0 ? i.ToString() : "n/a").ToArray();
var sw = Stopwatch.StartNew();
int ok = 0;
foreach (var s in inputs)
{
try { int.Parse(s); ok++; } catch (FormatException) { }
}
Console.WriteLine($"Parse + catch: {ok} ok in {sw.ElapsedMilliseconds} ms");
sw.Restart();
ok = 0;
foreach (var s in inputs)
if (int.TryParse(s, out _)) ok++;
Console.WriteLine($"TryParse: {ok} ok in {sw.ElapsedMilliseconds} ms");
$ dotnet run
Parse + catch: 10000 ok in 175 ms
TryParse: 10000 ok in 0 ms
Same answer, very different cost: 10,000 thrown-and-caught exceptions take tens of milliseconds or more, while TryParse handles the same input in under a millisecond. Your numbers will vary; the ratio is the lesson. Also note the empty catch { } here is acceptable only because the failure is expected and counted — an empty catch around code you don’t understand hides bugs.
Practice
- Retry with a filter. Write
T WithRetry<T>(Func<T> op, int max)that retries onTimeoutExceptiononly, usingwhenso the final failure propagates.
Solution
int calls = 0;
Console.WriteLine(WithRetry(() => ++calls < 3 ? throw new TimeoutException($"try {calls}") : "done", max: 5));
try { WithRetry<string>(() => throw new TimeoutException("always"), max: 2); }
catch (TimeoutException ex) { Console.WriteLine($"gave up: {ex.Message}"); }
static T WithRetry<T>(Func<T> op, int max)
{
for (int attempt = 1; ; attempt++)
{
try { return op(); }
catch (TimeoutException ex) when (attempt < max)
{
Console.WriteLine($"attempt {attempt} failed: {ex.Message}");
}
}
}
$ dotnet run
attempt 1 failed: try 1
attempt 2 failed: try 2
done
attempt 1 failed: always
gave up: always
- Wrap with context. Write
LoadUser(int id)that readsuser-{id}.txtand, if the file is missing, throwsInvalidOperationException("User 7 not found")with the original exception asInnerException.
Solution
try { LoadUser(7); }
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine($"cause: {ex.InnerException?.GetType().Name}");
}
static string LoadUser(int id)
{
try { return File.ReadAllText($"user-{id}.txt"); }
catch (FileNotFoundException ex) { throw new InvalidOperationException($"User {id} not found", ex); }
}
$ dotnet run
User 7 not found
cause: FileNotFoundException
- Dispose on failure. Using the
Resourceclass from theusingexample, show thatDisposeruns when an exception escapes a method that has ausing var.
Solution
try { Work(); }
catch (Exception ex) { Console.WriteLine($"caller caught: {ex.Message}"); }
static void Work()
{
using var r = new Resource("lock");
throw new Exception("boom");
}
class Resource : IDisposable
{
private readonly string _name;
public Resource(string name) { _name = name; Console.WriteLine($"acquire {name}"); }
public void Dispose() => Console.WriteLine($"release {_name}");
}
$ dotnet run
acquire lock
release lock
caller caught: boom
The lock is released before the caller’s catch runs.