📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do try, catch and finally work in C#? In what order are catch blocks checked, when does finally run (and when does it not), and how do using statements relate to try/finally?

The basics of structured exception handling in C#: catch ordering, finally guarantees and limits, and using as try/finally.

Junior Theory Beginner error-handling exceptions finally try-catch using

🎯 Key points

  • Catch blocks are tested top to bottom; specific types must come before general ones
  • `finally` runs on normal completion, `return`, and caught or propagated exceptions
  • `finally` does not run on `FailFast`, stack overflow, process kill, or reliably for unhandled exceptions
  • An exception thrown in `finally` replaces the original exception
  • `using` and `await using` compile to `try/finally` that calls `Dispose`/`DisposeAsync`
  • Return values are copied before `finally` runs
  • Only catch exceptions you can handle, enrich, or translate
  • In async methods, exceptions surface when the task is awaited

⏱ 30-second answer

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(); }.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The basic shape

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
}

2. How catch blocks are chosen

  • 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.
  • So order must go from most specific to most general. Putting 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.
  • If no catch in the current method matches, the runtime searches up the call stack (caller's try blocks, then its caller's...). If nothing matches anywhere, the exception is unhandled: the process terminates (in ASP.NET Core, the request pipeline's exception middleware usually catches it first).
  • 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).
  • Some exceptions can't really be handled: StackOverflowException terminates the process; OutOfMemoryException usually leaves the process in a bad state; ThreadAbortException doesn't exist in .NET Core.

3. When finally runs

finally runs when control leaves the try (or a catch) by any normal or exceptional path:

  • the try completes normally,
  • an exception is caught by a catch in the same statement,
  • an exception propagates out (finally runs during unwinding, before outer handlers execute),
  • 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).

4. When finally does not run

  • Environment.FailFast(...) — deliberate immediate termination.
  • StackOverflowException — the process is killed.
  • The process is killed externally (container OOM-kill, kill -9, Azure App Service restart without graceful shutdown, power loss).
  • An unhandled exception in some cases: the runtime may terminate before running finally blocks on other frames (behaviour depends on whether a debugger/handler exists; don't rely on finally for unhandled crash cleanup).
  • An infinite loop or deadlock inside the try.
  • In an iterator: if the consumer never finishes or disposes the enumerator, finally blocks inside the iterator don't run.
  • A fire-and-forget task whose process exits first.

So finally is for in-process cleanup, not for guaranteeing durable side effects — use transactions, idempotency and outbox patterns for those.

5. using is try/finally

using (var stream = File.OpenRead(path)) { Process(stream); }
// compiles to
var stream = File.OpenRead(path);
try { Process(stream); }
finally { stream?.Dispose(); }
  • C# 8 using declarations (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).
  • If 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.

6. A production example

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.

🔍 What the interviewer is checking

Checks fundamentals of exception flow and resource clean-up.

  • Green flags: knows specific-before-general ordering, explains when finally runs and when it does not, links using to try/finally, and only catches what they can handle.
  • Red flags: believes finally always runs, swallows exceptions, or releases resources outside finally.

📚 Learn in depth

1. How the CLR finds a handler: two-pass exception handling

.NET uses a two-pass model (like Windows SEH):

  1. First pass (search): starting from the throwing frame, the runtime walks up the stack looking for a matching catch clause, evaluating exception filters (when) along the way. No stack frames are unwound yet, and no finally blocks run.
  2. Second pass (unwind): once a handler is found, the runtime unwinds frames from the throw point up to the handler, running 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.

2. Exception handling tables in IL

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.

3. Interaction with return

static 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.

4. Exceptions thrown inside finally or catch

If 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.

5. Async methods

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.

6. using details

  • For a struct implementing IDisposable, using calls Dispose without boxing (constrained call).
  • Pattern-based disposal: 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).
  • Multiple using declarations are disposed in reverse order.

7. What not to do in catch

  • Catch exceptions you can't handle meaningfully (log-and-rethrow at every layer creates duplicate logs).
  • Catch Exception in library code and swallow it.
  • Use exceptions for normal control flow (int.Parse in a try instead of TryParse).

Try it yourself

  1. Write nested try/finally blocks across three methods with Console.WriteLine in each finally and filter, throw from the innermost, and observe the order (filters run before finallys).
  2. Return a value from try and modify it in finally.
  3. Call Environment.FailFast inside a try with a finally and observe that the finally doesn't run.
  4. Throw from a Dispose method while another exception is propagating and see which one reaches the catch.

💻 Code

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
    }
}

⚠ Common mistakes

1. Catching Exception first

What 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.

2. Cleanup at the end of try instead of in finally/using

What 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.

3. Swallowing exceptions

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.

4. Relying on finally for durable work

What 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.

5. Throwing from finally or Dispose

What 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.

6. Catching and rethrowing with 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.

7. Log-and-rethrow at every layer

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.

8. Catching OperationCanceledException as an error

What 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.

9. Using try/catch for expected conditions

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.

10. Forgetting await inside try

What 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate rethrows from a catch block Why: Checks throw versus throw ex
    How to answer

    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.

  2. When: Candidate mentions exceptions nobody catches Why: Probes global and last-chance handling
    How to answer

    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.

↩ Usually asked after

More in Exception Handling

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me