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.
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:
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The import starts. The top-level code calls Statement.Import with four lines, inside a try statement whose catch clause takes any Exception.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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):
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");
}
} 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
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”:
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
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:
// 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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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 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:
// 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
// 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
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:
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
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:
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.
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.
-
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?
-
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?
-
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?
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
Exceptiononly 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 inInnerException, andExceptionDispatchInforethrows 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.FailFastends the process without running it. - A throw and catch cost hundreds of times more than a
falsefrom 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
- Exception-handling statements: throw and try, catch, finally (Microsoft Learn)
- Exception handling (C# fundamentals) (Microsoft Learn)
- Best practices for exceptions (Microsoft Learn)
- Compiler Error CS0160 (Microsoft Learn)
- CA2200: Rethrow to preserve stack details (Microsoft Learn)
- ExceptionDispatchInfo class (Microsoft Learn)
- CA2219: Do not raise exceptions in exception clauses (Microsoft Learn)
- Environment.FailFast method (Microsoft Learn)
- StackOverflowException class (Microsoft Learn)
- Int32.TryParse method (Microsoft Learn)
- C# language specification: Exceptions (Microsoft Learn (ECMA-334))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress