AlgoMaster Logo

Exception Filters (when clause)

Last Updated: May 22, 2026

Medium Priority
16 min read

An exception filter is a boolean expression attached to a catch block that decides whether that catch should actually handle the exception. The clause uses the when keyword and runs before the stack starts unwinding, which lets you route exceptions by their data, log without handling, and keep the rest of the catch search going when the predicate says "not this one." This chapter covers what filters do, why they're not the same as catching and rethrowing, and the patterns common in production code.

The when Clause

A normal catch block matches purely on type. If the exception is the catch's declared type (or a subtype), the block runs. There's no way to say "only handle this if some condition on the exception is true." Before C# 6, the only workaround was to catch the type, check the condition inside the body, and rethrow when the condition was false. That works, but it has real costs.

C# 6 added the when clause. The syntax slots between the catch parameter and the opening brace:

The predicate is any expression that returns bool. If it returns true, the catch body runs. If it returns false, this catch is skipped, and the runtime keeps searching the remaining catches on the same try (and then the outer try blocks on the call stack) for one that matches.

A payment service throws different HttpRequestException errors depending on the response status code, and we only want to react to "rate limited" responses (status 429) with a retry message. Everything else should bubble up.

The catch looks identical to a regular one until you reach when. The predicate ex.StatusCode == HttpStatusCode.TooManyRequests runs first. Since it returns true, the body runs. If ChargeCard had thrown a different HttpRequestException (say, status 500), the predicate would have returned false, this catch would have been skipped, and the exception would have propagated out of Main and crashed the program because there was no other matching catch.

The predicate can reference the catch parameter (ex), any local variable in scope, any field, and it can call methods. The compiler doesn't restrict what you put in there. The runtime, however, treats anything thrown inside the predicate carefully, and we'll come back to that detail in a few sections.

The predicate runs every time an exception of the matching type passes this catch. If your predicate calls expensive methods (network requests, file I/O, locking), you'll pay that cost on every relevant throw, even when the catch ends up not running. Keep predicates cheap and side-effect-free, ideally property reads and equality checks.

A filter can sit alongside non-filtered catches. The next snippet shows a filter narrowing the retryable case, with a fallback for everything else of the same type.

The runtime evaluates the catches in source order. It sees the first catch is for HttpRequestException, the type matches, and the filter runs. 503 isn't 429, so the predicate returns false, the catch is skipped, and the search moves to the next catch. The second catch has no filter, so it matches and runs.

This is the same ordering rule for multiple catch blocks, applied with one extra step: type match first, then filter (if any), then body. The order of source declarations still matters.

Why Filters and Not catch-then-rethrow

A common pattern looks like this:

That looks like it does the same thing as a when clause. It catches the exception, checks a condition, rethrows if the condition isn't what we wanted, and runs the handler otherwise. The behavior the program ends up with is similar, but the mechanics are very different, and the difference shows up exactly when you most need diagnostic clarity.

The key idea is that a when filter runs before the stack unwinds. A catch body, by contrast, runs after the stack has unwound to the catch's frame. Once the body is running, the frames between where the exception was thrown and the catch are gone. Rethrowing from inside the catch then sends the exception back up, but from this point, not from where it originally came from. The original site is no longer on the stack.

Concretely, that means a debugger watching first-chance exceptions sees a when filter as part of the throw, with the original call stack intact, all locals still on their frames. The catch-and-rethrow path sees the throw, then an unwind, then a new throw from the catch. Two events instead of one, and the live state at the original site is gone by the time the second throw happens.

The two diagrams below contrast the timing.

That's the when path. The filter runs while the stack still contains every frame between the throw and the catch. The runtime hasn't started removing frames yet, because it doesn't know which catch (if any) will actually handle the exception. Only after a filter returns true does the runtime commit to that catch and unwind.

That's the catch-and-rethrow path. By the time the condition runs, the stack has already been chopped down to the catch frame. Frames for ChargeCard and anything it called are gone. When the rethrow fires, it can preserve the original exception's stack trace string (using bare throw;), but the live frames themselves are not coming back. A debugger attached at this point sees a different picture from one attached at the original throw.

The practical consequences:

  • Debugger fidelity. With "break on thrown exceptions" enabled, a when filter pauses the debugger once, at the original site, with the full stack visible. Catch-and-rethrow pauses it twice (or hides the rethrow if the debugger is configured to ignore rethrows) and shows the second pause from the catch frame, not the throw site.
  • Stack trace preservation. Both forms keep the original stack trace string, but the live stack frames matter for things like SOS extensions, memory dump analysis, and any tooling that reads frames directly rather than reading the formatted trace.
  • `Exception.ToString()` output. With catch-and-rethrow using bare throw;, the formatted trace usually preserves the original chain. With throw ex; (the wrong choice), the trace gets clipped to the catch site. With a when filter, there's nothing to clip in the first place; the exception never stopped propagating.
  • Performance on the hot path. If the filter returns false, the runtime keeps searching without ever having to copy frame state for the unwind. Catch-and-rethrow always unwinds, even when it's about to rethrow.

The program below makes the debugger-pause difference visible. Run this once with each variant, watching the console messages. The order of "filter evaluated" or "catch entered" relative to "rethrow" tells you which side of the unwind you're on.

For the matching case, both forms produce the same console output, but the order of events from the runtime's perspective is different. With the filter, the predicate ran while the stack still had ThrowRateLimited on it. With the catch-and-rethrow, the catch ran after the stack was unwound, and only then did the condition check happen.

For a single match, the runtime cost of a filter and a catch are roughly the same. The win is for non-matches: a filter that returns false is cheaper than entering a catch body and rethrowing, because rethrowing involves capturing context, walking the stack a second time, and copying frame information. In tight error-handling loops (rare but real), filters can be measurably faster.

Filtering by Properties of the Exception

The most common use of when is routing on data carried by the exception itself. Exception types in modern .NET expose useful properties for this. HttpRequestException has StatusCode, SqlException has Number, IOException subtypes carry paths and error codes, and custom exception types you write can carry whatever domain fields you need. A filter against these properties lets multiple catches share a single exception type while reacting to different cases distinctly.

Start with a transient-failure router. Our checkout service calls a downstream stock service. The stock service throws HttpRequestException with different status codes for different problems. We want a "retry" message for retryable codes (429, 503, 504), and a "give up" message for everything else.

IsRetryable is a regular static method, but it could just as easily be a HashSet<HttpStatusCode>.Contains or a switch expression. The filter doesn't care what the predicate looks like as long as it returns bool. Factoring it into a method keeps both the catch line readable and the retry list testable on its own.

A nice secondary benefit is that the filter expression naturally documents what's retryable. Anyone reading the catch line sees "retryable" right there. With a catch-and-rethrow, the same information is buried inside the body, and you have to scan more code to figure out which subset of HttpRequestException this block actually wants.

The same pattern applies for SQL errors. SqlException.Number identifies the specific SQL Server error code, and the retry policy for transient ones (1205 = deadlock victim, 1222 = lock timeout, -2 = timeout, 64 = connection broken, 4060 = cannot open database) is well-known. A filter routes them to a retry handler while a fallback catch reports the rest.

The filter calls Array.IndexOf, which is a method call. That's fine. The predicate is still a small, pure operation: it touches ex.Number, scans a small fixed array, and returns. There's no I/O, no locking, no allocation that would matter.

A custom domain exception is even cleaner because you control the shape of the data. Consider a payment domain that throws a PaymentFailedException carrying a ReasonCode enum.

Each catch tells the reader, on its declaration line, exactly which case it handles. The bodies are short because the routing is already done. Compare that to one big catch that does an if/else if/else ladder inside its body and you can see why filters read better in this style of code.

The same predicate pattern works for collections too. Consider a transient-stock-service failure that wraps a list of failed item IDs. We might want to react only when a specific SKU we care about is in the list.

Contains is an O(n) scan on a list, and that's the kind of cost you should pause on before using inside a filter. For the failed-product-IDs list here, n is small. If the same predicate was scanning a million-entry list, you'd want a different data structure (a HashSet<int> of "products I care about") or a different design (decide outside the filter, throw the routing decision in the body).

Property reads and small comparisons in filters are essentially free. Anything that allocates (string concatenation, LINQ over a non-trivial collection, building a new object) costs the allocation every time an exception of the type passes, even non-matching ones. If you find yourself doing real work in a predicate, reconsider whether the routing belongs there.

The when (Log(ex)) Logging Idiom

A useful technique falls out of filter mechanics. A filter is a predicate, and a predicate is allowed to have side effects. Most of the time you don't want side effects in a filter (it's hard to reason about). But there's one case where the side effect is the whole point: logging the exception while it's still in flight, without actually catching it.

The pattern:

Where Log is a method that does whatever logging you want and returns false. Because the filter returns false, the catch never runs. The exception keeps propagating up the stack as if this catch wasn't there. But on the way past, the runtime ran the predicate, which means Log(ex) executed. The exception got logged, with the original stack intact (remember, filters run before unwinding), and the program continues to look for a real handler.

A working example. Every layer of our order-processing pipeline can add a when (Log(ex)) line near the top of its try to record any exception passing through, without changing where the exception is actually handled.

The exception is thrown at the bottom of the call chain, in CallPaymentProvider. Before the runtime unwinds, it looks for a handler. It checks ChargeCustomer's catch first. The filter calls Log, which prints the line and returns false, so the catch is skipped. The runtime moves up the stack to ProcessOrder. Its filter calls Log, prints, returns false, also skipped. Finally the runtime reaches Main's catch, which has no filter (or a true one), enters, prints "top-level handler", and the program ends.

The output shows three properties. First, the layer-by-layer log lines appear in the order the runtime visited them, bottom-up. Second, the same exception object flowed through every filter (the stack frames were still all there during evaluation). Third, the eventual handler is the one in Main, untouched by the filters along the way.

This is useful in production systems. You can sprinkle catch (Exception ex) when (Log(ex)) near the top of major methods to get a "where did exceptions pass through" trail without committing to handle them at every level. Combined with a structured logging library, you get rich context (method, params, correlation IDs) recorded with the exception still in flight, which means the stack trace and the original frames are intact when you record them.

A common variant uses a static logging helper bound to your team's logger.

(The timestamp will be whatever the current UTC time is when you run it.)

The handler in Main doesn't know ProcessOrder logged anything. It sees the exception and handles it (or doesn't). The logging is decoupled from the handling, which is the whole point of the idiom.

Why not catch, log, and rethrow? Because that path goes through the unwind we already discussed. The catch-then-rethrow version pays the unwind cost twice (once on the way in, once on the way back up after rethrow), and it changes what a debugger configured to break on rethrows will see. With when (Log(ex)), there's only one throw event from the runtime's perspective: the original one. The log call is a side trip during the search phase.

Each when (Log(ex)) adds the cost of evaluating its predicate every time an exception passes by, even if no one ends up handling at that level. The cost is whatever your Log method costs. If it's a synchronous structured log call, it's cheap. If it's a network round trip, it becomes expensive during an exception storm.

A few practical notes for the idiom. The method should return false unconditionally so the catch is never accidentally entered. If it can throw on its own, that throw is swallowed, which is usually what you want for a logger but can hide real bugs in the logging code. Some teams use the pattern only for top-of-method observability and let downstream logging happen in the real handlers. Others use it pervasively. Both are defensible.

Throwing Inside a Filter

A filter is allowed to throw an exception. The behavior may be unexpected.

When a filter throws, the runtime catches that thrown exception inside the filter machinery and treats the filter as having returned false. The exception thrown inside the filter is discarded without a warning. The original exception (the one being searched for) keeps propagating to the next catch.

A demo:

The filter threw ApplicationException, which might be expected to either escape (becoming the new exception in flight) or replace the original. Neither happens. The runtime discards it, treats the filter as returning false, and continues searching for a handler. The next catch has no filter, matches the original InvalidOperationException, and runs. The message is still "original" because the original exception is what's being propagated, not the one thrown inside the filter.

This is a deliberate design choice. The alternative (letting the filter's exception escape) would mean a buggy predicate could mask the original exception, which is much worse than the predicate failing closed. Failing to false keeps the search going, which usually finds the appropriate handler somewhere up the stack.

The downside is that bugs in your predicate are silent. If IsRetryable throws a NullReferenceException because ex.StatusCode is null and you forgot to handle that case, the catch you wanted to use is skipped, and an upstream handler that wasn't written for this case ends up taking the exception. The symptom looks like "my retry logic doesn't work" with no obvious reason.

Keep predicates pure and total. They should never throw. Read properties, compare values, return a bool. If you need to call a method, make it one you've reviewed for "can this throw?" Common pitfalls:

  • Dereferencing null (use ?. or check before).
  • Calling LINQ over a collection that might be null (check first).
  • Calling methods that allocate and could OutOfMemoryException (rare, but real on huge inputs).
  • Calling methods that lock and could block, especially with reentrancy issues. (Filters block exception handling while they run. A filter that deadlocks deadlocks the entire propagation.)

A defensive predicate for the rate-limited router:

The predicate handles the null case explicitly instead of letting a NullReferenceException be discarded somewhere inside the comparison. Treat predicates the way you'd treat the body of a hash function (deterministic, fast, total, side-effect-free) to avoid the silent-failure trap.

A diagram of the failure mode:

The "discard and treat as false" branch is the one to remember. Predicates need to be reliable: if they break, the catch disappears from the search, and the symptom looks like a handler not running.

A throwing predicate is much more expensive than a returning predicate (allocating an exception object, capturing context, throwing, discarding). Beyond cost, the bigger issue is correctness: a throwing predicate is effectively a false return, which is almost certainly not what your handler logic assumes.

Filters and Multiple Catches Together

Real exception handling code mixes filters and plain catches on the same try block. The standard rules still hold (catches are checked in source order, the first matching one wins), but with filters in the picture there are a few more decisions to make.

The pattern that comes up most often in production: a specific filter narrows the retryable subset of a broad exception type, and a fallback catch handles the rest. We saw a small version of this earlier. Here's a fuller version where multiple subtypes share the routing.

Five catches handle six different cases. The two HttpRequestException catches split the retryable from the permanent. The two PaymentDeclinedException catches split user-recoverable from permanent. The TimeoutException catch covers the network-layer timeout, and the bottom Exception catch sweeps up anything that didn't match.

The structure makes the policy visible. Reading the catches top-to-bottom is a complete description of how this layer treats faults: retry these, fail-order these, prompt-card these, flag-order these, retry these, log everything else. If you had to write the same policy with nested if statements inside catch bodies, the structure would be there, but you'd have to read the bodies to find it.

The general guideline: put the filtered catch above the fallback catch for the same type. The runtime won't let you put the fallback above the filtered one for the same exact type (technically it allows it, but the filtered one would be unreachable, so the compiler warns). For different types, source order still matters: more specific types should be listed before more general ones.

The compiler warning for unreachable filters is worth seeing once:

When you compile this, the compiler doesn't actually warn (it's legal C#), but the second catch is dead code: any InvalidOperationException is caught by the first catch before the filter ever gets a chance. The takeaway is the same: order matters, and a more-specific (or filtered) catch on a given type needs to come before any less-specific catch on that type or a base type.

The flip side is that a filter doesn't prevent a later catch on the same type from running for a different case. That's the whole point. Source order means the runtime checks each in turn, and the filter says "not mine, keep going."

A diagram for the filter evaluation flow with multiple catches:

Type match first, filter (if present) second, body third. The whole thing happens in source order across all catches on this try. If nothing matches at this level, the runtime moves up the call stack to the next enclosing try and starts the same loop there.

One nuance with finally: if a filter returns false, the catch doesn't run, but finally still runs whenever control eventually leaves the try block. That's by design: finally is unconditional. A filter that returns false is "not me," not "skip cleanup."

The inner filter is hardcoded false, so the inner catch never runs. The finally runs anyway, because the exception is leaving the inner try. Then the outer catch handles the exception. Same propagation semantics as if the inner catch wasn't there at all, plus the cleanup guarantee finally always provides.