Exceptions and Error Handling
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
An exception is how a .NET method reports that it could not do what its name promises. The type says what went wrong, the message says why, and the stack trace says where. C#'s job is to make throwing cheap to write, catching precise, and cleanup guaranteed.
try, catch, finally
public static class TryCatchFinally
{
public static int ReadCount(string? text)
{
try
{
return int.Parse(text!);
}
catch (ArgumentNullException)
{
// Most specific first -- the first matching clause wins.
return 0;
}
catch (FormatException ex)
{
Console.WriteLine($"not a number: {ex.Message}");
return -1;
}
catch (OverflowException)
{
return int.MaxValue;
}
finally
{
// Runs whether or not an exception was thrown, and on an early return.
Console.WriteLine("parse attempt finished");
}
}
}
Rules worth stating plainly:
-
catchclauses are tested in source order, so order them most-derived first; the compiler rejects an unreachable clause hidden behind a base type. -
A bare
catch { }catches everything, including exceptions with no managed payload.catch (Exception)is equivalent in modern .NET and is clearer. -
finallyruns on normal exit, onreturn, and while an exception propagates. It does not run if the process dies outright (Environment.FailFast, a stack overflow, a hard kill). -
A
tryneeds at least onecatchor afinally—try/finallywith nocatchis a common and correct shape when you only need cleanup.
Exception Filters: when
A when clause decides whether a catch clause applies, before the stack unwinds:
public sealed class HttpLikeException : Exception
{
public HttpLikeException(int statusCode, string message) : base(message) => StatusCode = statusCode;
public int StatusCode { get; }
}
public static class Filters
{
public static string Fetch(Func<string> operation)
{
try
{
return operation();
}
catch (HttpLikeException ex) when (ex.StatusCode == 404)
{
return "not found";
}
catch (HttpLikeException ex) when (ex.StatusCode >= 500)
{
return "server error -- retry later";
}
// Anything else propagates: no catch clause claims it.
}
// A filter that never matches is the idiomatic "log without handling" trick:
// the log runs while the stack is still intact, then the exception continues.
public static void LoggingWithoutCatching(Action operation)
{
try
{
operation();
}
catch (Exception ex) when (Log(ex))
{
throw new UnreachableException(); // never reached: Log always returns false
}
}
private static bool Log(Exception ex)
{
Console.WriteLine($"observed: {ex.GetType().Name}");
return false;
}
}
The "before the stack unwinds" part is the real advantage over catching-and-rethrowing: a debugger stopping on
a first-chance exception still sees the original frames, and finally blocks between the throw and the filter
have not yet run.
Rethrowing Correctly
The difference between throw; and throw ex; is the difference between a useful bug report and a useless one:
public static class Rethrowing
{
public static void Correct(Action operation)
{
try
{
operation();
}
catch (InvalidOperationException)
{
Console.WriteLine("noting and rethrowing");
throw; // preserves the ORIGINAL stack trace
}
}
public static void Wrong(Action operation)
{
try
{
operation();
}
catch (InvalidOperationException ex)
{
throw ex; // RESETS the stack trace to this line -- almost always a bug
}
}
public static void WrappingWithContext(Action operation, string resource)
{
try
{
operation();
}
catch (IOException ex)
{
// Wrapping is fine -- as long as the original goes in as the inner exception.
throw new InvalidOperationException($"could not process '{resource}'", ex);
}
}
}
When an exception must be captured now and rethrown elsewhere — across a thread, or out of a callback — ExceptionDispatchInfo preserves the original trace where a plain throw ex; would not:
using System.Runtime.ExceptionServices;
public static class Dispatching
{
public static void Run()
{
ExceptionDispatchInfo? captured = null;
try
{
throw new InvalidOperationException("original failure");
}
catch (Exception ex)
{
captured = ExceptionDispatchInfo.Capture(ex);
}
// …later, possibly on another thread, with the original trace intact.
try
{
captured?.Throw();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message); // original failure
}
// ExceptionDispatchInfo.SetCurrentStackTrace attaches a trace to an
// exception that has not been thrown yet.
var prepared = ExceptionDispatchInfo.SetCurrentStackTrace(new TimeoutException("prepared"));
Console.WriteLine(prepared.StackTrace is not null);
}
}
throw as an Expression
Since C# 7, throw may appear wherever an expression is expected — in a conditional, after ??, in a lambda
or expression-bodied member, and in a switch arm:
public sealed class Account
{
private readonly string _id;
public Account(string? id) =>
_id = id ?? throw new ArgumentNullException(nameof(id));
public string Id => _id;
public decimal Balance { get; private set; }
public void Withdraw(decimal amount) => Balance = amount switch
{
<= 0m => throw new ArgumentOutOfRangeException(nameof(amount), amount, "must be positive"),
_ when amount > Balance => throw new InvalidOperationException("insufficient funds"),
_ => Balance - amount,
};
public override string ToString() => $"{_id}: {Balance:C}";
}
The Exception Hierarchy
Everything throwable derives from System.Exception. The types you meet daily:
| Type | Thrown when |
|---|---|
|
An argument is invalid in a way the others do not cover |
|
An argument that must not be |
|
An argument is outside its allowed range |
|
The call is invalid for the object’s current state |
|
The operation is never supported by this implementation |
|
A placeholder — never ship one on a reachable path |
|
A string is not in the expected format |
|
A checked arithmetic operation overflowed |
|
An array index was out of bounds (usually your bug) |
|
A dictionary key was absent |
|
A member was accessed on |
|
An I/O operation failed; |
|
An operation exceeded its allotted time |
|
A |
|
A member was called after |
|
One or more exceptions from concurrent work |
ArgumentException versus InvalidOperationException is the distinction worth internalising: the argument is
wrong versus the object is in the wrong state for this call.
Some exceptions signal that the process itself is compromised — OutOfMemoryException,
StackOverflowException (which cannot be caught at all since .NET Framework 2.0), AccessViolationException.
Do not catch these to "keep running"; there is nothing useful left to do.
(filters run HERE, stack still intact) Low->>Mid: is there a matching catch? Note over Mid: catch (IOException) when (retries #lt; 3)
filter evaluated: false Mid->>Top: is there a matching catch? Note over Top: catch (Exception) -- matches end rect rgb(238, 246, 240) Note over Low,Top: Pass 2 -- unwind, running finally blocks Low-->>Low: finally: stream.Dispose() Low-->>Mid: frame popped Mid-->>Mid: finally: scope.Complete() Mid-->>Top: frame popped end Note over Top: handler body runs -- ex.StackTrace still shows SaveToDisk
Designing Custom Exceptions
Add an exception type only when a caller could plausibly catch it specifically. If every handler would treat
it like InvalidOperationException, throw that instead.
// Derive from Exception (not ApplicationException -- that guidance was withdrawn long ago).
public class OrderProcessingException : Exception
{
// The three conventional constructors, so the type behaves like every BCL exception.
public OrderProcessingException()
{
}
public OrderProcessingException(string message) : base(message)
{
}
public OrderProcessingException(string message, Exception innerException)
: base(message, innerException)
{
}
// Extra state gets its own constructor and a read-only property.
public OrderProcessingException(string message, string orderId) : base(message) => OrderId = orderId;
public string? OrderId { get; }
}
Guidelines that keep custom exceptions well behaved:
-
name it
…Exception; -
make it
publicif callers should catch it,internalotherwise; -
supply the three standard constructors plus any state-carrying ones;
-
make added state immutable and expose it as read-only properties;
-
write a message that describes the problem, not the fix, and never interpolate a secret into it;
-
do not derive from
ApplicationException, and do not create a deep hierarchy nobody catches by.
Binary serialization of exceptions (ISerializable, the SerializationInfo constructor) is obsolete in modern
.NET and should not be added to new types.
Guard Clauses
The BCL ships throw-helpers that collapse the check-and-throw pair into one line and capture the argument name automatically:
public sealed class Report
{
private readonly string _title;
private readonly int _pageCount;
public Report(string title, int pageCount, string[] sections)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageCount);
ArgumentOutOfRangeException.ThrowIfGreaterThan(pageCount, 10_000);
ArgumentNullException.ThrowIfNull(sections);
_title = title;
_pageCount = pageCount;
}
public override string ToString() => $"{_title} ({_pageCount} pages)";
}
public static class GuardHelpers
{
public static void Demonstrate()
{
try
{
_ = new Report(" ", 1, []);
}
catch (ArgumentException ex)
{
// The parameter name is captured by [CallerArgumentExpression] -- no nameof needed.
Console.WriteLine(ex.ParamName); // title
}
// The full family, all following the same ThrowIf… shape:
// ArgumentNullException.ThrowIfNull
// ArgumentException.ThrowIfNullOrEmpty / ThrowIfNullOrWhiteSpace
// ArgumentOutOfRangeException.ThrowIfNegative / ThrowIfZero / ThrowIfEqual /
// ThrowIfLessThan / ThrowIfGreaterThanOrEqual …
// ObjectDisposedException.ThrowIf
}
}
Validate at the public boundary of a type or library, where a caller’s mistake can still be attributed to them. Re-validating in every private helper adds noise without adding safety.
Exceptions and Cleanup: using
finally guarantees cleanup; using is the shorthand for the overwhelmingly common case of disposing
something:
public static class CleanupWithUsing
{
public static async Task<string> ReadAsync(string path, CancellationToken token)
{
// A `using` declaration disposes at the end of the enclosing scope.
using var reader = new StringReader("contents");
return await reader.ReadToEndAsync(token);
}
public static void Nested()
{
// Equivalent to try/finally { a.Dispose(); } nested around b.
using var a = new MemoryStream();
using var b = new MemoryStream();
b.WriteByte(1);
Console.WriteLine(a.Length + b.Length);
}
}
An exception thrown inside a using propagates after Dispose runs. An exception thrown by Dispose
while another is already propagating replaces it — which is why Dispose implementations must not throw. See
Memory Management and Disposal.
AggregateException and Asynchronous Failure
Concurrent work can fail more than once, so the TPL collects failures into an AggregateException:
public static class AggregateFailures
{
public static async Task Run()
{
Task failing1 = Task.Run(() => throw new InvalidOperationException("first"));
Task failing2 = Task.Run(() => throw new FormatException("second"));
Task all = Task.WhenAll(failing1, failing2);
try
{
await all; // `await` rethrows only the FIRST exception…
}
catch (Exception ex)
{
Console.WriteLine($"await surfaced: {ex.GetType().Name}"); // InvalidOperationException
// …but the task itself still holds every one of them.
foreach (Exception inner in all.Exception!.InnerExceptions)
{
Console.WriteLine($" inner: {inner.GetType().Name}: {inner.Message}");
}
}
// Flatten collapses nested AggregateExceptions into one level.
try
{
Task.WhenAll(failing1, failing2).Wait(); // Wait() throws the AggregateException itself
}
catch (AggregateException ex)
{
Console.WriteLine(ex.Flatten().InnerExceptions.Count); // 2
}
}
}
Two async-specific points:
-
An exception from an
async Taskmethod is captured on the returned task and rethrown at theawait. An exception from anasync voidmethod has nowhere to go and crashes the process — never writeasync voidoutside an event handler. -
OperationCanceledExceptionfrom a cancelled token is expected control flow, not a failure. Let it propagate; catch it only where cancellation genuinely needs a response.
See Async and Await.
Alternatives to Throwing
Exceptions are for the exceptional. When failure is ordinary and expected, express it in the return type:
public static class Alternatives
{
public static void TryPattern(string input)
{
// Try… returns false instead of throwing -- right when invalid input is routine.
if (int.TryParse(input, out int value))
{
Console.WriteLine(value);
}
else
{
Console.WriteLine("not a number");
}
}
// A result object makes both outcomes part of the signature.
public readonly record struct Result<T>(bool Succeeded, T? Value, string? Error)
{
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
}
public static Result<int> Divide(int numerator, int denominator) =>
denominator == 0
? Result<int>.Fail("division by zero")
: Result<int>.Ok(numerator / denominator);
public static void Run()
{
TryPattern("x");
Result<int> result = Divide(10, 0);
Console.WriteLine(result.Succeeded ? result.Value.ToString() : result.Error);
// Pattern matching reads well over result types.
Console.WriteLine(Divide(10, 2) switch
{
{ Succeeded: true, Value: var v } => $"= {v}",
{ Error: var e } => $"failed: {e}",
});
}
}
Offer both where it helps: the BCL pairs Parse (throws) with TryParse (does not), letting the caller choose
which is the exceptional case for them.
Unhandled Exceptions
An exception nobody catches terminates the process. Register a handler to log it — not to continue:
public static class LastResort
{
public static void Install()
{
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
// Last chance to log. The process is going down regardless.
Console.Error.WriteLine($"fatal: {(e.ExceptionObject as Exception)?.Message}");
};
// A task whose exception is never observed used to crash the process; today it
// raises this event instead. Logging here catches forgotten awaits.
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Console.Error.WriteLine($"unobserved: {e.Exception.Message}");
e.SetObserved();
};
}
}
Best Practices
-
Throw for broken contracts, not for control flow. An exception on an expected path is both slow and misleading.
-
Catch only what you can act on. A
catchthat logs and swallows turns a crash into corrupt data. -
Never write an empty
catch. If an exception is genuinely ignorable, say so in a comment and catch the specific type. -
Catch the narrowest type that fits, and let everything else propagate to a boundary that can decide.
-
Use
throw;, neverthrow ex;. -
Preserve the original as
InnerExceptionwhenever you wrap. -
Do not use exceptions across a public API boundary to return data. The type and message are for diagnostics, not a protocol.
-
Document what a public member throws with
<exception cref="…">— see Coding Conventions and Documentation. -
Leave a top-level handler in place to log and exit cleanly.
See Also
-
Memory Management and Disposal —
using,IDisposableand whyDisposemust not throw. -
Async and Await — how exceptions travel through tasks.
-
Control Flow —
throwamong the other statements. -
Nullable Types and Null Safety — preventing the
NullReferenceExceptionin the first place. -
Pattern Matching — matching over result types.