The basics of structured exception handling in C#: catch ordering, finally guarantees and limits, and using as try/finally.
Code that might fail goes in try. When an exception is thrown, the runtime looks for the first matching catch, top to bottom, so put specific types before general ones (the compiler rejects the wrong order). finally runs when control leaves the try by any normal path: completing, return, break, or an exception that is caught here or further up the stack. It is for clean-up such as releasing locks or files. It does not run if the process dies: Environment.FailFast, a stack overflow, the process being killed, or, in practice, an exception that nothing catches. A using statement or declaration compiles to try { ... } finally { x.Dispose(); }.
try marks code that might throw; catch blocks handle specific exception types; finally runs cleanup whether or not an exception occurred. I'd explain how the runtime chooses a catch, the exact rules for when finally runs — including the cases where it doesn't — and how using is just compiler-generated try/finally around Dispose.
try
{
await using var connection = await _factory.OpenConnectionAsync(ct);
return await SaveOrderAsync(connection, order, ct);
}
catch (SqlException ex) when (ex.Number is 1205) // deadlock victim: retryable
{
_logger.LogWarning(ex, "Deadlock saving order {OrderId}", order.Id);
throw new TransientPersistenceException("Deadlock", ex);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // let cancellation flow
}
finally
{
_metrics.RecordSaveAttempt(); // runs in every case above
}
catch clauses are checked top to bottom; the first whose type matches (the exception is that type or derived from it) and whose when filter (if any) returns true handles it.catch (Exception) first makes later handlers unreachable — the compiler reports CS0160 for a catch that can never run because an earlier one catches a base type.catch { } and catch (Exception) catch everything that derives from Exception — in modern .NET, everything thrown is wrapped as an Exception (non-CLS exceptions become RuntimeWrappedException).StackOverflowException terminates the process; OutOfMemoryException usually leaves the process in a bad state; ThreadAbortException doesn't exist in .NET Core.finally runsfinally runs when control leaves the try (or a catch) by any normal or exceptional path:
try completes normally,catch in the same statement,return, break, continue or goto leave the block,await inside the try: the finally runs when the async method later leaves the try (possibly on another thread).finally does not runEnvironment.FailFast(...) — deliberate immediate termination.StackOverflowException — the process is killed.kill -9, Azure App Service restart without graceful shutdown, power loss).finally blocks on other frames (behaviour depends on whether a debugger/handler exists; don't rely on finally for unhandled crash cleanup).finally blocks inside the iterator don't run.So finally is for in-process cleanup, not for guaranteeing durable side effects — use transactions, idempotency and outbox patterns for those.
using is try/finallyusing (var stream = File.OpenRead(path)) { Process(stream); }
// compiles to
var stream = File.OpenRead(path);
try { Process(stream); }
finally { stream?.Dispose(); }
using var stream = ...;) dispose at the end of the enclosing scope, in reverse order of declaration.await using calls DisposeAsync() for IAsyncDisposable (e.g. DbConnection, Utf8JsonWriter over streams, ServiceProvider scopes).Dispose itself throws inside a finally during exception propagation, the original exception is lost (replaced by the new one) — another reason Dispose should not throw.A payment reconciliation job opened a SQL connection and a file stream, with Close() calls at the end of the try block instead of in finally/using. When a malformed file caused a FormatException halfway through, connections were never returned to the pool; after a few hours of bad files, the pool was exhausted (InvalidOperationException: Timeout expired... max pool size was reached) and all database work in the service stalled. Converting to await using for the connection and stream fixed it, and we added a catch (FormatException) that quarantined the bad file instead of failing the whole run.
Checks fundamentals of exception flow and resource clean-up.
finally runs and when it does not, links using to try/finally, and only catches what they can handle.finally always runs, swallows exceptions, or releases resources outside finally..NET uses a two-pass model (like Windows SEH):
catch clause, evaluating exception filters (when) along the way. No stack frames are unwound yet, and no finally blocks run.finally and fault blocks in each frame, then executes the catch block.This is why exception filters run before inner finally blocks, and why a filter sees the stack exactly as it was at the throw — useful for diagnostics and crash dumps.
.NET 9 replaced the runtime's C++ exception-handling implementation with a managed one (ported from NativeAOT), making throw/catch roughly 2–4× faster, but the semantics are the same.
Each method has an exception handling table listing protected regions (try ranges) and their handlers:
.try IL_0000 to IL_0020 catch System.IO.IOException handler IL_0020 to IL_0030
.try IL_0000 to IL_0030 finally handler IL_0030 to IL_0038
The C# compiler nests try/catch/finally as try { try { } catch { } } finally { }. catch ... when becomes a filter block (filter handler) that returns a decision. Because handlers are described by tables, a try block with no exception costs essentially nothing at run time — "zero-cost" exception handling. The cost is paid only when something is thrown.
returnstatic int Get()
{
int x = 1;
try { return x; } // return value (1) is captured here
finally { x = 2; } // runs, but doesn't change the returned value
}
The return value is stored in a temporary before finally runs. You cannot return from inside a finally in C# (CS0157), which prevents the Java pitfall where a finally return swallows exceptions.
finally or catchIf a finally block throws while an exception is already propagating, the new exception replaces the original — the original is lost unless captured. Same for a catch block that throws a different exception without passing the original as InnerException. Defensive patterns: keep finally blocks simple, make Dispose non-throwing, and when wrapping, pass the inner exception.
In an async method, try/catch/finally work across await: the compiler moves the bodies into the state machine's MoveNext, and exceptions from awaited tasks are rethrown at the await point (the first inner exception for faulted tasks). finally blocks run when the method's execution leaves the try — which may be much later and on a different thread. Exceptions thrown in an async Task method are stored in the returned Task; in async void methods they're raised on the SynchronizationContext or thread pool and typically crash the process.
using detailsIDisposable, using calls Dispose without boxing (constrained call).ref structs with a Dispose() method can be used in using without implementing the interface.using var scopes end at the enclosing block, so the resource lives until then — sometimes longer than intended; use a block-scoped using (...) { } when you need earlier release (e.g. before a long computation).using declarations are disposed in reverse order.catchException in library code and swallow it.int.Parse in a try instead of TryParse).Console.WriteLine in each finally and filter, throw from the innermost, and observe the order (filters run before finallys).try and modify it in finally.Environment.FailFast inside a try with a finally and observe that the finally doesn't run.Dispose method while another exception is propagating and see which one reaches the catch.private static readonly SemaphoreSlim Gate = new(1, 1);
public async Task<int> ImportAsync(string path, CancellationToken ct)
{
await Gate.WaitAsync(ct);
try
{
await using var stream = File.OpenRead(path); // try/finally + DisposeAsync
using var reader = new StreamReader(stream);
return await ParseAsync(reader, ct);
}
catch (FileNotFoundException ex) // most specific first
{
logger.LogWarning(ex, "Import file {Path} missing", path);
return 0; // fallback is a real decision
}
catch (IOException ex) // more general
{
throw new ImportFailedException(path, ex); // add context, keep inner
}
finally
{
Gate.Release(); // runs on success, return and exceptions
}
}
Exception firstWhat happens: catch (Exception) placed before more specific handlers.
Why it is wrong: the compiler rejects unreachable handlers (CS0160), or developers "fix" it by removing specific handling.
Do instead: order catch blocks from most specific to most general.
try instead of in finally/usingWhat happens: connection.Close() as the last line of the try block.
Why it is wrong: any exception skips cleanup — leaked connections, file handles and locks.
Do instead: use using/await using or a finally block.
What happens: catch (Exception) { } or catch { return null; }.
Why it is wrong: failures disappear; the system continues in an inconsistent state with no diagnostics.
Do instead: catch only what you can handle; otherwise let it propagate to a global handler that logs it.
finally for durable workWhat happens: sending a message, releasing a distributed lock or writing an audit record in finally.
Why it is wrong: finally doesn't run on process crashes, OOM kills or FailFast.
Do instead: use transactions, lock leases with expiry, outbox patterns and idempotent operations.
finally or DisposeWhat happens: cleanup code throws while another exception is propagating. Why it is wrong: the original, more important exception is lost. Do instead: keep cleanup non-throwing; catch and log inside cleanup if necessary.
throw ex;What happens: catch (Exception ex) { Log(ex); throw ex; }.
Why it is wrong: resets the stack trace, hiding where the error originated.
Do instead: use throw; or exception filters for logging.
What happens: repository, service and controller each catch, log and rethrow. Why it is wrong: one failure produces several log entries, confusing alerting and investigation. Do instead: log once at the boundary (global handler) and add context with scopes or wrapping.
OperationCanceledException as an errorWhat happens: cancellations caught by catch (Exception) and logged as errors or converted to 500s.
Why it is wrong: client disconnects and timeouts are expected; treating them as errors pollutes logs and alerts.
Do instead: let cancellation propagate or handle it explicitly with a filter on the token.
What happens: try { int.Parse(input) } catch { ... } or catching KeyNotFoundException.
Why it is wrong: exceptions are expensive and hide intent.
Do instead: use TryParse, TryGetValue and validation.
await inside tryWhat happens: try { return DoWorkAsync(); } catch { ... } without await.
Why it is wrong: the task is returned before it fails, so the catch never sees the exception; using resources may be disposed before the work completes.
Do instead: return await DoWorkAsync(); inside try/catch/using blocks.
Seniors treat finally as a clean-up guarantee within a living process, not a durability guarantee.
| Concern | Tool | Note |
|---|---|---|
| Dispose resources | using / await using |
compiler-generated finally |
| Release locks, reset state | finally |
keep it small, never throw |
| Translate or enrich errors | catch + rethrow with inner |
keep the original |
| Survive crashes | transactions, outbox, idempotency | finally is not enough |
| Unhandled exceptions | global handler + logging | process may still end |
They also know that catching is a design decision: each catch should state what recovery it performs, and code reviews should question any catch that does not recover, add context, or translate.
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.
In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.
Last updated 30 Oct 2026.