Error handling
C# provides exceptions as the principal error-propagation mechanism, with the convention that exceptions are exceptional and that ordinary failure modes use return values, bool success indicators, or Try-prefixed methods. The exception model integrates with IDisposable and the using statement for deterministic cleanup; exception filters (the when clause) admit conditional handling. Async exceptions propagate through await and are aggregated in AggregateException for tasks awaited synchronously through Wait or Result. The mechanism is the .NET inheritor of the C++/Java exception tradition with substantial refinements.
This page covers the exception model, the integration with IDisposable, the conventions for choosing between exceptions and return-values, and the common defects.
The exception model
An exception is raised by throw, propagates up the call stack, and is caught by the nearest matching catch clause:
public double Divide(int numerator, int denominator) {
if (denominator == 0) {
throw new DivideByZeroException("denominator must not be zero");
}
return (double)numerator / denominator;
}
try {
double r = Divide(a, b);
Use(r);
} catch (DivideByZeroException e) {
Console.Error.WriteLine($"error: {e.Message}");
}
Three operations:
throw expr;— raises an exception of the type ofexpr.try { … } catch (T e) { … }— registers a handler for exceptions of typeT(or types derived fromT).throw;(in a catch handler) — re-raises the current exception.throw e;(in a catch handler) — raisese, resetting the stack trace; the original is lost. Use plainthrow;to preserve.
Each try may have multiple catch clauses, ordered from most specific to least specific:
try {
DoWork();
} catch (FileNotFoundException e) {
LogMissing(e.FileName);
} catch (UnauthorizedAccessException e) {
LogPermission(e);
} catch (IOException e) {
LogIO(e);
} catch (Exception e) {
LogUnexpected(e);
}
The finally clause runs whether the try block completes normally or by exception:
try {
var stream = OpenStream();
Use(stream);
} finally {
stream?.Dispose();
}
The conventional contemporary form for cleanup is using, treated below.
The exception hierarchy
The standard library provides a hierarchy of exception types rooted at System.Exception:
System.Exception
├─ SystemException
│ ├─ ArithmeticException (DivideByZeroException, OverflowException)
│ ├─ ArgumentException (ArgumentNullException, ArgumentOutOfRangeException)
│ ├─ InvalidOperationException
│ ├─ NotImplementedException
│ ├─ NotSupportedException
│ ├─ NullReferenceException
│ ├─ IndexOutOfRangeException
│ ├─ FormatException
│ ├─ IOException (FileNotFoundException, DirectoryNotFoundException, PathTooLongException)
│ ├─ UnauthorizedAccessException
│ ├─ TimeoutException
│ ├─ StackOverflowException
│ └─ OutOfMemoryException
└─ ApplicationException (largely deprecated as a base)
System.Exception carries:
Message— a textual description.StackTrace— the call stack at the point of the throw.InnerException— an optional chained exception (the original cause, if this exception wraps another).Source— the assembly that threw.Data— a dictionary for ad-hoc additional information.
try {
Connect();
} catch (HttpRequestException e) {
throw new ApplicationException("connection failed", e); // chained
}
User-defined exceptions conventionally derive from System.Exception (or one of its descendants) and follow the four-constructor pattern:
[Serializable]
public class ParseException : Exception {
public int Line { get; }
public ParseException() { }
public ParseException(string message) : base(message) { }
public ParseException(string message, Exception inner) : base(message, inner) { }
public ParseException(int line, string message)
: base($"line {line}: {message}") { Line = line; }
}
The conventional advice is to derive from Exception directly rather than from ApplicationException; the latter was introduced in early .NET as the user-exception base but is no longer recommended.
Exception filters
C# 6 introduced exception filters: a when clause that conditionally handles an exception based on a runtime predicate:
try {
DoWork();
} catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) {
HandleMissing();
} catch (HttpRequestException e) when (IsRetriable(e)) {
Retry();
} catch (HttpRequestException e) {
Fail(e);
}
The filter is evaluated before the catch handler runs and without unwinding the stack. If the filter returns false, the exception continues propagating as if the catch clause did not match. The mechanism is more efficient than catching, examining, and re-throwing because the stack remains intact.
The conventional uses:
- Distinguishing among different conditions of the same exception type.
- Logging or recording diagnostic information without consuming the exception:
try {
DoWork();
} catch (Exception e) when (LogException(e)) {
// never reached because LogException returns false
} catch (Exception e) {
Handle(e);
}
bool LogException(Exception e) {
Logger.LogError(e, "operation failed");
return false;
}
The filter form lets the exception continue while still observing it; older code uses a catch-and-rethrow with throw; for the same effect.
RAII and using statements
C# does not have C++-style stack unwinding through destructors, but it has using and IDisposable for deterministic cleanup:
public void Process(string path) {
using (var stream = new FileStream(path, FileMode.Open)) {
// use stream
} // stream.Dispose() called, even on exception
}
The mechanism is equivalent to:
var stream = new FileStream(path, FileMode.Open);
try {
// use stream
} finally {
stream.Dispose();
}
C# 8 introduced using declarations:
public void Process(string path) {
using var stream = new FileStream(path, FileMode.Open);
// use stream
// stream.Dispose() called at the end of the enclosing scope
}
The form reduces indentation and is the conventional choice in modern code.
The full treatment of IDisposable is in Memory and the CLR.
The dispose pattern
Types that hold unmanaged resources implement IDisposable:
public class ResourceHolder : IDisposable {
private bool disposed;
private IntPtr nativeHandle;
public ResourceHolder() {
nativeHandle = AllocateNativeResource();
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing) {
if (disposed) return;
if (disposing) {
// dispose managed resources here
}
// release unmanaged resources here
if (nativeHandle != IntPtr.Zero) {
FreeNativeResource(nativeHandle);
nativeHandle = IntPtr.Zero;
}
disposed = true;
}
~ResourceHolder() {
Dispose(false);
}
}
The pattern is the conventional shape for types holding unmanaged resources; the simpler form (no finalizer, no bool parameter) is appropriate for types that hold only managed resources.
C# 8 introduced IAsyncDisposable for asynchronous cleanup:
public class StreamHolder : IAsyncDisposable {
private Stream stream;
public async ValueTask DisposeAsync() {
await stream.DisposeAsync();
}
}
await using (var holder = new StreamHolder()) {
// use holder
} // holder.DisposeAsync() awaited
AggregateException and async
Tasks await exceptions through await, but synchronous task waits (Task.Wait(), Task.Result) wrap the task’s exception in AggregateException:
try {
await SomeTask();
} catch (HttpRequestException e) {
Handle(e); // the actual exception, unwrapped
}
try {
SomeTask().Wait();
} catch (AggregateException ae) {
foreach (var e in ae.InnerExceptions) {
// the actual exceptions, wrapped one level
}
}
The conventional discipline is to use await rather than .Wait()/.Result; the latter blocks the calling thread (potentially deadlocking in UI code) and obscures the exception. The full treatment is in Async and concurrency.
Error codes versus exceptions
C# admits both exceptions and value-returned error codes. The conventional discipline:
| Use exceptions for | Use return-codes for |
|---|---|
| Conditions that should never occur in correct usage | Conditions that the caller will routinely handle |
| Constructor failures | Parse failures |
| Programming errors (null arguments, range violations) | Domain errors that are part of the contract |
| Failures requiring stack unwinding through many layers | Failures handled at the immediate caller |
The standard library provides several Try-prefixed methods that return bool and use an out parameter for the value:
if (int.TryParse(input, out int n)) {
Use(n);
} else {
HandleParseFailure();
}
if (dictionary.TryGetValue(key, out var value)) {
Use(value);
}
The Try pattern is the conventional alternative to exception-based parse and lookup APIs.
C# 11+ admits [NotNullWhen] attributes that propagate the non-null state to the caller:
public bool TryParse(string input, [NotNullWhen(true)] out User? user) {
/* ... */
}
if (TryParse(s, out var u)) {
Console.WriteLine(u.Name); // OK: u is non-null here per NotNullWhen(true)
}
Result<T, E> style
C# does not include a Result<T, E> type in the standard library. Third-party libraries (LanguageExt, OneOf) provide them; many codebases roll their own:
public abstract record Result<T, E>;
public record Ok<T, E>(T Value) : Result<T, E>;
public record Err<T, E>(E Error) : Result<T, E>;
Result<int, string> Parse(string s) =>
int.TryParse(s, out var n) ? new Ok<int, string>(n) : new Err<int, string>("parse failed");
var r = Parse(input);
string display = r switch {
Ok<int, string> ok => $"value: {ok.Value}",
Err<int, string> err => $"error: {err.Error}",
};
The pattern is the conventional value-level alternative to exceptions; modern codebases adopting functional-style C# use it for boundaries where exception-based error propagation is undesirable.
assert and static_assert
C# does not have a built-in assert keyword, but Debug.Assert (in System.Diagnostics) provides the conventional runtime assertion:
using System.Diagnostics;
public void Process(int[] data, int n) {
Debug.Assert(data != null, "data must not be null");
Debug.Assert(n >= 0 && n <= data.Length, "n out of range");
// ...
}
Debug.Assert is conditionally compiled: in release builds (without the DEBUG symbol defined), the call compiles to nothing. The mechanism is the conventional way to express invariants without paying the cost in production.
For unconditional assertions that should always run, the conventional pattern is:
if (!condition) throw new InvalidOperationException("invariant violated");
For compile-time constant assertions, C# does not have static_assert; the conventional substitutes are unit tests or Debug.Assert with [ConditionalAttribute("DEBUG")]-style discipline.
Common defects
Recurring exception-related defects:
| Defect | Description |
|---|---|
throw e instead of throw | throw e resets the stack trace, losing the original location. Use bare throw; to re-throw. |
| Empty catch clauses | catch (Exception) { } swallows errors silently. Either log, re-throw, or do not catch. |
Catching Exception indiscriminately | Catches everything, including system errors that should propagate. Use specific exception types. |
| Throwing in a finalizer | Finalisers run on a dedicated thread; an unhandled exception terminates the process. |
| Resource leak in constructor | A constructor that throws partway through leaves no instance to dispose. Use the IDisposable pattern with care; ensure each acquired resource has a corresponding try/finally. |
| Async exception observation | Awaiting a task observes its exception. A fire-and-forget task whose exception is unobserved triggers TaskScheduler.UnobservedTaskException. |
| Disposing a disposed object | The Dispose method should be idempotent (safe to call twice). |
| Mixing exception types | A function that throws different exception types for the same logical condition is harder to handle correctly. Pick a consistent type. |
The combination of using statements, the standard exception types, exception filters, and the Try pattern is the conventional toolkit for error handling in modern C#. The discipline of writing exception-correct code — using using for resources, validating arguments at the boundary, throwing specific exception types, and choosing return-codes versus exceptions appropriately — is one of the principal skills of writing reliable C#.