Free Handbook · Every example compiled & verified

Exceptions

Handle failures in C# with try, catch and finally: the exception hierarchy, filters, rethrowing, custom exceptions, using and IDisposable, and stack traces.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 07 · what you'll be able to do

  • Catch the specific exceptions you can handle, in the right order, and let the rest travel up
  • Use finally, using statements and using declarations so resources are released on every path
  • Rethrow with throw; (not throw ex;), filter with when, and wrap causes as inner exceptions
  • Write a custom exception type that callers can catch on its own
  • Read an unhandled-exception stack trace to the exact line, and choose TryParse-style APIs over exceptions for expected failures
01

try, catch and finally

When something goes wrong that the code at that point cannot deal with (a file is missing, a number does not parse, you divide by zero), .NET throws an exception: an object describing the problem. Normal execution stops, and the runtime walks back up the call stack looking for a catch block that accepts that kind of exception. If none is found, the program ends with an "Unhandled exception" message.

A try block marks code whose exceptions you want to handle. A catch block handles them. A finally block runs whether or not an exception happened, even if the try returned early, which makes it the place for clean-up.

C#Program.cs
Console.WriteLine(Divide(10, 2));
Console.WriteLine(Divide(10, 0));

static string Divide(int a, int b)
{
    try
    {
        Console.WriteLine($"trying {a} / {b}");
        return $"= {a / b}";
    }
    catch (DivideByZeroException ex)
    {
        return $"failed: {ex.Message}";
    }
    finally
    {
        Console.WriteLine("finally runs either way");
    }
}
Outputcompiled & run with real C#
trying 10 / 2
finally runs either way
= 5
trying 10 / 0
finally runs either way
failed: Attempted to divide by zero.

The finally line prints before the returned value, because finally runs on the way out of the method, before the caller gets the result.

VisualizeWhat runs when the division failsStep 1 / 7
try
{
Console.WriteLine("start");
int zero = 0;
int x = 10 / zero;
Console.WriteLine("never printed");
}
catch (DivideByZeroException)
{
Console.WriteLine("caught");
}
finally
{
Console.WriteLine("clean up");
}
Console.WriteLine("carry on");
Line 3

The try block starts running normally.

Variables now

nothing yet

Printed so far
start
All 7 steps as a table
StepLineWhat happenedVariables now
13The try block starts running normally.
24A local set to 0.zero = 0
35Integer division by zero: the runtime throws a DivideByZeroException. The rest of the try block is skipped.exception = DivideByZeroException
48The runtime checks the catch clauses in order. This one matches the exception's type.
510The handler runs. The exception is now handled.exception = (handled)
614finally always runs after try/catch.
716Execution continues after the whole statement, as if nothing had happened.
Error you will hit

CS0165: using a variable assigned only inside try

C#
string input = "42x";
int n;
try
{
    n = int.Parse(input);
}
catch (FormatException)
{
    Console.WriteLine("not a number");
}
Console.WriteLine(n + 1);
Program.cs(11,19): error CS0165: Use of unassigned local variable 'n'
Why the compiler said that

If int.Parse throws, the assignment never happens, the catch block runs, and execution reaches the last line with n still unassigned. The compiler follows that path and refuses to let you read a variable that might have no value.

The fix

Give the variable a value on every path (a default before the try, or in the catch). Better, for parsing, use int.TryParse and skip the exception entirely (last lesson).

C#
string input = "42x";
int n = 0;
try
{
    n = int.Parse(input);
}
catch (FormatException)
{
    Console.WriteLine("not a number");
}
Console.WriteLine(n + 1);
02

The exception hierarchy and catching specific types

Every exception is an object whose class derives from System.Exception. A catch (T) clause catches T and every class derived from it, so where a type sits in the hierarchy decides what a clause catches. The exceptions you will meet most often:

ExceptionDerives fromTypical cause
ArgumentExceptionSystemExceptionA method got an argument it cannot accept
ArgumentNullExceptionArgumentExceptionA required argument was null
ArgumentOutOfRangeExceptionArgumentExceptionAn index or number outside the allowed range
InvalidOperationExceptionSystemExceptionThe call is not valid in the object's current state (e.g. First() on an empty list)
NullReferenceExceptionSystemExceptionA member was used on null (a bug: fix it, do not catch it)
IndexOutOfRangeExceptionSystemExceptionAn array index past the end
KeyNotFoundExceptionSystemExceptionA dictionary indexer read a missing key
FormatExceptionSystemExceptionint.Parse("abc")
DivideByZeroExceptionArithmeticExceptionInteger division by zero
IOException / FileNotFoundExceptionSystemException / IOExceptionFile and network I/O failures

Catch the most specific type you can actually do something about, and put specific clauses before general ones: the runtime takes the first clause that matches.

C#Program.cs
string[] inputs = { "12", "abc", "99999999999", "" };

foreach (string s in inputs)
{
    try
    {
        int n = int.Parse(s);
        Console.WriteLine($"{s}: 100 / {n} = {100 / (n - 12)}");
    }
    catch (FormatException)
    {
        Console.WriteLine($"\"{s}\": not a number");
    }
    catch (OverflowException)
    {
        Console.WriteLine($"\"{s}\": too big for an int");
    }
    catch (ArithmeticException ex)       // catches DivideByZeroException too
    {
        Console.WriteLine($"\"{s}\": maths problem ({ex.GetType().Name})");
    }
}
Outputcompiled & run with real C#
"12": maths problem (DivideByZeroException)
"abc": not a number
"99999999999": too big for an int
"": not a number
Your turn

Swap the order of the OverflowException and ArithmeticException clauses. What does the compiler say? (OverflowException also derives from ArithmeticException.)

Error you will hit

CS0160: a general catch before a specific one

C#
try
{
    Console.WriteLine(int.Parse("abc"));
}
catch (Exception)
{
    Console.WriteLine("something failed");
}
catch (FormatException)
{
    Console.WriteLine("not a number");
}
Program.cs(9,8): error CS0160: A previous catch clause already catches all exceptions of this or of a super type ('Exception')
Why the compiler said that

Clauses are tried top to bottom and FormatException is an Exception, so the first clause always wins and the second can never run. The compiler turns that dead code into an error.

The fix

Order clauses from most specific to most general.

C#
try
{
    Console.WriteLine(int.Parse("abc"));
}
catch (FormatException)
{
    Console.WriteLine("not a number");
}
catch (Exception)
{
    Console.WriteLine("something failed");
}
Do not catch Exception everywhere
catch (Exception) also swallows the bugs you need to see (NullReferenceException, IndexOutOfRangeException). Catch Exception in one place only: the top of the program or request pipeline, where you log it and return an error response. Everywhere else, catch what you can handle.
03

Exception filters with when

A when clause adds a condition to a catch: the clause handles the exception only if the condition is true. If it is false, the runtime keeps looking at the next clauses (and up the call stack) as if this clause did not exist. That is better than catching and rethrowing, because an exception that is not handled keeps its original stack untouched.

C#Program.cs
foreach (int status in new[] { 404, 503, 500 })
{
    try
    {
        Call(status);
    }
    catch (HttpError e) when (e.Status == 404)
    {
        Console.WriteLine("not found: use a default");
    }
    catch (HttpError e) when (e.Status >= 500 && e.Status != 500)
    {
        Console.WriteLine($"{e.Status}: server busy, retry later");
    }
    catch (HttpError e)
    {
        Console.WriteLine($"{e.Status}: give up ({e.Message})");
    }
}

static void Call(int status) => throw new HttpError(status, $"request failed with {status}");

class HttpError(int status, string message) : Exception(message)
{
    public int Status { get; } = status;
}
Outputcompiled & run with real C#
not found: use a default
503: server busy, retry later
500: give up (request failed with 500)
Logging without catching
A filter that calls a method returning false lets you see every exception without handling it: catch (Exception ex) when (Log(ex)) { } where Log writes the error and returns false. The exception continues up the stack untouched.
04

Throwing, guard clauses, and throw versus throw ex

You throw an exception with throw new SomeException("message"). The most common place is a guard clause at the top of a method that rejects bad arguments before any work starts, so the failure points at the caller's mistake rather than at a confusing crash three calls later. .NET has one-line helpers for the usual checks.

C#Program.cs
var acct = new Account(100);
acct.Withdraw(30);
Console.WriteLine(acct.Balance);

try { acct.Withdraw(-5); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message); }

try { acct.Rename(null); }
catch (ArgumentNullException ex) { Console.WriteLine(ex.Message); }

try { acct.Withdraw(500); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }

class Account(decimal opening)
{
    public decimal Balance { get; private set; } = opening;
    public string Name { get; private set; } = "main";

    public void Withdraw(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        if (amount > Balance)
            throw new InvalidOperationException($"Cannot withdraw {amount}; balance is {Balance}.");
        Balance -= amount;
    }

    public void Rename(string? name) =>
        Name = name ?? throw new ArgumentNullException(nameof(name));   // throw expression
}
Outputcompiled & run with real C#
70
amount ('-5') must be a non-negative and non-zero value. (Parameter 'amount')
Actual value was -5.
Value cannot be null. (Parameter 'name')
Cannot withdraw 500; balance is 70.

When you catch an exception, cannot fully handle it, and want it to continue up the stack, write throw; on its own. It rethrows the same exception with its original stack trace. throw ex; looks equivalent but restarts the stack trace at the rethrow line, erasing where the problem actually happened. The build even warns about it (CA2200); the example silences that warning only to show the damage.

C#Program.cs
#pragma warning disable CA2200   // silenced only to demonstrate the problem

foreach (var run in new Action[] { Service.RunRethrow, Service.RunThrowEx })
{
    try { run(); }
    catch (Exception ex)
    {
        Console.WriteLine($"{run.Method.Name}:");
        foreach (var line in ex.StackTrace!.Split('\n'))
            Console.WriteLine("  " + line.Trim().Split(" in ")[0]);
    }
}

static class Repo
{
    public static int Load(string key) =>
        key.Length == 0 ? throw new InvalidOperationException("empty key") : key.Length;
}

static class Service
{
    public static void RunRethrow()
    {
        try { Repo.Load(""); }
        catch (InvalidOperationException) { throw; }        // keeps the trace
    }

    public static void RunThrowEx()
    {
        try { Repo.Load(""); }
        catch (InvalidOperationException ex) { throw ex; }  // resets it
    }
}
Outputcompiled & run with real C#
RunRethrow:
  at Repo.Load(String key)
  at Service.RunRethrow()
  at Program.<Main>$(String[] args)
RunThrowEx:
  at Service.RunThrowEx()
  at Program.<Main>$(String[] args)

With throw ex; the frame Repo.Load, where the exception was really thrown, is gone. In production that is the one line you needed.

Error you will hit

CS0029: throwing a string instead of an exception

C#
int age = -1;
if (age < 0) throw "age cannot be negative";
Program.cs(2,20): error CS0029: Cannot implicitly convert type 'string' to 'System.Exception'
Why the compiler said that

Only objects derived from System.Exception can be thrown, so the compiler tries to convert the string to System.Exception and finds no conversion. A string carries no type to catch on and no stack trace.

The fix

Throw an exception object and put the text in its message.

C#
int age = 30;
if (age < 0) throw new ArgumentOutOfRangeException(nameof(age), "age cannot be negative");
Console.WriteLine(age);
05

Custom exceptions and inner exceptions

Write your own exception class when callers need to catch your failure specifically and react to it, typically a business rule (insufficient funds, a booking conflict). Derive from Exception, end the name in Exception, pass the message to the base constructor, and add properties for any data a handler needs. Do not create one per method; reuse the built-in types (ArgumentException, InvalidOperationException) when they already say it.

When you catch a low-level exception and throw a higher-level one, pass the original as the inner exception. The new exception explains what failed in your terms; the inner one keeps the root cause and its stack trace.

C#Program.cs
var store = new OrderStore();
try
{
    store.Save("A-17", "{ broken json");
}
catch (OrderSaveException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
    Console.WriteLine($"  order: {ex.OrderId}");
    Console.WriteLine($"  caused by {ex.InnerException?.GetType().Name}: {ex.InnerException?.Message}");
}

class OrderStore
{
    public void Save(string id, string payload)
    {
        try
        {
            if (!payload.TrimEnd().EndsWith('}'))
                throw new FormatException("payload is not closed with '}'");
        }
        catch (FormatException ex)
        {
            throw new OrderSaveException(id, $"Could not save order {id}.", ex);
        }
    }
}

class OrderSaveException : Exception
{
    public string OrderId { get; }

    public OrderSaveException(string orderId, string message, Exception? inner = null)
        : base(message, inner)
    {
        OrderId = orderId;
    }
}
Outputcompiled & run with real C#
OrderSaveException: Could not save order A-17.
  order: A-17
  caused by FormatException: payload is not closed with '}'
Your turn

Catch Exception instead of OrderSaveException and print ex.ToString(). Find the ---> marker that separates the outer and inner exception.

Unhandled inner exceptions
When an exception with an inner exception is never caught, .NET prints both, joined by ---> and closed by --- End of inner exception stack trace ---. The next-but-one lesson shows one.
06

using, IDisposable and using declarations

Some objects hold resources the garbage collector does not manage well: open files, database connections, network sockets. They implement IDisposable, whose Dispose() method releases the resource. You must call it on every path, including when an exception is thrown. A using statement does exactly that: it is a try/finally that calls Dispose() in the finally.

The shorter using declaration (using var file = ...;, no braces) disposes the object at the end of the enclosing block. Several are disposed in reverse order of creation.

C#Program.cs
try
{
    using (var db = new Resource("database"))
    {
        Console.WriteLine("querying");
    }                                        // Dispose here

    using var log = new Resource("log file");       // using declarations
    using var cache = new Resource("cache");
    Console.WriteLine("working");
    throw new InvalidOperationException("boom");
}                                            // cache, then log, disposed here
catch (InvalidOperationException ex)
{
    Console.WriteLine($"caught {ex.Message}");
}

class Resource : IDisposable
{
    private readonly string name;

    public Resource(string name)
    {
        this.name = name;
        Console.WriteLine($"open {name}");
    }

    public void Dispose() => Console.WriteLine($"close {name}");
}
Outputcompiled & run with real C#
open database
querying
close database
open log file
open cache
working
close cache
close log file
caught boom

The exception did not stop either resource from being closed, and both were closed before the catch block ran.

Error you will hit

CS1674: using on a type that is not IDisposable

C#
using var report = new Report();
Console.WriteLine("done");

class Report
{
    public void Close() => Console.WriteLine("closed");
}
Program.cs(1,1): error CS1674: 'Report': type used in a using statement must implement 'System.IDisposable'.
Why the compiler said that

using works by calling IDisposable.Dispose(). Report has a Close method, but the compiler does not guess: without the interface, there is nothing to call.

The fix

Implement IDisposable (and put the clean-up in Dispose), or call Close() yourself in a finally.

C#
using var report = new Report();
Console.WriteLine("done");

class Report : IDisposable
{
    public void Dispose() => Console.WriteLine("closed");
}
In real jobs
Every SqlConnection, FileStream, StreamReader and HttpResponseMessage goes in a using. One classic trap: do not wrap HttpClient in using per request; creating and disposing one per call exhausts sockets. Reuse one instance, or let IHttpClientFactory manage them. For async resources there is IAsyncDisposable and await using (Module 10).
07

Reading a stack trace

An unhandled exception prints three things: the exception type, its message, and the stack trace, the chain of method calls that were active when it was thrown. In .NET the first at line is where the exception was thrown and each following line is the caller of the one above, down to Main. Read the first line that points into your code (not System.*) and go to that file and line number.

Error you will hit

Unhandled exception with an inner exception

C#
var totals = new Dictionary<string, decimal> { ["north"] = 1200m };
Console.WriteLine(Report.For(totals, "south"));

static class Report
{
    public static string For(Dictionary<string, decimal> totals, string region)
    {
        try
        {
            return $"{region}: {totals[region]}";
        }
        catch (KeyNotFoundException ex)
        {
            throw new InvalidOperationException($"No totals for region '{region}'.", ex);
        }
    }
}
Unhandled exception. System.InvalidOperationException: No totals for region 'south'.
 ---> System.Collections.Generic.KeyNotFoundException: The given key 'south' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Report.For(Dictionary`2 totals, String region) in Program.cs:line 10
   --- End of inner exception stack trace ---
   at Report.For(Dictionary`2 totals, String region) in Program.cs:line 14
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

Read it in two halves. The outer exception, InvalidOperationException, is your explanation, thrown at line 14 inside Report.For, called from Main at line 2. After ---> comes the root cause: a KeyNotFoundException thrown by the dictionary indexer (the System.Collections.Generic frame, not your code), reached from line 10 of Report.For. So line 10 read a key that is not in the dictionary.

The fix

Here the fix is at line 10: decide what a missing region means, and use TryGetValue or GetValueOrDefault instead of an indexer that throws.

C#
var totals = new Dictionary<string, decimal> { ["north"] = 1200m };
Console.WriteLine(Report.For(totals, "south"));

static class Report
{
    public static string For(Dictionary<string, decimal> totals, string region) =>
        totals.TryGetValue(region, out var total) ? $"{region}: {total}" : $"{region}: no data";
}
  1. 1
    Read the type and message

    The first line names the exception and says what happened. KeyNotFoundException plus the key already tells you most of the story.

  2. 2
    Find the first frame in your code

    Skip frames from System.* and framework namespaces. The first frame with your file name and a line number is where to look.

  3. 3
    Follow the inner exception

    If there is a --->, the inner exception is the root cause; the outer one is context. Fix the root cause.

  4. 4
    Read the callers for context

    Lines further down show how execution got there: which request, which loop, which input.

No line numbers?
Line numbers come from the .pdb symbol file produced at build time. Release builds still produce one by default; if a deployed app shows frames without in File.cs:line N, ship the .pdb files alongside the .dlls.
08

When not to use exceptions: the Try pattern

Exceptions are for situations the code at that point did not expect and cannot handle. They are also slow: throwing captures a stack trace and unwinds frames, thousands of times the cost of an if. When failure is a normal, expected outcome (user input that may not be a number, a key that may not be in a cache), use an API that reports failure as a return value. .NET's convention is the Try pattern: bool TryX(input, out result).

C#Program.cs
string[] inputs = { "42", "4.2", "forty-two", "-7" };
foreach (string s in inputs)
{
    if (int.TryParse(s, out int n))
        Console.WriteLine($"{s,-10} -> {n}");
    else
        Console.WriteLine($"{s,-10} -> not an int");
}

var prices = new Dictionary<string, decimal> { ["tea"] = 2.5m };
foreach (string item in new[] { "tea", "cake" })
{
    string shown = prices.TryGetValue(item, out decimal price) ? price.ToString() : "not on the menu";
    Console.WriteLine($"{item}: {shown}");
}

Console.WriteLine(TryParsePercent("85%", out int pct) ? $"percent {pct}" : "bad percent");
Console.WriteLine(TryParsePercent("185%", out _) ? "ok" : "185% rejected");

static bool TryParsePercent(string text, out int value)
{
    value = 0;
    if (!text.EndsWith('%')) return false;
    return int.TryParse(text[..^1], out value) && value is >= 0 and <= 100;
}
Outputcompiled & run with real C#
42         -> 42
4.2        -> not an int
forty-two  -> not an int
-7         -> -7
tea: 2.5
cake: not on the menu
percent 85
185% rejected
Your turn

Write static bool TryGetInitials(string fullName, out string initials) that fails for an empty or single-word name.

Error you will hit

KeyNotFoundException: the indexer on a missing key

C#
var stock = new Dictionary<string, int> { ["apple"] = 3 };
Console.WriteLine(stock["pear"]);
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'pear' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

Reading a Dictionary through its indexer means "this key is definitely there". It is not, so the indexer throws. The message even names the key.

The fix

If a missing key is a normal case, ask with TryGetValue or GetValueOrDefault. Keep the indexer for keys that really must exist, where a crash means a bug.

C#
var stock = new Dictionary<string, int> { ["apple"] = 3 };
Console.WriteLine(stock.GetValueOrDefault("pear"));

Throw an exception when

  • An argument breaks the method's contract (a bug in the caller)
  • An object is in a state where the call makes no sense
  • An external resource fails: disk, network, database
  • The current method cannot recover and its caller might

Return a result when

  • Failure is an ordinary outcome: parsing input, looking up a key
  • You are in a hot loop and failure is common
  • The caller will always check the result immediately
  • You are validating user input to show messages, not crashing
Exception
An object derived from System.Exception describing an error; throwing it unwinds the stack until a matching catch.
try / catch / finally
Guard a block, handle matching exceptions, and run clean-up code on every path.
Exception filter
A when (condition) on a catch clause; if false the exception passes by untouched.
throw;
Rethrows the current exception keeping its original stack trace (unlike throw ex;).
Guard clause
A check at the top of a method that throws for invalid arguments before any work is done.
Inner exception
The original exception wrapped inside a higher-level one, kept as the root cause.
IDisposable
Interface with a Dispose() method that releases a resource; using calls it on every path.
Stack trace
The list of active method calls when an exception was thrown, innermost (where it was thrown) first.
Try pattern
bool TryX(input, out result): report expected failure as false instead of throwing.
Quick check

A try block contains return 1; and its finally block prints "bye". What happens when the method is called?

Quick check

Inside a catch block you want to log the error and let it continue up the stack. Which line keeps the original stack trace?

Frequently asked questions

What is the difference between throw and throw ex in C#?
throw; rethrows the caught exception with its original stack trace. throw ex; rethrows the same object but resets the stack trace to the current line, hiding where the error really happened. The .NET analyzer warns about it as CA2200.
Does finally always run in C#?
It runs whenever control leaves the try block: normally, by return, break, or an exception, whether caught or not. It does not run if the process is killed, on Environment.FailFast, or on a StackOverflowException, which ends the process.
Should I use exceptions for validation in C#?
Not for expected failures such as user input. Use TryParse-style methods or return a result and show a message. Throw exceptions for broken contracts and failures the current code cannot handle, and let a top-level handler log them.

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.