How C# Exception Filters Preserve Stack Traces

In C#, handling errors effectively requires maintaining accurate diagnostic data about where an exception originated. This article explains how C# exception filters leverage the .NET runtime's two-pass exception handling system to inspect exceptions without unwinding the call stack, preventing stack trace rewriting and preserving the original execution context for debugging.

The Problem with Traditional Catch Blocks

When an exception is thrown in C#, the Common Language Runtime (CLR) looks for a handling block. In a traditional try-catch structure, once execution enters a catch block, the CLR has already committed to unwinding the call stack to that frame.

try
{
    PerformOperation();
}
catch (CustomException ex)
{
    if (!ex.IsRecoverable)
    {
        throw; // The stack has already begun unwinding
    }
    Handle(ex);
}

If the code determines inside the block that it cannot handle the error and calls throw;, the stack trace retains the original throw point, but the runtime state between the throw site and the catch block has already been torn down. Local variables, execution frames, and forensic state useful in post-mortem debugging or crash dumps are permanently lost.

Worse, developers sometimes write throw ex;, which completely resets the stack trace to the catch block's location, obliterating all records of where the exception originally occurred.

The Two-Pass Exception Architecture

The .NET runtime processes exceptions using a two-pass mechanism defined by the underlying operating system (Structured Exception Handling, or SEH):

  1. Pass 1: The Search Pass. The runtime traverses up the call stack to locate an exception handler willing to handle the exception. The stack is not unwound during this pass; the program state remains paused exactly as it was when the error occurred.
  2. Pass 2: The Unwind Pass. Once a handler is identified, the runtime walks the stack a second time, executing finally blocks and unwinding stack frames up to the handling catch block.

Standard catch (ExceptionType ex) blocks unconditionally accept the exception during Pass 1, triggering Pass 2 immediately.

How Exception Filters Prevent Rewriting

Exception filters, introduced in C# 6 using the when keyword, run code during Pass 1 rather than Pass 2.

try
{
    PerformOperation();
}
catch (CustomException ex) when (ex.IsRecoverable)
{
    Handle(ex);
}

When an exception occurs:

  1. The runtime reaches the search pass and evaluates the expression inside the when (...) clause.
  2. If the expression evaluates to false, the catch block is rejected.
  3. The runtime continues searching up the stack for another handler without unwinding the stack.

Because no unwinding occurs when the filter evaluates to false, no stack frames are removed, and the exception remains entirely untouched. If an unhandled exception reaches the root of the process, a diagnostic tool or minidump captures the exact memory state and call stack at the original point of failure.

Logging Without Modifying Stack State

A major practical application of this behavior is logging exceptions without intercepting them:

try
{
    RunWorkload();
}
catch (Exception ex) when (LogException(ex))
{
    // Never reached if LogException returns false
}
private static bool LogException(Exception ex)
{
    Logger.LogError(ex, "Unexpected failure.");
    return false; // Tells the runtime to keep looking
}

By returning false, the filter allows developers to inspect the exception, log full details, and leave the original stack trace intact for subsequent handlers or crash analysis tools.