← Open in the full interactive course (progress tracking, search & more)

An exception is your program raising its hand and saying "something went wrong here" — loudly, immediately, and impossible to ignore.

Picture a payment method that transfers money out of a bank account. Somewhere deep inside, the account balance turns out to be too low. What should happen next?

One option: the method quietly returns false, or -1, or null, and hopes that whoever called it remembers to check. If they forget — and eventually, someone always forgets — the program carries on as if the transfer succeeded. Money "leaves" an account that never actually lost it. That's not a bug you find in code review. That's a bug you find in production, in an audit, months later.

C# (like most modern languages) solves this with exceptions: a built-in mechanism for saying "this operation failed, and the failure cannot be silently ignored." In this lesson, you'll learn what exceptions are, why they exist, how try/catch/finally work together, and how the exception type hierarchy lets you handle failures precisely.

What Is It?

The Simple Explanation

An exception is an object that represents "something went wrong." When code hits a problem it can't reasonably continue past — dividing by zero, calling a method on a null reference, a file that doesn't exist — it throws an exception. Throwing immediately stops the normal flow of the method and starts searching for someone who knows how to handle that specific kind of problem.

The Technical Definition

In .NET, an exception is an instance of a class that derives (directly or indirectly) from System.Exception. When code executes a throw statement, the CLR suspends normal execution and unwinds the call stack — frame by frame — looking for a catch block whose declared exception type matches (or is a base type of) the thrown exception. If one is found, control transfers there. If none is found anywhere up the call stack, the runtime terminates the program (or, in a web app, the request) and reports the unhandled exception.

The Three Blocks

Why Does It Exist?

The Problem — Error Codes Are Easy to Ignore

Older languages (and older .NET APIs) often signal failure through a return value: a negative number, a null reference, a boolean. This has a fatal flaw — nothing forces the caller to check it.

// Error-code style — the compiler is perfectly happy either way int result = TransferFunds(fromAccount, toAccount, 500m); // Forgot to check `result`? No warning. No error. Just silent wrong behavior. ProcessNextStep();

The Need

What's actually needed is a failure signal that cannot be accidentally ignored, carries rich information about what went wrong, and can travel automatically through layers of method calls until something knows how to deal with it.

The Solution — Exceptions

An exception, once thrown, forcibly interrupts normal execution. It doesn't wait to be checked — it propagates upward through every calling method until either a matching catch block handles it, or it reaches the top of the program and crashes it loudly. Loud and visible beats quiet and wrong.

Error Codes

Exceptions

Big Picture

When an exception is thrown, it doesn't just affect the current method — it travels back up through every method that called it, looking for a handler:

HOW AN EXCEPTION TRAVELS UP THE CALL STACK
Main() calls ProcessOrder()
No try/catch here — just a normal call
ProcessOrder() calls ChargeCard()
Wrapped in try/catch — this is where we handle it
ChargeCard() calls ValidateFunds()
No try/catch — just does the work
ValidateFunds() throws InsufficientFundsException
ValidateFunds itself has no catch block for this — it doesn't stop here
Back in ChargeCard() — no matching catch — keeps unwinding
Back in ProcessOrder() — the catch block here matches — handled!
Execution resumes here, inside the catch block. Main() never even knows anything went wrong.

Notice that neither Main() nor ValidateFunds() needed any exception-handling code at all. The exception automatically skipped every method in between that didn't have a matching catch, until it found one that did. That's the power of exceptions over manual error codes — the "plumbing" of propagating failure upward is built into the language.

How It Works

TRY / CATCH / FINALLY — STEP BY STEP
Step 1 — Code inside try runs normally
try
{
    Console.WriteLine("Step A");
    Console.WriteLine("Step B");   // ← an exception happens here
    Console.WriteLine("Step C");   // ← this line is skipped entirely
}
Step 2 — The runtime looks for a matching catch
catch (DivideByZeroException ex)
{
    Console.WriteLine($"Math problem: {ex.Message}");
}
Step 3 — finally always runs
finally
{
    Console.WriteLine("Cleanup runs here — always.");
}
Step 4 — Execution resumes after the try/catch/finally block

The Exception Hierarchy

Every exception type in .NET ultimately derives from System.Exception. This matters because a catch block matches an exception's declared type or any type it inherits from. Some of the most common built-in exception types:

Exception TypeThrown When…
System.ExceptionThe root of everything. Catching this catches all exceptions.
SystemExceptionBase for exceptions thrown by the .NET runtime itself.
ArgumentExceptionAn argument passed to a method is invalid.
ArgumentNullExceptionAn argument that shouldn't be null, was null. (Derives from ArgumentException.)
ArgumentOutOfRangeExceptionAn argument is outside its allowed range. (Derives from ArgumentException.)
InvalidOperationExceptionA method call is invalid given the object's current state.
NullReferenceExceptionCode dereferenced a null object reference.
DivideByZeroExceptionInteger division by zero.
IndexOutOfRangeExceptionAn array index is outside its bounds.
FormatExceptionA string isn't in the format a parsing method expects.
IOExceptionA file or stream operation failed (covered in later lessons).

Because ArgumentNullException derives from ArgumentException, which derives from SystemException, which derives from Exception, a single catch (ArgumentException ex) block will catch both ArgumentException and ArgumentNullException — because "is-a" still applies to exceptions, just like any other class hierarchy.

Simple Example

A method that divides two numbers, with the caller handling the case where the divisor is zero:

int numerator = 10; int denominator = 0; try { int result = numerator / denominator; // throws DivideByZeroException Console.WriteLine($"Result: {result}"); // never reached } catch (DivideByZeroException ex) { Console.WriteLine($"Can't divide by zero: {ex.Message}"); } finally { Console.WriteLine("Division attempt finished."); } Console.WriteLine("Program continues normally."); // Output: // Can't divide by zero: Attempted to divide by zero. // Division attempt finished. // Program continues normally.

Walking through it:

Multiple catch blocks

You can stack several catch blocks to handle different failure types differently. C# checks them top to bottom and runs the first one that matches:

try { var order = ParseOrder(userInput); SaveOrder(order); } catch (FormatException ex) { Console.WriteLine($"The input wasn't formatted correctly: {ex.Message}"); } catch (InvalidOperationException ex) { Console.WriteLine($"Couldn't save the order right now: {ex.Message}"); } catch (Exception ex) { // A catch-all, always last — the most general type has to come last Console.WriteLine($"Unexpected error: {ex.Message}"); }
Order matters. Catch blocks are evaluated top to bottom, and the compiler enforces that a more specific exception type must appear before a more general one (like Exception). Put the broadest catch last — otherwise it would swallow everything before more specific handlers ever got a chance to run, and the compiler will actually refuse to build it.

Real-World Example

Consider a background job that imports orders from a CSV file uploaded by a client. A single line in that file could be malformed, but one bad row shouldn't kill the entire import:

public class OrderImportResult { public int SucceededCount { get; set; } public List<string> Errors { get; } = new(); } public OrderImportResult ImportOrders(string[] csvLines) { var result = new OrderImportResult(); foreach (var line in csvLines) { try { var fields = line.Split(','); var order = new Order { CustomerId = int.Parse(fields[0]), ProductSku = fields[1], Quantity = int.Parse(fields[2]), UnitPrice = decimal.Parse(fields[3]) }; SaveToDatabase(order); result.SucceededCount++; } catch (FormatException) { result.Errors.Add($"Skipped malformed row: \"{line}\""); } catch (IndexOutOfRangeException) { result.Errors.Add($"Skipped row with missing fields: \"{line}\""); } catch (Exception ex) { // Something unexpected — log it with full detail, but keep processing result.Errors.Add($"Unexpected failure on row \"{line}\": {ex.Message}"); } } return result; }

Because the try/catch lives inside the loop, one bad row is contained to that single iteration — the loop moves on to the next line instead of the whole import crashing. This is a genuinely common .NET pattern: isolate risky, per-item work so a single failure degrades gracefully instead of taking down the whole operation.

Under the Hood

Two things are worth understanding about what actually happens when an exception is thrown.

STACK UNWINDING
1. throw creates the exception object
2. The CLR walks up the call stack, frame by frame
3. A match is found — or it isn't

This is why finally is such a strong guarantee: even while the stack is being torn down because nobody caught the exception yet, every finally block in every frame along the way still gets to run. That's what makes it the right place for cleanup code — closing a file, releasing a lock, disposing a connection — that absolutely must happen regardless of how the method exits.

Common Confusion

1. "Exceptions are for validating user input"

Not really. If a value is expected to sometimes be invalid — like a text box where a user might type non-numeric text — that's normal, everyday flow, not exceptional. Prefer int.TryParse over wrapping int.Parse in a try/catch. Reserve exceptions for conditions that are genuinely unexpected or that represent a broken invariant, not for routine input validation you already anticipate.

2. "catch (Exception) is the safe, defensive choice"

Catching the base Exception type feels safe but it isn't — it also catches things you never intended to catch, like a NullReferenceException revealing an actual bug in your code, or an OutOfMemoryException signaling the process is in real trouble. Catch the most specific type that you can actually do something meaningful about.

3. finally vs. "code after the try block"

Code placed right after a try/catch block only runs if the exception was caught (or none was thrown). Code inside finally runs even if the exception isn't caught at all — during unwinding, on its way past. That distinction is exactly why cleanup belongs in finally, not just "after" the block.

Common Mistakes

Mistake 1 — Swallowing exceptions silently

Wrong:

try { ChargeCustomer(order); } catch (Exception) { // nothing here — the failure just vanishes }

This is worse than not using exceptions at all — the payment silently fails and nobody ever finds out. Correct: at minimum, log it; ideally, decide whether the caller needs to know.

try { ChargeCustomer(order); } catch (PaymentException ex) { _logger.LogError(ex, "Payment failed for order {OrderId}", order.Id); throw; // let the caller decide what to do next }

Mistake 2 — Using exceptions for routine control flow

Wrapping int.Parse in a try/catch to check if user input is a valid number, when int.TryParse exists precisely for this. Throwing and catching an exception is measurably more expensive than a simple bool check, and it also reads as if the input being non-numeric were an emergency rather than an everyday case.

Mistake 3 — Catching too broadly, too early

Wrapping an entire Main() method — or a huge chunk of business logic — in one giant try { ... } catch (Exception) { ... }. This makes it impossible to know which specific operation actually failed, and it hides bugs that should have crashed loudly during development. Keep try blocks tight around the specific operation that can fail.

When Should I Use It?

Unexpected failures
A database connection dies, a required file is missing, an external API times out.
Broken invariants
A method receives a state or argument that should never be possible if the code calling it is correct.
Guaranteed cleanup
Use finally (or using, covered soon) whenever a resource absolutely must be released no matter what.
Not for expected input
User typed letters into a number field? That's routine — use TryParse, not try/catch.
Rule of thumb: If the condition is something you fully expect to happen sometimes as part of normal use (invalid input, a "not found" lookup), handle it with a normal return value or a TryXxx pattern. Reserve exceptions for conditions that are genuinely exceptional — the kind of thing that, if it happens, something has actually gone wrong.

Mental Model

throw = "Stop everything — something went wrong, and here's an object describing it."
try = "I'm attempting this — watch for trouble."
catch (SpecificType) = "If it's this kind of trouble, I know what to do."
finally = "Run this no matter what happened — success, failure, or still unresolved."

Remember:
· An unhandled exception keeps climbing the call stack until something catches it, or the program crashes.
· Catch specific types near the code that can actually recover; let unexpected ones propagate.
· finally is the one thing guaranteed to run — that's what makes it the home for cleanup.

Key Takeaway


Check Your Understanding

You've seen why exceptions exist and how try/catch/finally cooperate. Let's check that it's really sunk in.

1. Why are exceptions generally preferred over error-code return values for signaling failure?

Show answer

Correct: B

Why B is correct: An error-code return value can simply be discarded by the caller with no warning. A thrown exception forcibly interrupts normal execution and keeps propagating until something handles it — it cannot be quietly dropped on the floor.

Why A is incorrect: The opposite is generally true — throwing and catching an exception has real overhead compared to a normal return.

Why C is incorrect: Methods still declare return types; exceptions are a separate channel for failures, not a replacement for return values.

Why D is incorrect: An exception only reports that something went wrong — it doesn't repair anything on its own.

Reinforcement: The core advantage of exceptions is that they can't be accidentally ignored the way a return value can.

2. Given this code, what gets printed?

try { Console.WriteLine("A"); throw new InvalidOperationException(); Console.WriteLine("B"); } catch (ArgumentException) { Console.WriteLine("C"); } catch (InvalidOperationException) { Console.WriteLine("D"); } finally { Console.WriteLine("E"); }
Show answer

Correct: C

Why C is correct: "A" prints, then the throw immediately abandons the rest of the try block — "B" never prints. The runtime checks catch blocks top-to-bottom: the first, for ArgumentException, doesn't match an InvalidOperationException, so it's skipped. The second matches exactly, so "D" prints. Then finally always runs, printing "E".

Why A is incorrect: Code after a throw statement in the same try block never executes — "B" is unreachable once the exception is thrown.

Why B is incorrect: ArgumentException does not match InvalidOperationException — they're unrelated types (neither derives from the other), so that catch block is skipped.

Why D is incorrect: The exception is caught by the second catch block ("D" does print) — this answer misses that a match was found.

Reinforcement: Catch blocks are matched by type, checked in order, and finally always runs regardless of which (if any) catch matched.

3. You're validating a form field where the user is expected to sometimes enter non-numeric text — this happens routinely. What's the best approach?

Show answer

Correct: B

Why B is correct: Invalid form input is an expected, everyday occurrence — not an exceptional one. TryParse was built exactly for this: it reports success/failure through a boolean without the overhead or "emergency" framing of throwing and catching an exception.

Why A is incorrect: This works, but it uses exceptions for routine control flow, which is both slower and semantically misleading — this isn't an exceptional situation.

Why C is incorrect: Catching the broad Exception type would also mask unrelated bugs elsewhere in the same try block, not just the parsing failure you intended to handle.

Why D is incorrect: Letting a routine validation failure crash the whole program is exactly what exceptions are not for.

Reinforcement: Reserve throw/catch for genuinely unexpected conditions; use Try-pattern methods for input you know might routinely be invalid.

4. A method three layers deep throws an IOException. Neither of the two methods that called it (directly or indirectly) has any try/catch at all. What happens?

Show answer

Correct: B

Why B is correct: This is exactly how the call stack unwinds — the exception propagates automatically through every calling frame, regardless of whether that frame has any exception-handling code, until a matching catch is found or the program terminates due to an unhandled exception.

Why A is incorrect: Exceptions don't vanish at the boundary of the throwing method — that's precisely the mechanism that makes them more reliable than manual error codes.

Why C is incorrect: .NET does not silently convert unhandled exceptions into null or any other value — it treats them as a real failure.

Why D is incorrect: Execution doesn't pause or wait — it immediately continues unwinding the stack; if nothing ever catches it, the program terminates.

Reinforcement: You don't need a try/catch in every method — only where you can meaningfully react. The exception finds its way to whichever frame does have one.

5. Why should you generally avoid an empty catch block like catch (Exception) { }?

Show answer

Correct: B

Why B is correct: An empty catch block catches the exception and then does nothing — no logging, no recovery, no re-throw. The failure simply vanishes as if nothing happened, which is often worse than letting the program crash, because now a real problem is invisible.

Why A is incorrect: It compiles fine — the compiler has no way to know the block is empty on purpose versus by mistake.

Why C is incorrect: A finally block, if present, still runs regardless of what the catch block does or doesn't do.

Why D is incorrect: catch (Exception) catches everything — it's actually too broad, not restricted to any subtype.

Reinforcement: If you catch an exception, do something meaningful with it — log it, handle it, or re-throw it. Never let it disappear silently.

You now understand how exceptions signal failure, how try/catch/finally cooperate, and why the exception hierarchy lets you handle problems precisely. Next up: creating and throwing your own custom exception types.


dotnetmadeeasy.com — Learn C# and .NET, the right way.