Chapter 5: Error handling and resource management

Last Updated Date

September 7, 2026

Writing code that runs is not hard. Writing robust code that doesn’t crash unexpectedly requires a solid understanding of error handling and resource management. In the .NET world, exceptions are the standard mechanism for handling errors, while the IDisposable interface is the core of resource management.

These are also the two topics where developers most often stumble:

This chapter follows two main threads: how to represent and propagate errors when something goes wrong, and how to release resources reliably after you are done with them. We will cover:

Once you understand these practices, you will have a clearer sense of which failures should become exceptions, which ones are better expressed as return values, and which resources need explicit cleanup.

5.1 Exception handling

Exception handling isn’t just about wrapping code in try-catch blocks. When used correctly, it improves both debugging and maintainability. When used poorly, it can degrade performance and mask the root cause of a problem.

C# uses structured exception handling, which separates error-handling logic from the normal flow. This also raises common questions:

  • When should you throw exceptions? If user input is invalid, should you throw or return a value?
  • How should you catch exceptions? What is wrong with catch (Exception)?
  • How should you rethrow exceptions? What happens if you do throw ex;?
  • How do you guarantee resource cleanup? Even when exceptions occur, you still need to close files and connections.

We will start with exception handling and look at several choices that directly affect readability and debugging:

  • Exceptions are only for exceptional cases (Tester-Doer and Try-Parse patterns)
  • Catch specific exceptions instead of generic ones
  • Use exception filters (when) to improve debugging
  • Concise throw expressions
  • Rethrow correctly (preserve stack traces)
  • Use finally to ensure resource cleanup

.NET 9 performance improvements

.NET 9 introduced a new exception handling implementation based on NativeAOT, improving performance by 2x to 4x. Even when exceptions must be used, their performance overhead is noticeably smaller than before. Still, for expected errors (like user input), you should prefer TryParse and similar patterns.

Exceptions are only for exceptional cases

Here is a typical pattern: wrap the normal flow in a try block and handle exceptions in catch:

try
{
    ... // normal flow
}
catch
{
    ... // runs when something unexpected happens
}

You can read this as: “try to run some code and see if anything goes wrong. If something unexpected happens, stop the normal flow and pass the captured exception to the catch block to handle it.”

The golden rule is: do not use exceptions for normal control flow. Exception handling in the .NET runtime has overhead (modern runtimes optimize this, but generating a stack trace still takes time). The following example shows two approaches:

// Not recommended: exceptions for control flow
try
{
    int value = int.Parse(input);
}
catch (FormatException)
{
    // Handle format error
}

// Recommended: use TryParse (the Try-Parse pattern)
if (int.TryParse(input, out int value))
{
    // Success
}
else
{
    // Handle error
}

Think of it this way: exceptions should handle unexpected situations, not expected failures. If user input can be invalid (which is normal), you should not use exceptions for control flow. Use TryParse and similar APIs instead. It avoids exception overhead and makes the intent clearer. We will cover the Tester-Doer pattern later.

Catch specific exceptions

Avoid catching everything (catch (Exception ex)) unless you are at the application boundary (for example, logging an unhandled exception). If an intermediate layer catches everything, it may swallow programming bugs or errors the caller could have handled, making the real problem harder to trace. Catching specific exceptions lets you respond precisely to different error cases.

There are several ways to write catch clauses:

// Catch a specific exception and access the exception object
catch (FileNotFoundException ex)
{
    Console.WriteLine(ex.Message);
}

// Only care about the type, no exception variable needed
catch (IOException)
{
    // Handle I/O errors
}

// Catch all exceptions (usually only at the outermost layer)
catch
{
    // Exceptions not handled by earlier catch clauses end up here
}

When you catch specific exceptions, you can provide tailored responses—like distinguishing between a missing file and a permission error—rather than a vague “something went wrong” message. This makes your application’s behavior more predictable and user-friendly.

Exception filters

Starting in C# 6, you can use the when keyword to filter exceptions. This is more efficient than checking inside a catch block and rethrowing because the when condition is evaluated before stack unwinding. If the condition fails, the exception simply continues searching for another handler without first entering a catch block and being rethrown.

try
{
    MakeRequest();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    // Only handle 404 Not Found
    Console.WriteLine("Resource not found");
}
catch (HttpRequestException ex)
{
    // Handle other HTTP errors
    Console.WriteLine($"Request failed: {ex.Message}");
}

The key advantage is that filters run before stack unwinding. That means the original call stack and local state are still available while the filter is being evaluated; when a debugger is attached and configured to break on the exception, it is also more likely to show the original throw site instead of a location after control has entered a catch block.

In other words, when not only makes the catch condition more precise; it can also preserve more context while you diagnose a problem.

Rethrow correctly

If you need to rethrow an exception from a catch block, always use throw; instead of throw ex;.

try
{
    // ...
}
catch (Exception ex)
{
    Log(ex);

    // Incorrect: resets the stack trace, making debugging harder
    // throw ex;

    // Correct: preserves the original stack trace
    throw;
}

The detail is easy to miss: throw ex is legal C#, but it changes the stack information stored on the exception. The difference is:

  • throw: Preserves the original stack trace, so you can still locate where the exception first happened.
  • throw ex: Resets the stack trace. The caller cannot see the original location in the exception’s StackTrace. If you need to present a higher-level error to the caller, wrap the original exception and keep it in InnerException instead of destroying the original stack trace with throw ex.

Call stack and stack trace

A stack trace is a string that lists every method in the current call stack and the line number of each method (if debug symbols are available).

A call stack is a memory structure that stores the sequence of method calls for a thread, including parameters and local variables.

Throw expressions

Starting with C# 7, throw can be used as an expression, not just as a statement. This lets you throw in more contexts:

// In expression-bodied members
public string GetName() => throw new NotImplementedException();

// In a ternary operator
string ValidateInput(string input) =>
    input != null ? input.Trim() :
    throw new ArgumentNullException(nameof(input));

// In the null-coalescing operator
string result = userInput ??
                throw new ArgumentNullException(nameof(userInput));

Source code: DemoThrowExpressions

The common idea in these examples is that an exception no longer has to appear only as a standalone throw statement. It can live where a value is expected. For checks where “no value” means execution should not continue, this keeps the failure condition close to the value being used.

Note

throw null; does end up producing a NullReferenceException, but it is obscure and has no practical value. Avoid it in real code.

ArgumentNullException.ThrowIfNull (.NET 6+)

The previous section showed throw expressions combined with ?? to validate null parameters. Starting with .NET 6, there is an even more concise dedicated method:

// Traditional approach
void ProcessData(string data)
{
    if (data == null)
    {
        throw new ArgumentNullException(nameof(data));
    }
    // Process data
}

// .NET 6+ simplified approach
void ProcessData(string data)
{
    ArgumentNullException.ThrowIfNull(data);
    // Process data
}

This ThrowIfNull helper not only makes the code shorter, but also carries the parameter name into the exception object even when you do not write nameof(data) yourself; the compiler supplies the argument name at the call site.

For example, when data is null, ArgumentNullException.ThrowIfNull(data) still knows that the problematic parameter name is data, which makes the error message clearer.

Later .NET versions added more helpers. Relevant docs:

For numeric parameter validation, .NET 8 introduced many static methods on ArgumentOutOfRangeException, making code more explicit and readable:

  • ArgumentOutOfRangeException.ThrowIfNegative(value): Throw if the value is negative.
  • ArgumentOutOfRangeException.ThrowIfZero(value): Throw if the value is zero.
  • ArgumentOutOfRangeException.ThrowIfGreaterThan(value, other): Throw if value is greater than other.
  • ArgumentOutOfRangeException.ThrowIfLessThan(value, other): Throw if value is less than other.

These methods are especially useful when validating IDs, indices, or quantities—and they automatically include the parameter name in the error message for better diagnostics.

Wrap exceptions and preserve inner exceptions

Sometimes you want to throw a different exception type, such as translating low-level details into a higher-level message. In that case, keep the original exception as the InnerException property of the new one:

DateTime StringToDate(string? input)
{
    ArgumentNullException.ThrowIfNull(input);

    try
    {
        return DateTime.Parse(input);
    }
    catch (FormatException ex)
    {
        // null is handled by ThrowIfNull; only wrap format errors here
        throw new ArgumentException("Invalid date string.", nameof(input), ex);
    }
}

This way, different failures stay clearly separated: a null argument results in ArgumentNullException, while a malformed date string is wrapped as a higher-level ArgumentException. The caller gets an API-friendly error message, can identify the offending parameter through ParamName, and can still inspect the low-level cause (FormatException) via InnerException.

Key properties of Exception

Knowing where an exception came from and why it happened is usually more useful than merely knowing that something failed. System.Exception is the base class for all exceptions, and the following example shows the properties you will use most often while debugging:

try
{
    // Code that may throw
}
catch (Exception ex)
{
    // Message: short error description
    Console.WriteLine($"Message: {ex.Message}");

    // StackTrace: full call stack showing the exception path
    Console.WriteLine($"Stack trace: {ex.StackTrace}");

    // InnerException: wrapped inner exception (if any)
    if (ex.InnerException != null)
    {
        Console.WriteLine($"Inner exception: {ex.InnerException.Message}");
    }
}

When reading exception details, start with Message to understand the surface error, then use StackTrace to find where it occurred. If the exception has been wrapped, InnerException is often the clue closest to the root cause.

Exception inheritance hierarchy

After seeing the common properties, it is easier to understand why the inheritance hierarchy matters when choosing what to catch. System.Exception is the common ancestor of all exceptions. In the inheritance tree, you will often see SystemException and ApplicationException, but in practice it is more important to understand the meaning of each concrete exception type.

  • SystemException: This is the base type for many built-in .NET exceptions, such as ArgumentException, NullReferenceException, and IOException. That does not mean they all represent the same kind of problem. Exceptions such as ArgumentException and IOException are often expected and recoverable in the right context, while NullReferenceException usually indicates a programming bug and should not be your primary target for catch.

  • ApplicationException: It was originally intended as a base class for application-defined exceptions, but modern .NET does not especially recommend using it. For custom exceptions, inheriting directly from Exception is usually the better choice.

Understanding this hierarchy helps explain why catch (Exception) catches everything, and it is also a reminder that broad catches should have a clear reason. For custom exceptions, you usually do not need to inherit from ApplicationException.

Extra: Arithmetic overflow checks

By default, integer overflow at runtime usually does not throw. It wraps around instead. For example, if you first store int.MaxValue in a variable and then evaluate max + 1, the result becomes int.MinValue. However, if the overflow happens in a compile-time constant expression, that expression is evaluated in a checked context by default, so the compiler reports the overflow as an error. If you explicitly place it inside unchecked, overflow is allowed instead. If you want to detect overflow explicitly, such as in money calculations, use a checked block:

checked
{
    int balance = int.MaxValue;
    balance++; // Throws OverflowException
}

If you want to explicitly ignore overflow checks, use unchecked. This is rare in typical business apps, but useful to know for special cases.

finally clause: ensure resource cleanup

The code in a finally block runs whether or not an exception occurs. That makes it ideal for cleanup:

StreamReader? reader = null;
try
{
    reader = new StreamReader("app.config");
    var content = reader.ReadToEnd();
    Console.WriteLine(content);
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"File not found: {ex.Message}");
}
finally
{
    // Always runs
    reader?.Dispose();
}

Here is how it works:

  • Any line inside the try block could throw. When an error occurs, the rest of the try block is skipped. Control jumps immediately to a matching catch (if one exists), and then the finally block runs.
  • Even if no catch handles the exception, the finally block still runs before the exception propagates up the call stack. This provides a language-level guarantee that your cleanup code runs, unless the process is forcibly terminated first.

Source code: DemoExceptionHandling

Finalizer: the last line of defense

Besides finally, C# provides a finalizer, which runs before the GC reclaims an object. Its syntax looks like a constructor with a ~ prefix:

class ResourceWrapper
{
    ~ResourceWrapper()  // Finalizer
    {
        // Release unmanaged resources
    }
}

Finalizers have significant restrictions and costs:

  • Slows allocation and collection
  • Extends object lifetime (cleanup waits for a later GC)
  • Execution order and timing are unpredictable
  • Should not reference other finalizable objects
  • Should not throw or block

Therefore, avoid finalizers unless necessary. In most cases, the IDisposable pattern is enough. Finalizers are typically needed only when wrapping unmanaged handles (such as native pointers returned by Windows APIs), because these are not things the GC can directly close for you.

Also, teaching examples sometimes put Console.WriteLine inside a finalizer just to make the execution flow visible, but that should remain only in demonstration code. In real code, avoid doing I/O, logging, or any other work in a finalizer that could fail or block. If you add diagnostic output inside Dispose(bool disposing), keep it on the disposing == true path only.

If you truly need both IDisposable and a finalizer, use the standard Dispose pattern introduced in the next section. That pattern separates the explicit cleanup path from the GC-backed fallback path.

5.2 Resource management

In .NET, memory is managed automatically by the garbage collector (GC). Think of it as a janitor who regularly cleans your desk by removing objects you no longer use.

However, the janitor cannot enter the server room or open the safe. Likewise, the GC can reclaim managed memory, but it does not automatically release external resources held behind an object, such as file handles, database connections, sockets, or native handles. Those resources must be released explicitly with Dispose. Therefore, IDisposable is not just about unmanaged resources; it ensures that critical resources are cleaned up promptly rather than waiting for ordinary garbage collection.

Common resource management challenges include:

  • Forgetting to release resources: Leads to file locks, memory leaks, and exhausted connection pools
  • Cleanup during exceptions: How do you ensure resources are released when code throws?
  • Async cleanup: In async methods, how do you release resources that require async operations (like FileStream.FlushAsync())?
  • Implementing IDisposable yourself: How do you implement the pattern correctly when a class owns unmanaged resources (with finalizer support)?

Next, we will start with the using syntax from the caller’s perspective, then move into the patterns you need when your own types manage resources:

  • using statements and using declarations to ensure cleanup
  • The design of the IDisposable pattern
  • Async cleanup with IAsyncDisposable and await using
  • The full standard Dispose pattern (including finalizer support)
  • A practical anonymous disposal helper

Automatic cleanup with using

In the earlier finally example, we released the file resource with reader.Dispose(). This is common in .NET. The same idea applies to network connections, database connections, and other objects that wrap external resources. The object itself is managed by the GC, but the underlying resource often needs earlier, explicit cleanup through Dispose.

Because the standard try-finally-dispose pattern is long and ubiquitous, C# provides more concise syntax: first the using statement, and later the even more compact using declaration.

Let’s start with the earlier and easier-to-read form: the using statement. The compiler turns it into a try-finally block, ensuring Dispose is called even if exceptions occur.

// Traditional approach (nested block)
using (var file = File.OpenRead("data.txt"))
{
    /*
      Use file... (omitted)
     */
} // file.Dispose() is called here

This was the standard style in early C#. Its strength is that the scope is explicit; its downside is that multiple resources can quickly create deeper nesting. C# 8 introduced a style that is now more common for many cases.

using declaration (C# 8)

C# 8 introduced using declarations, which do not require braces. The variable is disposed when the current scope ends. This reduces indentation.

if (File.Exists("data.txt"))
{
    using var file = File.OpenRead("data.txt");
    using var reader = new StreamReader(file);

    var content = reader.ReadToEnd();
    Console.WriteLine(content);

} // reader and file are disposed here, in reverse order

Note: Disposal happens in reverse declaration order (stack order). In the example above, reader is disposed before file, which matches the dependency direction between those two resources.

Async disposal

The using syntax shown so far releases resources synchronously. However, with async programming, resource cleanup can also be I/O-bound (such as closing a stream or database connection). To handle these scenarios, C# 8 introduced the IAsyncDisposable interface and await using. Here is an example:

public async Task ProcessFileAsync(string path)
{
    // Use await using to ensure DisposeAsync is called
    await using var file = new FileStream(path, FileMode.Open);

    // ...
}

The key point in this code is that when execution leaves the scope, the compiler arranges to call DisposeAsync instead of the synchronous Dispose. If you are designing a type that performs asynchronous cleanup, implement IAsyncDisposable and let callers release it with await using.

Source code: DemoUsingStatements

Pay close attention to one detail: if you wrap an await using resource inside another wrapper that only implements IDisposable (for example, a StreamReader created with its default constructor), that wrapper may close the underlying resource synchronously first, because by default it also disposes the stream it wraps.

If you truly want the underlying FileStream to go through DisposeAsync, keep the wrapper from closing the inner stream (for example, by using leaveOpen: true), or apply await using directly to the underlying resource.

Why DisposeAsync?

With FileStream, closing the file may need to flush buffered data to disk. If you use Dispose, the flush is synchronous and can block, especially on network drives. DisposeAsync ensures the flush is async, improving throughput.

Note

For more on await using and IAsyncDisposable, see Chapter 12, “Async Programming”, in the section “Async resource cleanup”.

Standard Dispose pattern (with finalizer support)

The previous sections showed how to use objects that implement IDisposable through the using syntax. Now let’s switch perspectives and look at how to implement the standard Dispose pattern when your own class needs to manage resources.

When a class implements the standard Dispose pattern, it usually provides a public Dispose() method and an overridable Dispose(bool disposing) method for inheritance. Add a finalizer only if the class directly owns unmanaged resources. The following example shows the full version with finalizer support:

public class ResourceHolder : IDisposable
{
    private bool _disposed = false;

    // Public Dispose method (non-virtual)
    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);  // Tell GC not to run the finalizer
    }

    // Protected virtual Dispose method (for subclasses)
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Release managed resources (safe to reference other objects)
            // Example: _managedResource?.Dispose();
        }

        // Release unmanaged resources (always run)
        // Example: free handles, close native resources

        _disposed = true;
    }

    // Finalizer (backup if Dispose was not called)
    ~ResourceHolder()
    {
        Dispose(disposing: false);
    }
}

Source code: DemoDisposePattern

This code is much longer than the earlier using examples because it describes how the type itself cleans up correctly. As you read the pattern, focus on these points:

  1. disposing indicates the call source:

    • true: Called from Dispose(), safe to access managed objects.
    • false: Called from the finalizer, do not touch managed objects (they may be gone).
  2. GC.SuppressFinalize(this): if the type has a finalizer, this tells the GC not to run it because cleanup already happened. If the type doesn’t have a finalizer, the call simply has no practical effect.

  3. protected virtual: allows subclasses to override and add cleanup logic.

  4. Prevent double disposal: use a _disposed flag so cleanup runs only once.

Practical advice

For a sealed class that wraps only managed resources, a simplified IDisposable implementation without a finalizer or virtual method is sufficient. If the class is unsealed, it’s still a good idea to provide Dispose(bool) for inheritance support. But a finalizer is only needed when the class directly owns unmanaged resources, and in practice SafeHandle is usually the better option for wrapping them because it handles reliable release of the underlying handle.

Anonymous disposal

The Dispose pattern above is for classes that own resources themselves. But sometimes you just want to leverage using’s automatic cleanup on scope exit for short-lived state changes, without creating a full class. For example, you may want to pause events during an operation and resume them afterward:

// Less elegant approach
foo.SuspendEvents();
try
{
    // Do work
}
finally
{
    foo.ResumeEvents();
}

// Cleaner approach with using
using (foo.SuspendEvents())
{
    // Do work
} // Events resume automatically

Both versions do the same work, but the using version binds “enter this temporary state” and “restore it when leaving” to the same scope, so the cleanup is harder to miss when reading the code. You can implement this with a small helper class:

public class Disposable : IDisposable
{
    private Action? _onDispose;

    public static Disposable Create(Action onDispose)
        => new Disposable(onDispose);

    private Disposable(Action onDispose)
        => _onDispose = onDispose;

    public void Dispose()
    {
        _onDispose?.Invoke();
        _onDispose = null;  // Prevent multiple calls
    }
}

Usage example:

class EventManager
{
    private int _suspendCount;

    public IDisposable SuspendEvents()
    {
        _suspendCount++;
        return Disposable.Create(() => _suspendCount--);
    }

    private void FireEvent()
    {
        if (_suspendCount == 0)
        {
            // Fire event
        }
    }
}

// Let's see it in action
using (manager.SuspendEvents())
{
    // Events paused
} // Events resume automatically here

Here, Disposable.Create receives a delegate and invokes it during Dispose. In other words, any “run this once when leaving the scope” action can be packaged as a small IDisposable object. This pattern is useful for:

  • Temporary state changes (pause/resume)
  • Scoped configuration changes (culture switches)
  • Timing and tracing scopes

Source code: DemoAnonymousDisposal

5.3 Exceptions vs. error codes: when to use which

Not all errors should be handled with exceptions. When designing APIs, consider the caller’s needs.

We have already mentioned several times that an expected failure is not always a good fit for exceptions. Here is the decision point more explicitly: when should you throw exceptions, and when should you return an error code (or a boolean)? Poor choices lead to:

  • Overusing exceptions: Worse performance, confusing control flow, and forcing callers to handle expected failures as “exceptions”
  • Overusing error codes: Callers may forget to check errors, handling becomes scattered, and root causes become harder to track.

This section uses two common .NET patterns as the guide:

  • Tester-Doer and Try-Parse patterns: how .NET avoids turning expected failures into exceptions
  • API design considerations: when to throw vs. when to return error codes
  • Practical guidance: when to provide a TryXxx method

Tester-Doer and Try-Parse patterns

The .NET class library commonly offers both patterns, but they are not the same design.

// Tester-Doer: test first, then perform the operation that could throw
ICollection<int> numbers = GetNumbers();

if (!numbers.IsReadOnly)
{
    numbers.Add(42);
}

In the Tester-Doer pattern, one member tests whether an operation is safe or valid, and another member performs the operation. This helps when failure is common and you want to avoid paying exception costs repeatedly.

Another common design is the Try-Parse pattern:

// Parse: input is expected to be valid; failure throws
int result = int.Parse(input);  // May throw FormatException

// TryParse: input may be invalid; failure returns false
if (int.TryParse(input, out int result))
{
    Console.WriteLine($"Converted successfully: {result}");
}
else
{
    Console.WriteLine("Input format is invalid. Please try again.");
}

Putting these two patterns side by side gives us this rule of thumb:

  • Use Tester-Doer when the API already provides an inexpensive and explicit way to check whether an operation should be attempted.
  • Use Parse when input should be valid and failure indicates a bug (for example, data read from a validated database).
  • Use TryParse when input comes from users or external systems and failure is expected.

Compared to throwing exceptions, TryParse is a good fit when failure is a normal result and the caller will branch based on success or failure. Tester-Doer fits when the API can provide a cheap, explicit check before the caller attempts the operation.

Designing your own APIs

When designing methods, use these principles:

  • Throw exceptions: Unexpected or severe errors, unrecoverable situations, or violations of preconditions.
  • Return error codes or flags: Expected failure cases, performance-sensitive paths, or when callers branch on success/failure.

Practical advice

If failure is common and expected, and callers usually only need a success/failure result (for example parsing, dictionary lookup, or input validation), consider offering a TryXxx version. For file or network operations, where the reason for failure often matters, exceptions are still usually the better design because callers often need details such as a path, status code, or underlying cause.

Summary

  • Exceptions are for exceptional cases: Avoid exceptions for normal control flow; prefer TryXxx.
  • Exception filters: Use when to catch only what you need.
  • Preserve stack traces: Use throw; instead of throw ex;.
  • Wrap exceptions: Pass the original exception to the new one as InnerException.
  • finally ensures cleanup: finally runs even when exceptions occur.
  • Using declarations: Use C# 8 using var to reduce nesting.
  • Async cleanup: Use IAsyncDisposable and await using for I/O-bound cleanup.
  • TryXxx pattern: Offer TryXxx methods for expected failures.

Glossary

Term Description
Anonymous disposal Pattern that creates a short-lived IDisposable via delegates
Dispose Explicitly release resources, typically via IDisposable
Exception An error or abnormal condition during program execution
Exception filter Use when conditions to decide whether to catch an exception
Finalizer Method that runs before GC reclaims an object, syntax ~ClassName()
Inner exception The original exception that caused the current one, stored in InnerException
Managed resource Resources managed by the CLR, such as objects in memory
Stack trace Shows the call stack when an exception occurs
Stack unwinding The runtime walking up the stack to find a handler when an exception occurs
Suppress finalize Use GC.SuppressFinalize to prevent finalizer execution
Tester-Doer pattern Test whether an operation can succeed before doing it
Throw expression C# 7 feature: use throw in expressions
Unmanaged resource Resources not managed by the CLR, like file or window handles
Using declaration C# 8 syntax that auto-disposes without braces