Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 13 – Exceptions and resource cleanup

Exceptions: try, catch, finally and filters

Catch C# exceptions where the code can act, order catch clauses, rethrow without losing the stack trace, filter with when and clean up with finally.

  • Intermediate
  • 20 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Order catch clauses from the most specific exception type to the most general, and catch where the code can act
  • Pass an exception on with throw; or ExceptionDispatchInfo so its stack trace still starts at the original throw
  • Use an exception filter to choose a handler, or to log an exception without catching it
  • Predict when a finally block runs, and name the failures that end a process before it can
  • Choose int.TryParse over int.Parse in try and catch for input that is often invalid, from a measured cost

Before you start

What you’ll learn · Before you start

What you will learn

  • Order catch clauses from the most specific exception type to the most general, and catch where the code can act
  • Pass an exception on with throw; or ExceptionDispatchInfo so its stack trace still starts at the original throw
  • Use an exception filter to choose a handler, or to log an exception without catching it
  • Predict when a finally block runs, and name the failures that end a process before it can
  • Choose int.TryParse over int.Parse in try and catch for input that is often invalid, from a measured cost

Before you start

On this page

A statement file of four payment lines reaches line 2, UPI-1002,45O.00, and decimal.Parse throws: the letter O stands where a zero should be. Whether the import skips that line, stops cleanly or crashes with exit status 134 depends only on where a catch clause waits for the exception, and which one.

You will place catch clauses where the code can act, pass an exception on without losing its stack trace, filter exceptions with when, predict exactly when finally runs, and decide from a measured cost when a Try method should replace the exception.

A thrown exception climbs the calls to the first catch that matches

travel.cs imports four lines. Statement.Import adds up the amounts that ParseAmount reads and catches FormatException around each call, and the top-level code catches any Exception around the whole import:

travel.cs: two exceptions, caught in two places C# · travel/travel.cs
using System.Globalization;

// One exception travels up the calls until a catch clause matches it.
string[] lines = ["UPI-1001,450.00", "UPI-1002,45O.00", "UPI-1003", "UPI-1004,120.50"];

try
{
    decimal total = Statement.Import(lines);
    Console.WriteLine($"total: {total}");
}
catch (Exception e)
{
    Console.WriteLine($"top level: the import stopped with {e.GetType().Name}");
}

static class Statement
{
    public static decimal Import(string[] lines)
    {
        decimal total = 0;
        for (int i = 0; i < lines.Length; i++)
        {
            try
            {
                total += ParseAmount(lines[i]);
                Console.WriteLine($"line {i + 1}: added");
            }
            catch (FormatException e)
            {
                Console.WriteLine($"line {i + 1}: skipped. {e.Message}");
            }
        }

        return total;
    }

    static decimal ParseAmount(string line)
    {
        string text = line.Split(',')[1];
        decimal amount = decimal.Parse(text, CultureInfo.InvariantCulture);
        Console.WriteLine($"  ParseAmount read {amount}");
        return amount;
    }
}

Output

  ParseAmount read 450.00
line 1: added
line 2: skipped. The input string '45O.00' was not in a correct format.
top level: the import stopped with IndexOutOfRangeException

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run travel.cs

A throw, here inside decimal.Parse, stops the method at once and raises an exception: An object of a type derived from System.Exception that a throw raises when an operation cannot complete. The runtime then searches the waiting calls, newest first, for a catch clause that accepts its type.. The runtime then searches the calls that are still waiting, newest first, for a catch clause: The part of a try statement that names an exception type, as in catch (FormatException e), with the block that runs when an exception of that type, or of a type derived from it, reaches the try statement. whose type is the exception’s type or a base of it. The first one it finds handles the exception, and every frame above it is left. Step through the run:

Interactive · message by message Where the exceptions of travel.cs are caught Step through the four lines of the statement. Watch which method's catch clause takes each exception, and which frames are left on the way.
Where the exceptions of travel.cs are caught, step 8 of 8. Messages so far: Top-level code to Import: Import(lines); Import to ParseAmount: ParseAmount("UPI-1001,450.00"); ParseAmount to Import: FormatException; Import to ParseAmount: ParseAmount("UPI-1003"); Import to Top-level code: IndexOutOfRangeException.Top-level codecaught: import stoppedImportcaught line 2ParseAmountthrows at decimal.ParseImport(lines)ParseAmount("UPI-1001,450.00")FormatExceptionParseAmount("UPI-1003")IndexOutOfRangeException

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The import starts. The top-level code calls Statement.Import with four lines, inside a try statement whose catch clause takes any Exception.
  2. Line 1 parses. Import calls ParseAmount for line 1 inside its own try statement. decimal.Parse reads 450.00, ParseAmount prints it and returns, and Import prints line 1: added.
  3. Line 2 throws. For line 2, decimal.Parse throws FormatException, because 45O.00 has the letter O where a zero should be. The line after it in ParseAmount, the one that prints, never runs.
  4. The search leaves ParseAmount. The runtime looks for a catch clause, starting in the method that threw. ParseAmount has no try statement, so its frame is left and the search moves to its caller.
  5. Import's clause matches. The call sits inside a try statement of Import whose catch clause takes FormatException. The type matches, so that catch block runs: it prints line 2: skipped, and the loop goes on to line 3.
  6. Line 3 throws another type. Line 3 is UPI-1003, with no comma, so Split gives one part, and the [1] after it throws IndexOutOfRangeException inside ParseAmount, before decimal.Parse runs.
  7. No clause of Import matches. Import's only catch clause takes FormatException, and IndexOutOfRangeException is not one. The search leaves Import too, in the middle of its loop, so line 4 is never read.
  8. The top level catches it. catch (Exception) accepts every exception, so the top-level catch block runs and prints that the import stopped. Import never returned, so no total is printed.

Line 2’s FormatException was handled by Import, which went on to line 3. Line 3’s IndexOutOfRangeException passed Import’s clause, because it is not a FormatException, and reached the top level: line 4 was never read and no total was printed. That is what catching at the right level means. Import can act on a bad amount by skipping its line, so it catches FormatException; it cannot act on a line it was never written to expect, so that exception goes up to code that can stop the import and report it.

Within one try statement the runtime tests the catch clauses from top to bottom and runs at most one, so a clause for a base type placed first would take every exception meant for the clauses below it. The compiler refuses that order. order puts catch (Exception) before catch (FormatException):

order/Program.cs C# · order/Program.cs
using System.Globalization;

// The catch clauses in the wrong order: the general one comes first.
string[] lines = ["UPI-1001,450.00", "UPI-1002,45O.00"];
foreach (string line in lines)
{
    try
    {
        decimal amount = decimal.Parse(line.Split(',')[1], CultureInfo.InvariantCulture);
        Console.WriteLine($"added {amount}");
    }
    catch (Exception e)
    {
        Console.WriteLine($"stopped: {e.Message}");
    }
    catch (FormatException)
    {
        Console.WriteLine("skipped: not an amount");
    }
}
Building order Python · order_session.py
from session import run

# order puts catch (Exception) before catch (FormatException). It is built once.
run("""
cd order
!dotnet build -v q
""")

Output

$ cd order
$ dotnet build -v q
order/Program.cs(16,12): error CS0160: A previous catch clause already catches all exceptions of this or of a super type ('Exception') [order/App.csproj]

Build FAILED.

order/Program.cs(16,12): error CS0160: A previous catch clause already catches all exceptions of this or of a super type ('Exception') [order/App.csproj]
    0 Warning(s)
    1 Error(s)

Time Elapsed …
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_session.py

Error CS0160 says that a previous catch clause already catches all exceptions of this type. Put the most specific type first, and catch (Exception) last, only where a failure of any kind can be reported: the top level of an app, of a request or of a job.

Catching Exception deep inside

A catch (Exception) inside Import would also have taken line 3’s IndexOutOfRangeException, skipped the line and printed a total as if the statement were sound. Catch the types you expect where you can act on them, and let the rest reach the top level.

Checkpoint

Check: Which catch clause handles an exception, and where to catch

  1. Question 1 of 2 In travel.cs, suppose line 3 were "UPI-1003,12O.00" instead of "UPI-1003". What would the run print for line 3, and would line 4 be read?

    Choose one answer.

    A hint

    Which exception does decimal.Parse throw for 12O.00, and which clause of Import accepts it?

    Show the answer: Question 1 of 2

    Answer: line 3: skipped, and line 4 is read and added

    The runtime looks for the first catch clause up the calls whose type matches. A FormatException from line 3 meets Import's catch (FormatException) first, so only that line is skipped.

  2. Question 2 of 2 Why does the compiler refuse catch (Exception) placed before catch (FormatException) in the same try statement?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The first clause would take every FormatException, so the second could never run: error CS0160

    Order catch clauses from the most derived type to the least derived. The compiler enforces it with CS0160, as the build of order showed.

throw; keeps the stack trace, throw e; restarts it

A catch block that cannot fully handle an exception often logs it and passes it on, and how it passes it on decides what the next reader of the stack trace sees. rethrow.cs makes Gateway.Charge throw ArgumentOutOfRangeException for a negative amount, catches it one level up in four ways, and prints the frames of Gateway in each stack trace: The list of method calls that were running when an exception was thrown, newest first, each with its file and line when debug symbols are available. An unhandled exception prints it to standard error. More in “Compiler errors, stack traces and debugging”:

rethrow.cs: four ways to pass an exception on C# · rethrow/rethrow.cs
using System.Runtime.ExceptionServices;

// Four ways to pass an exception on, and the stack trace each one leaves.
Show("throw;", Gateway.PassOn);
Show("throw e;", Gateway.ThrowAgain);
Show("throw new ... (wrapping e)", Gateway.Wrap);
Show("ExceptionDispatchInfo, after the catch", Gateway.RethrowLater);

static void Show(string how, Action call)
{
    try
    {
        call();
    }
    catch (Exception e)
    {
        Console.WriteLine($"{how}: {e.GetType().Name}");
        PrintFrames(e);
        if (e.InnerException is Exception inner)
        {
            Console.WriteLine($"  inner exception: {inner.GetType().Name}");
            PrintFrames(inner);
        }
    }
}

// Prints the frames of the Gateway class, and the line that marks a rethrow.
static void PrintFrames(Exception e)
{
    foreach (string line in e.StackTrace!.Split('\n'))
    {
        string frame = line.Trim();
        if (frame.StartsWith("at Gateway.") || frame.StartsWith("---"))
        {
            Console.WriteLine($"    {frame}");
        }
    }
}

static class Gateway
{
    public static void PassOn()
    {
        try
        {
            Charge(-50m);
        }
        catch (ArgumentOutOfRangeException)
        {
            throw;
        }
    }

    public static void ThrowAgain()
    {
        try
        {
            Charge(-50m);
        }
        catch (ArgumentOutOfRangeException e)
        {
            throw e;
        }
    }

    public static void Wrap()
    {
        try
        {
            Charge(-50m);
        }
        catch (ArgumentOutOfRangeException e)
        {
            throw new InvalidOperationException("Payment UPI-1007 could not be charged.", e);
        }
    }

    public static void RethrowLater()
    {
        ExceptionDispatchInfo? failure = null;
        try
        {
            Charge(-50m);
        }
        catch (ArgumentOutOfRangeException e)
        {
            failure = ExceptionDispatchInfo.Capture(e);
        }

        failure?.Throw();
    }

    static void Charge(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "An amount to charge cannot be negative.");
        }
    }
}

Output

throw;: ArgumentOutOfRangeException
    at Gateway.Charge(Decimal amount) in ./rethrow.cs:line 97
    at Gateway.PassOn() in ./rethrow.cs:line 46
throw e;: ArgumentOutOfRangeException
    at Gateway.ThrowAgain() in ./rethrow.cs:line 62
throw new ... (wrapping e): InvalidOperationException
    at Gateway.Wrap() in ./rethrow.cs:line 74
  inner exception: ArgumentOutOfRangeException
    at Gateway.Charge(Decimal amount) in ./rethrow.cs:line 97
    at Gateway.Wrap() in ./rethrow.cs:line 70
ExceptionDispatchInfo, after the catch: ArgumentOutOfRangeException
    at Gateway.Charge(Decimal amount) in ./rethrow.cs:line 97
    at Gateway.RethrowLater() in ./rethrow.cs:line 83
    --- End of stack trace from previous location ---
    at Gateway.RethrowLater() in ./rethrow.cs:line 90

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run rethrow.cs

With throw; the trace starts at line 97, the throw inside Charge, and goes on to line 46, where PassOn called it: the whole way back to the real failure. With throw e; it starts at line 62, the rethrow: To pass a caught exception on from its catch block. throw; keeps the exception's stack trace; throw e; restarts the trace at that line, and the frames below it are lost., and the frame of Charge is gone. Microsoft’s reference states the rule: throw; preserves the original stack trace, and throw e; updates it. The SDK’s analyzer reports throw e; as warning CA2200, which is on by default in .NET 10.

Form The trace of what the caller catches starts at Use it when
throw; line 97, the original throw you log or clean up, then let the same exception go on
throw e; line 62, the rethrow never: it is warning CA2200
throw new InvalidOperationException(…, e) line 74, with the original, from line 97, in InnerException the caller needs another type or message, with the cause kept
ExceptionDispatchInfo.Capture(e), then Throw() line 97, then line 90 after a marker line the exception is thrown again later, outside its catch block

Wrapping changes what the caller catches: an InvalidOperationException with a message about the payment, and the original exception, with its own full trace, in InnerException. ExceptionDispatchInfo serves code that keeps an exception and throws it after its catch block has ended, such as a retry loop that gives up. The line --- End of stack trace from previous location --- marks where the original trace ends and the rethrow begins.

Checkpoint

Check: throw;, throw e;, wrapping and ExceptionDispatchInfo

  1. Question 1 of 2 In rethrow.cs, ThrowAgain catches the exception from Charge and runs throw e;. Where does the trace that Show prints start, and what is lost?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: At line 62, the throw e;, and the frame of Charge at line 97 is lost

    throw; keeps the original stack trace and throw e; updates it; the analyzer reports the second form as CA2200.

  2. Question 2 of 2 A retry loop catches the exception of each attempt and keeps it in a variable. After the third failure, outside any catch block, it must throw the last one with its original stack trace. What do you write?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Capture it with ExceptionDispatchInfo.Capture(e) in the catch block, and call Throw() on it after the loop

    Inside a catch block, throw; passes the exception on. Outside it, capture the exception while it is caught and throw it later with ExceptionDispatchInfo.Throw().

An exception filter decides before the stack unwinds

catch (IOException) when (CanRetry(file)) adds an exception filter: A when clause after a catch type, as in catch (IOException) when (retries > 0): the clause handles the exception only if the condition is true. The condition runs before the stack unwinds.: the clause handles the exception only if the condition after when is true. filters.cs uploads four files that the share refuses. Sync.Upload has a finally block, and every case prints when its filter, the finally block and its catch block run:

filters.cs: when a filter runs, and when a catch block runs C# · filters/filters.cs
// A filter runs before the stack unwinds; a catch block runs after it.
Console.WriteLine("1. A filter that returns true");
try
{
    Sync.Upload("0412.csv");
}
catch (IOException) when (CanRetry("0412.csv"))
{
    Console.WriteLine("   catch block: 0412.csv goes back in the queue");
}

Console.WriteLine("2. A filter that logs and returns false");
try
{
    try
    {
        Sync.Upload("0413.csv");
    }
    catch (Exception e) when (Log(e))
    {
        Console.WriteLine("   never printed: Log returns false");
    }
}
catch (IOException e)
{
    Console.WriteLine($"   outer catch block: {e.Message}");
}

Console.WriteLine("3. A catch block that logs and rethrows");
try
{
    try
    {
        Sync.Upload("0414.csv");
    }
    catch (IOException e)
    {
        Console.WriteLine($"   catch block logs: {e.Message}");
        throw;
    }
}
catch (IOException e)
{
    Console.WriteLine($"   outer catch block: {e.Message}");
}

Console.WriteLine("4. A filter that throws");
try
{
    try
    {
        Sync.Upload("0415.csv");
    }
    catch (IOException) when (RetriesLeft() > 0)
    {
        Console.WriteLine("   never printed: the filter threw");
    }
}
catch (IOException e)
{
    Console.WriteLine($"   outer catch block: {e.Message}");
}

static bool CanRetry(string file)
{
    Console.WriteLine($"   filter: {file} has 2 retries left, so this clause catches");
    return true;
}

static bool Log(Exception e)
{
    Console.WriteLine($"   filter logs: {e.GetType().Name}: {e.Message}");
    return false;
}

// A bug: the retry setting was typed as a word, so int.Parse throws inside the filter.
static int RetriesLeft()
{
    string setting = "three";
    Console.WriteLine($"   filter parses the retry setting \"{setting}\", and int.Parse throws");
    return int.Parse(setting);
}

static class Sync
{
    public static void Upload(string file)
    {
        try
        {
            Console.WriteLine($"   Upload opens {file}");
            throw new IOException($"The share did not accept {file}.");
        }
        finally
        {
            Console.WriteLine($"   Upload's finally closes {file}");
        }
    }
}

Output

1. A filter that returns true
   Upload opens 0412.csv
   filter: 0412.csv has 2 retries left, so this clause catches
   Upload's finally closes 0412.csv
   catch block: 0412.csv goes back in the queue
2. A filter that logs and returns false
   Upload opens 0413.csv
   filter logs: IOException: The share did not accept 0413.csv.
   Upload's finally closes 0413.csv
   outer catch block: The share did not accept 0413.csv.
3. A catch block that logs and rethrows
   Upload opens 0414.csv
   Upload's finally closes 0414.csv
   catch block logs: The share did not accept 0414.csv.
   outer catch block: The share did not accept 0414.csv.
4. A filter that throws
   Upload opens 0415.csv
   filter parses the retry setting "three", and int.Parse throws
   Upload's finally closes 0415.csv
   outer catch block: The share did not accept 0415.csv.

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run filters.cs

In cases 1, 2 and 4 the filter printed its line before Upload’s finally block closed the file. In case 3, a catch block with no filter, the finally block ran first. The runtime handles an exception in two passes: it first searches for a handler, running each filter it meets while every frame is still there, and only then unwinds, leaving the frames and running their finally blocks. That stack unwinding: Leaving the frames of the calls between a throw and the catch clause that handles it, running their finally blocks on the way. It happens after the runtime has found the handler and before its catch block runs. comes before the catch block runs. Step through case 1:

Interactive · message by message The two passes of case 1 in filters.cs Step through one exception. The first pass finds a handler and runs its filter; the second pass unwinds the calls and only then runs the catch block.
The two passes of case 1 in filters.cs, step 7 of 7. Messages so far: Top-level code to Sync.Upload: Upload("0412.csv"); The network share to Sync.Upload: refused; Sync.Upload to Top-level code: IOException.Top-level codecatch block: back in queueSync.Uploadtry and finallyThe network sharerefuses the fileUpload("0412.csv")refusedIOException

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Upload opens the file. The top-level code calls Sync.Upload("0412.csv") inside a try statement. Upload prints that it opens the file, inside its own try block.
  2. The upload fails. The share does not accept the file, and Upload throws IOException from inside its try block, which has a finally block but no catch clause.
  3. Pass 1: the search. The runtime searches up the calls for a clause that accepts IOException. Upload has none. The top-level clause has the right type and a filter, so the runtime runs the filter now.
  4. Every frame is still there. While the filter runs, Upload's frame has not been left and its finally block has not run: the run printed the filter's line before the finally block's line. A debugger stopped here would still see the throw.
  5. The filter says true. CanRetry returns true, so the search is over: this clause will handle the exception. Had it returned false, the search would have gone on to the next clause, as case 2 shows.
  6. Pass 2: the unwind. Now the runtime unwinds: it leaves Upload's frame and runs its finally block on the way, which prints that it closes 0412.csv.
  7. The catch block runs last. Only after the unwind does the catch block run, in the top-level code: it puts 0412.csv back in the queue. A catch block that tests a condition and rethrows would run after the unwind, too late to keep the frames.

Two uses follow from the order. A filter chooses between clauses of the same type by a condition, such as the retries left, without catching and rethrowing. A filter that logs and returns false, as Log does in case 2, records every exception with its stack still whole, catches nothing, and lets the search go on to the clause that really handles it, here the outer catch (IOException). Microsoft’s reference adds that a filter which is false leaves the stack trace unchanged.

A filter that throws

In case 4 the filter’s int.Parse("three") threw, and the runtime treated the filter as false: the outer clause caught the IOException, and the FormatException vanished without a trace. Code analysis rule CA2219 flags exceptions raised in filters. Keep filters to simple tests that cannot throw.

Checkpoint

Check: Exception filters and the two passes

  1. Question 1 of 2 In case 1 of filters.cs, which line was printed first: the filter's line or the line of Upload's finally block, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The filter's line: filters run while the runtime searches for a handler, before it unwinds and runs finally blocks

    The runtime handles an exception in two passes: it searches, running filters, then it unwinds, running finally blocks, and only then runs the catch block it found.

  2. Question 2 of 2 What does catch (Exception e) when (Log(e)) do when Log writes the exception to a log and always returns false?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It logs every exception and catches none: the search goes on to the next clause that accepts the exception

    A filter that logs and returns false records every exception with its stack still whole, without changing which clause handles it.

finally runs on every way out of a try block

A finally block: The block of a try statement that runs whenever control leaves the try block: at its end, on return, break or continue, or when an exception passes through. A process ended at once, as by Environment.FailFast, skips it. holds code that must run however a try block ends: closing a file, releasing a lock, resetting a busy flag. finally.cs leaves try blocks in four ways:

finally.cs: return, continue, break and an exception C# · finally/finally.cs
// finally runs on every way out of a try block: return, continue, break and an exception.
Console.WriteLine($"Seats() returned {Seats()}");

for (int pass = 1; pass <= 3; pass++)
{
    try
    {
        if (pass == 2)
        {
            continue;
        }

        if (pass == 3)
        {
            break;
        }

        Console.WriteLine($"pass {pass}: the body ran to its end");
    }
    finally
    {
        Console.WriteLine($"pass {pass}: finally");
    }
}

try
{
    Receipt.Print();
}
catch (Exception e)
{
    Console.WriteLine($"caught {e.GetType().Name}: {e.Message}");
}

static int Seats()
{
    int seats = 2;
    try
    {
        return seats;
    }
    finally
    {
        seats = 99;
        Console.WriteLine("finally, after return seats: seats is now 99");
    }
}

static class Receipt
{
    public static void Print()
    {
        try
        {
            throw new InvalidOperationException("Payment UPI-1009 failed.");
        }
        finally
        {
            throw new IOException("The receipt file could not be closed.");
        }
    }
}

Output

finally, after return seats: seats is now 99
Seats() returned 2
pass 1: the body ran to its end
pass 1: finally
pass 2: finally
pass 3: finally
caught IOException: The receipt file could not be closed.

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run finally.cs

The finally block of Seats ran after return seats; had already taken the value 2, so setting seats to 99 there did not change what the method returned. In the loop it ran on every pass, whether the body ran to its end, continue skipped the rest or break left the loop. In Receipt.Print the try block threw InvalidOperationException, and the finally block threw IOException while that exception was on its way out: the caller caught only the IOException, and the failed payment was lost. Microsoft’s best practices say not to throw from finally blocks.

The edges are the runs in which the process itself ends. Two of them:

unhandled.cs: an exception that nothing catches C# · unhandled/unhandled.cs
// No catch clause anywhere: the exception is unhandled. Does the finally block run, and when?
// Every line goes to standard error, where the runtime reports the exception, so the order is kept.
Console.Error.WriteLine("import started");
try
{
    throw new InvalidOperationException("Statement 0415.csv has no header line.");
}
finally
{
    Console.Error.WriteLine("finally: temporary files deleted");
}

Output (exit status 134)

Printed as an error (standard error)

import started
Unhandled exception. System.InvalidOperationException: Statement 0415.csv has no header line.
   at Program.<Main>$(String[] args) in ./unhandled.cs:line 6
finally: temporary files deleted

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run unhandled.cs

failfast.cs: Environment.FailFast inside a try block C# · failfast/failfast.cs
// Environment.FailFast ends the process at once: no catch clause and no finally block runs.
try
{
    Console.WriteLine("writing the ledger");
    Environment.FailFast("The ledger is corrupt; stopping before it is written again.");
}
catch (Exception e)
{
    Console.WriteLine($"catch: {e.Message}");
}
finally
{
    Console.WriteLine("finally: ledger closed");
}

Output (exit status 134)

writing the ledger

Printed as an error (standard error)

Process terminated.
The ledger is corrupt; stopping before it is written again.
   at System.Environment.FailFast(System.Runtime.CompilerServices.StackCrawlMarkHandle, System.String, System.Runtime.CompilerServices.ObjectHandleOnStack, System.String)
   at System.Environment.FailFast(System.Threading.StackCrawlMark ByRef, System.String, System.Exception, System.String)
   at System.Environment.FailFast(System.String)
   at Program.<Main>$(System.String[])

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run failfast.cs

With no catch clause anywhere, the runtime first reported the unhandled exception and then, in this run on .NET 10 on macOS, ran the finally block before it ended the process with exit status 134. The C# reference promises nothing for that case: whether finally blocks run depends on whether an unwind happens at all. Environment.FailFast printed Process terminated. with its message and ended the process at once, with exit status 134; neither its catch block nor its finally block ran. Its documentation says it ends a process without running any active try and finally blocks, and it exists to fail fast: To end a process at once, as Environment.FailFast does, without running catch or finally blocks, when going on could do more harm, such as writing a damaged file again. when going on would do more harm.

How control leaves the try block Does finally run? Seen in
the block ends, return, break, continue yes finally.cs
an exception that a catch clause up the calls handles yes, before that catch block filters.cs
an exception that nothing catches not promised; it ran in this run unhandled.cs
Environment.FailFast, a stack overflow, a killed process no failfast.cs

Key idea

A finally block runs whenever control leaves its try block, and before any catch block further up; it is not promised when nothing catches the exception, and it never runs when the process is ended at once.

Checkpoint

Check: When a finally block runs, and when it cannot

  1. Question 1 of 2 In finally.cs, Seats() returns seats from its try block, and its finally block then sets seats to 99. What does the call return?

    Type a number.

    A hint

    When does return seats; read the value of seats?

    Show the answer: Question 1 of 2

    Answer: 2 seats

    return seats; reads 2 before control leaves the try block, and the finally block runs afterwards. Changing the local variable there does not change the value already being returned: the run printed Seats() returned 2.

  2. Question 2 of 2 Receipt.Print's try block throws InvalidOperationException, and its finally block throws IOException. What does the caller's catch (Exception e) receive?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only the IOException; the InvalidOperationException is lost

    Don't throw from finally blocks: the new exception hides the original failure, which is why CA2219 exists.

A throw costs hundreds of times more than a false from TryParse

Exceptions are for failures that are not part of normal work, and a field that people type wrong every day is normal work. cost.cs parses 100,000 strings such as 7O, each with a letter O at the end, first with int.Parse inside try and catch, then with int.TryParse, the Try pattern: A method named TrySomething that returns true or false for success and hands back its result through an out parameter, such as int.TryParse, beside a method that throws for bad input. More in “ref, out, in and ref readonly parameters” that returns false instead of throwing; then it parses 100,000 valid numbers both ways. Each line is the fastest of three runs:

cost.cs: 100,000 failed parses, two ways C# · cost/cost.cs
using System.Diagnostics;

// 100,000 inputs parsed two ways: int.Parse inside try and catch, and int.TryParse.
const int Count = 100_000;
string[] typos = [.. Enumerable.Range(0, Count).Select(i => $"{i}O")];
string[] numbers = [.. Enumerable.Range(0, Count).Select(i => $"{i}")];

// A first, short run of each way on each kind of input, so that no timed run includes the JIT compiling it.
foreach (string[] inputs in new[] { typos[..2000], numbers[..2000] })
{
    WithCatch(inputs);
    WithTry(inputs);
}

Console.WriteLine($"{Count:N0} inputs that are not numbers, such as \"{typos[7]}\":");
var caught = Fastest(() => WithCatch(typos));
var refused = Fastest(() => WithTry(typos));
Console.WriteLine($"  int.Parse in try/catch: {caught.Failures:N0} failures in {caught.Ms:N1} ms, {caught.Ms * 1000 / Count:N2} µs each");
Console.WriteLine($"  int.TryParse:           {refused.Failures:N0} failures in {refused.Ms:N1} ms, {refused.Ms * 1000 / Count:N2} µs each");
Console.WriteLine($"  int.TryParse was {caught.Ms / refused.Ms:N0} times faster");

Console.WriteLine($"{Count:N0} inputs that are numbers:");
var parsed = Fastest(() => WithCatch(numbers));
var tried = Fastest(() => WithTry(numbers));
Console.WriteLine($"  int.Parse in try/catch: {parsed.Failures:N0} failures in {parsed.Ms:N1} ms");
Console.WriteLine($"  int.TryParse:           {tried.Failures:N0} failures in {tried.Ms:N1} ms");

// The fastest of three runs: a run that the computer slowed down for other work does not count.
static (int Failures, double Ms) Fastest(Func<(int Failures, double Ms)> run)
{
    var best = run();
    for (int i = 0; i < 2; i++)
    {
        var next = run();
        if (next.Ms < best.Ms)
        {
            best = next;
        }
    }

    return best;
}

static (int Failures, double Ms) WithCatch(string[] inputs)
{
    var clock = Stopwatch.StartNew();
    int failures = 0;
    foreach (string text in inputs)
    {
        try
        {
            int.Parse(text);
        }
        catch (FormatException)
        {
            failures++;
        }
    }

    return (failures, clock.Elapsed.TotalMilliseconds);
}

static (int Failures, double Ms) WithTry(string[] inputs)
{
    var clock = Stopwatch.StartNew();
    int failures = 0;
    foreach (string text in inputs)
    {
        if (!int.TryParse(text, out _))
        {
            failures++;
        }
    }

    return (failures, clock.Elapsed.TotalMilliseconds);
}

Output

100,000 inputs that are not numbers, such as "7O":
  int.Parse in try/catch: 100,000 failures in 919.7 ms, 9.20 µs each
  int.TryParse:           100,000 failures in 1.3 ms, 0.01 µs each
  int.TryParse was 713 times faster
100,000 inputs that are numbers:
  int.Parse in try/catch: 0 failures in 1.0 ms
  int.TryParse:           0 failures in 1.0 ms

This output changes from run to run: the times depend on the computer and on what else it is doing, so every run prints other numbers; the counts never change

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run cost.cs

On the run shown, each throw and catch cost 9.20 µs against 0.01 µs for a false from int.TryParse, 713 times as much: 919.7 ms for 100,000 failures against 1.3 ms. With valid numbers both ways took 1.0 ms, so a try block that nothing is thrown through costs next to nothing. The times change from computer to computer; a gap of hundreds of times does not. Microsoft’s best practices draw the same line: check in code for conditions that happen routinely, with an if or a Try method, and keep exception handling for events that are rare and truly exceptional.

Key idea

Use a Try method or a check for failures that happen in normal use, and exceptions for the ones that should not happen: in this run a throw and catch cost hundreds of times more than a false.

Checkpoint

Check: The cost of a throw against a Try method

  1. Question 1 of 2 In the run of cost.cs shown, 100,000 failed parses took 919.7 ms with int.Parse inside try and catch. At that rate, how many seconds would 1,000,000 failed parses a night take?

    Type a number.

    A hint

    Ten times as many failures, each at about the same cost.

    Show the answer: Question 1 of 2

    Answer: 9.2 seconds (anything from 9 to 9.4 counts)

    919.7 ms for 100,000 is 9.20 µs each, so 1,000,000 failures take about 9.2 s of throwing and catching. The same failures as a false from int.TryParse took 1.3 ms per 100,000: about 13 ms for a million.

  2. Question 2 of 2 A delivery form takes a six-digit PIN code, and about 1 user in 20 types it wrong. How should the form parse it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: With int.TryParse, showing a message when it returns false

    Check in code for conditions that happen routinely, with an if or a Try method, and keep exceptions for the rare failures that a caller cannot expect.

Practise: an importer that retries once and never swallows errors

importer.cs checks an Import method that you write. It reads the lines of a statement through a function that stands in for a network share, and its rules are at the top of the file: record an amount that is not a number, try a busy share once more, and let every other exception reach the caller. As it starts, Import throws NotImplementedException, so all six checks fail:

importer.cs: six checks of Import C# · importer/importer.cs
using System.Globalization;

// Practice: write Import, then run this file until all six checks pass.
// Import reads lines 1 to lineCount of a statement through read, which stands in for a file on a network share,
// adds up the amounts and lists the lines it could not use. The rules:
//   an amount that is not a number is recorded as "line N: not an amount", and the import goes on;
//   a read that throws ShareBusyException is tried once more; a line still busy is recorded as "line N: share busy";
//   any other exception is not Import's to handle, so it reaches the caller unchanged.
(string Check, Func<string> Run, string Expected)[] checks =
[
    ("a clean statement", () => Describe(Import(3, Lines(["450.00", "120.50", "450.00"]))), "1020.50, no failures"),
    ("an amount that is not a number", () => Describe(Import(3, Lines(["450.00", "45O.00", "120.50"]))), "570.50, line 2: not an amount"),
    ("the share is busy once", () => Describe(Import(2, Lines(["450.00", "120.50"], busyReads: 1))), "570.50, no failures"),
    ("the share stays busy", () => Describe(Import(2, Lines(["450.00", "120.50"], busyReads: 2))), "450.00, line 2: share busy"),
    ("an unexpected error", () => Describe(Import(2, Lines(["450.00", "120.50"], broken: true))), "throws InvalidOperationException"),
    ("an empty statement", () => Describe(Import(0, Lines([]))), "0, no failures"),
];

int passed = 0;
foreach (var (check, run, expected) in checks)
{
    string got;
    try
    {
        got = run();
    }
    catch (Exception e)
    {
        got = $"throws {e.GetType().Name}";
    }

    bool ok = got == expected;
    if (ok)
    {
        passed++;
    }

    Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}

Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;

// The sum of the amounts, and one entry for each line that could not be used.
static (decimal Total, List<string> Failures) Import(int lineCount, Func<int, string> read)
{
    throw new NotImplementedException();
}

static string Describe((decimal Total, List<string> Failures) result) =>
    $"{result.Total}, {(result.Failures.Count == 0 ? "no failures" : string.Join("; ", result.Failures))}";

// A stand-in for the share: line 2 throws ShareBusyException on its first busyReads reads, or
// InvalidOperationException when broken is true.
static Func<int, string> Lines(string[] amounts, int busyReads = 0, bool broken = false)
{
    int busyLeft = busyReads;
    return line =>
    {
        if (line == 2 && broken)
        {
            throw new InvalidOperationException("The statement format changed.");
        }

        if (line == 2 && busyLeft > 0)
        {
            busyLeft--;
            throw new ShareBusyException("The share is busy.");
        }

        return amounts[line - 1];
    };
}

sealed class ShareBusyException(string message) : IOException(message);

Output (exit status 1)

a clean statement: FAIL, throws NotImplementedException instead of 1020.50, no failures
an amount that is not a number: FAIL, throws NotImplementedException instead of 570.50, line 2: not an amount
the share is busy once: FAIL, throws NotImplementedException instead of 570.50, no failures
the share stays busy: FAIL, throws NotImplementedException instead of 450.00, line 2: share busy
an unexpected error: FAIL, throws NotImplementedException instead of throws InvalidOperationException
an empty statement: FAIL, throws NotImplementedException instead of 0, no failures
0 of 6 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run importer.cs

Make importer.cs pass

Copy importer.cs to your computer and write Import: decimal.TryParse for the amounts, a catch clause for ShareBusyException with a filter on the attempt for the retry, and no catch clause for anything else. Run dotnet run importer.cs until it prints 6 of 6 checks pass.

The questions below cover all five sections. They come one at a time, beginning with the section whose questions you have missed most, and every choice you pick explains itself.

Practice that adapts to you

Practise this lesson

13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 A try block has three catch clauses, and an exception is thrown inside it. How many of its catch blocks can run for that exception?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: At most one: the first clause, from the top, whose type matches

    At most one catch block runs for an exception, the first whose type matches from the top, which is why the compiler wants specific types first.

  2. Question 2 of 13 A command-line tool imports one statement file. Where does catch (Exception) belong in it?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: At the top level, where it reports the failure and makes the tool exit with a non-zero status

    Catch specific types where the code can act on them, and catch Exception once, at the boundary that can report a failure of any kind.

  3. Question 3 of 13 Which statement in a catch block passes the caught exception on with its original stack trace?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: throw;

    throw; keeps the original stack trace, which is what the next reader of the log needs.

  4. Question 4 of 13 Wrap in rethrow.cs catches ArgumentOutOfRangeException e and throws new InvalidOperationException("…", e). Where are the frames of Charge afterwards?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: In the InnerException, whose trace starts at line 97 and goes on to line 70

    Wrap when the caller needs another type or message, and pass the original as innerException so the real cause and its trace stay one property away.

  5. Question 5 of 13 Which clause handles IOException only while retries remain?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: catch (IOException) when (retriesLeft > 0)

    catch (Type) when (condition) handles an exception of that type only if the condition is true.

  6. Question 6 of 13 A try statement has catch (IOException e) when (IsTransient(e)) and, after it, catch (IOException). IsTransient returns false. What happens?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: The second clause handles the exception

    Several clauses for the same type are allowed when they differ by filters, with the unfiltered one last.

  7. Question 7 of 13 Why is catch (IOException e) when (IsTransient(e)) better than catching every IOException, testing IsTransient in the catch block and rethrowing with throw; when it is false?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: The filter runs before the stack unwinds, so an exception it does not want is never caught and the frames are still there for a debugger or a crash dump

    Put the condition in a filter when a catch block would only test it and rethrow: nothing is caught or unwound for an exception the clause does not handle.

  8. Question 8 of 13 When does a finally block run?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Whenever control leaves its try block: at the end, on return, break or continue, or when an exception passes through

    A finally block runs on every way out of its try block, which makes it the place for cleanup that must happen.

  9. Question 9 of 13 failfast.cs calls Environment.FailFast inside a try block that has a catch clause for Exception and a finally block. Which of them ran?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Neither: the process ended at once with exit status 134

    Use Environment.FailFast only when going on would do more harm than stopping, because no cleanup code of yours runs after it.

  10. Question 10 of 13 In unhandled.cs nothing catches the exception. What did the run show, and what follows for cleanup that must happen?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: The runtime reported the exception and then ran the finally block, but that is not promised, so code that must clean up needs a handler somewhere up the calls

    The first pass found no handler, so the runtime reported the exception; the unwind that ran the finally block is not something to rely on. A top-level catch, like the one in travel.cs, makes cleanup certain.

  11. Question 11 of 13 In cost.cs, how long did 100,000 valid numbers take with int.Parse inside try and catch, compared with int.TryParse?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: About the same, about a millisecond each way

    What costs is the throw and the search for a handler, not the try block itself.

  12. Question 12 of 13 What does int.TryParse("7O", out int n) do?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: It returns false and sets n to 0

    int.TryParse returns false for text that is not a number, with 0 in its out argument, so the caller decides what a failure means without paying for an exception.

  13. Question 13 of 13 A college portal reads roll numbers in two places: a search box where about 3 in 100 are mistyped, and a data file that was checked when it was uploaded. Which design fits both?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: A Try method for the search box, and a parse that throws for the checked file, where a bad value means a bug

    Choose by how often the failure happens in normal use: routinely gets a check or a Try method, rarely and only through a bug gets an exception.

CSV & Excel Data Profiler (EDA Report) Profile a statement file before you import it: a column that mixes numbers and text, such as an amount typed 45O.00, shows up in the report.

Interview questions

Fresher · What is the difference between throw; and throw ex; in a catch block?

Both pass the caught exception on to the callers, but they leave different stack traces. throw; rethrows the exception as it is, so its trace still starts at the line that first threw it and lists every call in between. throw ex; throws the same object again as if it were new at that line, so the trace restarts in the catch block and the frames down to the original throw are lost: in rethrow.cs the trace kept only line 62 instead of lines 97 and 46. The SDK’s analyzer reports it as CA2200. Use throw; to pass an exception on, wrap it with throw new SomeException(message, ex) when the caller needs another type, and ExceptionDispatchInfo to rethrow it later, outside its catch block.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A nightly job imports bank statements. Each file is processed inside try { … } catch (Exception e) { log.Warning(e.Message); }, and the job always ends with exit status 0. Customers report that the payments of some days never appeared. What is wrong with this error handling, and how would you restructure it?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Production logs show a NullReferenceException whose stack trace starts inside the catch block of OrderService.Save, not at the line that read the null. What most likely happened, and how do you make the next incident point at the real line?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A CSV import handles 2 million rows a night, and about 5 % of the amount fields fail to parse. It calls decimal.Parse inside try and catch for every field. A teammate proposes removing every try block in the project to make it faster. What do you advise, and how do you measure it?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An exception goes up the calls to the first catch clause that accepts its type, so order clauses from the most specific to the most general (the compiler refuses the reverse with CS0160) and catch Exception only where a failure of any kind can be reported.
  • throw; keeps the stack trace, throw e; restarts it at the rethrow (CA2200), wrapping keeps the original in InnerException, and ExceptionDispatchInfo rethrows later with the original trace kept.
  • An exception filter runs before the stack unwinds, so it can choose a handler or log an exception without catching it, and a filter that throws counts as false.
  • A finally block runs on every way out of its try block, before a catch block further up; an exception thrown inside it replaces the first one, and Environment.FailFast ends the process without running it.
  • A throw and catch cost hundreds of times more than a false from a Try method, so failures that happen in normal use get a Try method or a check.

Terms in this lesson

catch clause
The part of a try statement that names an exception type, as in catch (FormatException e), with the block that runs when an exception of that type, or of a type derived from it, reaches the try statement.
exception
An object of a type derived from System.Exception that a throw raises when an operation cannot complete. The runtime then searches the waiting calls, newest first, for a catch clause that accepts its type.
exception filter
A when clause after a catch type, as in catch (IOException) when (retries > 0): the clause handles the exception only if the condition is true. The condition runs before the stack unwinds.
fail fast
To end a process at once, as Environment.FailFast does, without running catch or finally blocks, when going on could do more harm, such as writing a damaged file again.
finally block
The block of a try statement that runs whenever control leaves the try block: at its end, on return, break or continue, or when an exception passes through. A process ended at once, as by Environment.FailFast, skips it.
rethrow
To pass a caught exception on from its catch block. throw; keeps the exception's stack trace; throw e; restarts the trace at that line, and the frames below it are lost.
stack unwinding
Leaving the frames of the calls between a throw and the catch clause that handles it, running their finally blocks on the way. It happens after the runtime has found the handler and before its catch block runs.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.