Exception filters in C#: two-pass handling, preserving state for dumps, conditional catching by property, and pitfalls.
An exception filter is a when (condition) clause on a catch: catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound). The catch is only chosen if the condition is true; otherwise the runtime keeps searching up the stack as if that catch did not exist. Filters run during the runtime's first pass, before the stack is unwound, so when no handler matches, a crash dump still shows the original frames and local state. That is better than catch-check-rethrow. Good uses: catching by status code or error number, logging without catching (when (Log(ex)) returning false), and excluding cancellation. Keep filters cheap and side-effect free (except deliberate logging), and remember an exception thrown inside a filter is swallowed and treated as false.
An exception filter is a when clause on a catch: catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests). The catch only handles the exception if the filter returns true. The key difference from "catch, check, rethrow" is when the filter runs: during the runtime's first pass, before the stack is unwound — so if the filter returns false, the exception continues as if the catch didn't exist, with the original stack and state fully intact.
try
{
await _client.SendAsync(request, ct);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable)
{
throw new TransientDependencyException("Payments API is throttling", ex);
}
catch (SqlException ex) when (ex.Number is 1205 or -2) // deadlock, timeout
{
// retry logic
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// caller cancelled: expected, don't log as error
throw;
}
// Catch-and-rethrow
catch (SqlException ex)
{
if (ex.Number != 1205) throw; // stack is ALREADY unwound to here; inner finally blocks have run
Retry();
}
// Filter
catch (SqlException ex) when (ex.Number == 1205)
{
Retry(); // only entered for deadlocks; others pass through untouched
}
Differences:
Catch + throw; |
when filter |
|
|---|---|---|
| Stack unwound before deciding? | Yes — frames between throw and catch are gone | No — decision made during first pass |
Inner finally blocks run before deciding? |
Yes | No (they run later, if/when a handler is chosen) |
| Crash dump / debugger state if ultimately unhandled | Shows the rethrow point | Shows the original throw point with locals intact |
| "First-chance" break in debugger | Breaks at rethrow too | Only original throw |
| Code clarity | Imperative | Declarative: handler applies only to matching cases |
The preserved state matters most for crash dumps and debugging: if no handler matches and the process crashes, the dump shows the exact frame and local variables where the exception was thrown — not a rethrow site.
HttpRequestException.StatusCode), SQL error numbers, IOException.HResult, gRPC StatusCode.catch (OperationCanceledException) when (ct.IsCancellationRequested) (caller cancelled) vs a timeout from a different token.false, so you add context at this layer but don't handle the exception:catch (Exception ex) when (LogAndContinue(ex, orderId)) { } // never entered
private bool LogAndContinue(Exception ex, int orderId)
{
_logger.LogError(ex, "Failure while processing order {OrderId}", orderId);
return false;
}
(Use sparingly — logging scopes, Activity tags or a global handler are usually cleaner for context.)
when (_env.IsDevelopment()) to add diagnostics.catch (Exception ex) when (attempt < MaxAttempts && IsTransient(ex)).finally blocks, at a surprising time. Keep them pure and cheap (logging is the accepted exception).false — the filter silently stops matching.when (true) tricks.An integration client caught all HttpRequestExceptions, inspected the status code, and rethrew non-retryable ones. When an unexpected 400 occurred in production, the crash dump captured by Azure App Service's auto-heal showed the stack at the rethrow site, and the request details held in locals of the inner method were gone. Rewriting the handler with catch (HttpRequestException ex) when (IsTransient(ex.StatusCode)) meant non-transient failures propagated without being caught at all; the next dump showed the original frame inside the serialisation code, including the payload that caused the 400 — the root cause was found in minutes.
Checks deeper knowledge of how .NET exception handling works.
if inside catch, or puts heavy logic in filters.When an exception is thrown, the CLR:
try region with handlers, it checks catch types and runs filter blocks for clauses with when. Nothing is unwound; finally blocks have not run. The first clause whose type matches and whose filter returns true is the chosen handler.finally/fault blocks on the way, then runs the catch body.If pass 1 finds no handler, the exception is unhandled; the runtime raises AppDomain.UnhandledException and terminates. Because pass 2 never ran, a crash dump taken at that moment contains the full original stack with all locals — the main diagnostic benefit of filters.
static void Inner()
{
try { throw new InvalidOperationException(); }
finally { Console.WriteLine("inner finally"); }
}
try { Inner(); }
catch (Exception) when (Print("filter")) { Console.WriteLine("catch"); }
// Output:
// filter
// inner finally
// catch
The filter runs before the inner finally. If the filter depends on state that the inner finally would reset (e.g. a flag, a lock release), it sees the pre-cleanup state. This is why filters must not assume cleanup has happened and shouldn't mutate shared state.
A catch (T ex) when (cond) compiles to a filter block in the method's exception table:
.try { ... }
filter
{
isinst T
// if not T -> push 0
// else evaluate cond -> push 0/1
endfilter
}
{ // handler
...
}
The type test is part of the filter. VB.NET and F# have had filters since .NET 1.0; C# gained the syntax in C# 6, though the runtime always supported them.
If a filter throws, the runtime catches that exception internally and treats the filter as returning false, then continues searching. The new exception is not reported anywhere. A filter like when (ex.Data["Code"].ToString() == "X") that throws NRE when the key is missing will just never match — a silent bug. Write filters defensively: when (ex.Data["Code"] is "X").
In async methods, exceptions from awaited tasks are rethrown at the await (via ExceptionDispatchInfo), and filters on enclosing catches run then. The "unwound" state is relative to the async method's current frame — the original synchronous stack of the task's execution is already gone, but the preserved stack trace text is intact. Filters still avoid creating an additional rethrow site in your method.
dotnet-dump analyze on a crash dump: clrstack -a shows locals of each frame; with filters (no catch), frames from the original throw remain.static bool IsTransient(Exception ex) => ex switch
{
HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests or >= HttpStatusCode.InternalServerError } => true,
HttpRequestException { StatusCode: null } => true, // network failure, no response
SqlException sql => sql.Number is 1205 or -2 or 40613 or 40501 or 49918, // deadlock, timeout, Azure SQL throttling
TimeoutException => true,
_ => false
};
catch (Exception ex) when (attempt < 3 && IsTransient(ex))
{
await Task.Delay(Backoff(attempt), ct);
}
In production I'd usually let a resilience pipeline (Polly / Microsoft.Extensions.Http.Resilience) handle retries, with the same classification as its ShouldHandle predicate.
using System.Net;
using Microsoft.Data.SqlClient;
// 1. Catch only what you can handle
for (int attempt = 1; ; attempt++)
{
try
{
await repository.SaveAsync(order, ct);
break;
}
catch (SqlException ex) when (ex.Number is 1205 or -2 && attempt < 3) // deadlock or timeout
{
await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), ct);
}
}
// 2. Branch on HTTP status without catching everything
try
{
return await http.GetFromJsonAsync<Product>($"products/{id}", ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
// 3. Do not treat shutdown as an error
try
{
await worker.RunAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Worker failed");
throw;
}
// 4. Log with the stack still intact, without handling
try
{
Process();
}
catch (Exception ex) when (LogAndReturnFalse(ex))
{
// never reached
}
bool LogAndReturnFalse(Exception ex)
{
logger.LogError(ex, "Unhandled in Process");
return false;
}
What happens: catch (SqlException ex) { if (ex.Number != 1205) throw; ... }.
Why it is wrong: unwinds the stack and runs inner finally blocks before deciding, losing debugging state.
Do instead: catch (SqlException ex) when (ex.Number == 1205).
What happens: filters update counters, change state, or call services. Why it is wrong: they run in the first pass, before inner finally blocks, at surprising times; behaviour becomes hard to reason about. Do instead: keep filters pure and cheap; logging-and-returning-false is the accepted exception.
What happens: when (((MyData)ex.Data["x"]).Code == 5).
Why it is wrong: an exception in a filter is swallowed and treated as false — the handler silently stops working.
Do instead: write defensive filters with pattern matching (when (ex.Data["x"] is MyData { Code: 5 })).
What happens: catch (Exception) when (!_options.StrictMode) { }.
Why it is wrong: failures vanish in some environments, making bugs environment-specific.
Do instead: handle specific types, and always log what you swallow.
What happens: catch (OperationCanceledException) without checking which token was cancelled.
Why it is wrong: a timeout from an internal CancellationTokenSource looks like a client cancellation, hiding real timeouts.
Do instead: when (ct.IsCancellationRequested) for caller cancellation; treat other cancellations as timeouts.
What happens: filters that query a database or call an API to decide. Why it is wrong: they run while the stack is suspended during exception dispatch, adding latency and risk of nested failures. Do instead: decide based on exception properties already available.
What happens: when (Log(ex)) at every layer.
Why it is wrong: duplicate logs, just like log-and-rethrow.
Do instead: log once at the boundary; use logging scopes or Activity tags to add context.
What happens: when (ex.Message.Contains("deadlock")).
Why it is wrong: messages change between versions and languages (localised messages on some systems).
Do instead: match on types, error numbers, status codes or HResults.
What happens: a filter checks a flag that an inner finally resets. Why it is wrong: inner finally blocks haven't run yet when the filter executes. Do instead: don't make filters depend on cleanup side effects.
What happens: complex nested if/throw; logic inside catch blocks.
Why it is wrong: harder to read and worse for diagnostics.
Do instead: use when for conditional handling — supported since C# 6.
Seniors use filters to keep diagnostic information intact and to express precise handling rules.
| Goal | Filter use |
|---|---|
| Transient retry | when (IsTransient(ex) && attempt < max) |
| Better crash dumps | avoid catch-rethrow; let unmatched exceptions stay unhandled |
| Clean shutdown logs | exclude OperationCanceledException |
| Log at the boundary with full stack | when (Log(ex)) returning false |
| Status-specific logic | HttpRequestException.StatusCode, SqlException.Number |
They also know that resilience libraries (Polly / Microsoft.Extensions.Resilience) use predicates that play the same role, and prefer those for retries in production instead of hand-written loops.
throw; rethrows the current exception and keeps its original stack trace. throw ex; throws the same object as if it were new, so the stack trace is reset to the line of throw ex, and you lose where it really failed. Analyzer CA2200 flags it. To add context, throw a new exception with the original as InnerException. To rethrow an exception you captured earlier, for example from another thread, use ExceptionDispatchInfo.Capture(ex).Throw(), which keeps the original trace and appends the new one. Throw expressions (x ?? throw new ...) and guard helpers such as ArgumentNullException.ThrowIfNull keep validation short.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
How to tell it (STAR)
**Situation:** At [company] our worker crashed on database errors and every dump pointed at our retry helper, not the failing query. **Task:** I was asked to make these crashes diagnosable. **Action:** The helper caught every `SqlException`, checked for a deadlock, and rethrew the rest. That unwinds the stack before the decision, so the dump lost the original frames. I changed it to an exception filter that only catches deadlocks and timeouts while attempts remain. Everything else now stays uncaught, so the dump keeps the original stack and locals. I added tests for the retried error numbers. **Result:** The next crash dump showed [the exact query and parameters] immediately, and we fixed the root cause the same day. The pattern became our standard for conditional catches.
Last updated 30 Oct 2026.