Exception Handling in Depth

try/catch/finally, catching specific exception types, custom exceptions, and exception filters with when.

try/catch/finally

C# handles runtime errors with exceptions — an object (always deriving from System.Exception) thrown at the point of failure and propagated up the call stack until something catches it, or the program terminates with an unhandled exception:

C#
static double Divide(double a, double b)
{
    if (b == 0)
    {
        throw new DivideByZeroException("Cannot divide by zero");
    }
    return a / b;
}

try
{
    Console.WriteLine(Divide(10, 0));
}
catch (DivideByZeroException ex)
{
    Console.WriteLine($"Error: {ex.Message}");
}
finally
{
    Console.WriteLine("Division attempt finished");
}

finally always runs — whether the try block succeeded, threw, or returned early from inside it — making it the right place for cleanup that must happen no matter what:

C#
static string ReadConfig(string path)
{
    StreamReader? reader = null;
    try
    {
        reader = new StreamReader(path);
        return reader.ReadToEnd();
    }
    catch (FileNotFoundException)
    {
        return "{}";
    }
    finally
    {
        reader?.Dispose();   // runs whether ReadConfig succeeded, hit the catch, or returned early
    }
}

In practice, resources implementing IDisposable (like StreamReader above) are almost always better wrapped in a using statement instead of a manual try/finally — see the common-mistakes section below.

Catching specific exception types, most specific first

A try block can have multiple catch clauses, checked top to bottom — the runtime uses the first clause whose type matches (or is a base type of) the thrown exception, so more specific exception types must be listed before more general ones:

C#
static void ProcessFile(string path)
{
    try
    {
        var contents = File.ReadAllText(path);
        Console.WriteLine(contents.Length);
    }
    catch (FileNotFoundException)
    {
        Console.WriteLine($"{path} does not exist");
    }
    catch (UnauthorizedAccessException)
    {
        Console.WriteLine($"No permission to read {path}");
    }
    catch (IOException ex)
    {
        // catches any other I/O problem not already handled above
        Console.WriteLine($"I/O error: {ex.Message}");
    }
}

FileNotFoundException and UnauthorizedAccessException are both, ultimately, subtypes of IOException's broader exception hierarchy in .NET's I/O namespace — putting the generic catch (IOException ex) clause first would silently swallow both of the more specific cases beneath it, since the compiler would never even reach them. C# actually enforces this at compile time for exception types in a direct inheritance relationship: putting a base exception type's catch before its own subtype's catch is a compiler error, not just a logic bug waiting to happen.

Custom exception classes

A custom exception is a class deriving from Exception (or a more specific built-in exception, when that relationship genuinely applies), following the standard convention of three constructors matching the base class's own:

C#
public class InsufficientFundsException : Exception
{
    public decimal Balance { get; }
    public decimal Amount { get; }

    public InsufficientFundsException(decimal balance, decimal amount)
        : base($"Cannot withdraw {amount:C}: balance is only {balance:C}")
    {
        Balance = balance;
        Amount = amount;
    }
}

public class BankAccount
{
    public decimal Balance { get; private set; }

    public BankAccount(decimal balance) => Balance = balance;

    public void Withdraw(decimal amount)
    {
        if (amount > Balance)
        {
            throw new InsufficientFundsException(Balance, amount);
        }
        Balance -= amount;
    }
}

var account = new BankAccount(100m);
try
{
    account.Withdraw(250m);
}
catch (InsufficientFundsException ex)
{
    Console.WriteLine(ex.Message);              // Cannot withdraw $250.00: balance is only $100.00
    Console.WriteLine($"{ex.Balance} / {ex.Amount}");  // 100 / 250 — structured data, not just text
}

Attaching structured properties (Balance, Amount) directly to the exception — not just a formatted message — lets calling code inspect exactly what went wrong and decide how to respond, without parsing a string. A small hierarchy of related custom exceptions under one common base lets calling code catch broadly or narrowly:

C#
public abstract class BankingException : Exception
{
    protected BankingException(string message) : base(message) { }
}

public class InsufficientFundsException : BankingException
{
    public InsufficientFundsException(decimal balance, decimal amount)
        : base($"Cannot withdraw {amount:C}: balance is only {balance:C}") { }
}

public class AccountFrozenException : BankingException
{
    public AccountFrozenException(int accountId)
        : base($"Account {accountId} is frozen") { }
}

try
{
    account.Withdraw(250m);
}
catch (BankingException ex)
{
    // catches InsufficientFundsException, AccountFrozenException, or any future BankingException subclass
    Console.WriteLine($"Transaction failed: {ex.Message}");
}

Exception filters with when

A when clause adds a runtime condition to a catch — the clause only actually catches the exception if both the type matches and the condition is true; if the condition is false, C# keeps searching for another matching catch (or propagates the exception further) exactly as if that clause didn't match at all:

C#
try
{
    CallExternalApi();
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
    Console.WriteLine("Rate limited — back off and retry later");
}
catch (HttpRequestException ex) when ((int?)ex.StatusCode >= 500)
{
    Console.WriteLine("Server error — safe to retry");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Request failed: {ex.Message} — not retrying");
}

This is meaningfully different from catching the same type once and branching with if inside the block: with when, an exception that matches the type but fails the filter continues on to the next catch clause (or propagates further if nothing else matches), rather than being caught here and then handled with internal branching. It's the idiomatic way to react differently to the same exception type based on its actual content (a status code, an error code, a specific message) without swallowing cases you didn't actually intend to handle in this particular catch.

when is also useful purely for observing an exception without catching it — logging that something happened while still letting it propagate, by making the filter's condition always evaluate a side effect and return false:

C#
catch (Exception ex) when (LogAndReturnFalse(ex))
{
    // never actually reached — LogAndReturnFalse always returns false
}

static bool LogAndReturnFalse(Exception ex)
{
    Console.WriteLine($"Observed: {ex.Message}");
    return false;
}

using for guaranteed disposal

Anything implementing IDisposable (file streams, database connections, HTTP clients) should be wrapped in a using statement rather than manually calling .Dispose() in a finally block — using guarantees disposal even if an exception is thrown, with far less code:

C#
static string ReadConfig(string path)
{
    using var reader = new StreamReader(path);   // Dispose() called automatically at the end of scope
    return reader.ReadToEnd();
}

This modern "declaration" form of using (C# 8+) disposes the resource at the end of the enclosing block, rather than requiring an explicit { } scope — it's the idiomatic default for a resource that should live for the rest of the current method.

Common mistakes

  • Catching a broad Exception (or SystemException) when a specific exception type was actually expected — it hides genuine bugs (like a NullReferenceException from unrelated code) alongside the anticipated failure, and the compiler won't stop you the way it would with a misordered specific-then-general pair.
  • Manually calling .Dispose() in a finally block instead of using a using declaration or statement — it works, but it's more code, easier to get wrong (forgetting a null check before disposing), and using exists specifically to make this pattern automatic.
  • Using when as if it were just a stylistic alternative to an if inside the catch body — an exception that fails the when condition isn't handled by that clause at all; it falls through to the next catch or propagates further, which is a meaningfully different control flow than branching inside the block.
  • Swallowing an exception silently (an empty catch { }) with no logging — the failure disappears without a trace, making a production issue extremely difficult to diagnose after the fact.

Interview questions

Q: What does an exception filter (catch (SomeException ex) when (condition)) actually do differently from catching the exception and checking the condition with an if inside the block? With a when filter, the exception is only considered "caught" by that clause if the condition evaluates to true — if it's false, C# treats that clause as not matching at all and continues searching for another catch clause (or lets the exception propagate further if nothing else matches). Catching first and branching with if inside the block, by contrast, always catches the exception regardless of the condition, potentially swallowing a case that should have been handled by a different catch clause or propagated further instead.

Q: Why does C# require more specific exception types to be caught before more general ones in the same try statement? Because catch clauses are checked top to bottom and the runtime uses the first one whose type matches (including matching a base type), a general clause placed before a more specific one would always match first, making the specific clause beneath it unreachable dead code. When the types involved are in a direct inheritance relationship the C# compiler actually flags this ordering as a compile-time error, rather than letting it silently become a runtime logic bug.