C# Exception Handling: try, catch and finally Explained
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.
try— the code you're watching. "Attempt this, and I'll deal with problems."catch— what to do when a specific exception type occurs.finally— code that runs no matter what — success, failure, or even an unhandled exception passing through.
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();- Silently ignorable — the caller can throw away the result and nothing stops them.
- No context — a
-1doesn't say why it failed. Insufficient funds? Account frozen? Network timeout? - Clutters every call site — every single call needs an
ifcheck, and it's easy to get the check subtly wrong. - Doesn't compose — if a method three layers deep fails, every layer in between has to manually check and re-propagate the failure code.
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
- Just a number, bool, or null
- Easy to forget to check
- Carries little context
- Must be manually re-checked at every layer
- Failure can silently propagate as "success"
Exceptions
- A rich object with message, type, stack trace
- Cannot be silently ignored — it stops execution
- Automatically propagates up the call stack
- Caught precisely by type where it makes sense
- Unhandled failures are loud, not silent
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:
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
{
Console.WriteLine("Step A");
Console.WriteLine("Step B"); // ← an exception happens here
Console.WriteLine("Step C"); // ← this line is skipped entirely
}
- The moment an exception is thrown, the rest of the try block is abandoned immediately.
catch (DivideByZeroException ex)
{
Console.WriteLine($"Math problem: {ex.Message}");
}
- Catch blocks are checked top to bottom, and the first type match wins.
- A catch for a base type (like
Exception) also matches any derived exception type.
finally
{
Console.WriteLine("Cleanup runs here — always.");
}
- Runs whether the try block succeeded, an exception was caught, or even if no catch matched and the exception is about to keep propagating.
- This is the one guarantee exceptions give you: cleanup code you put in
finallywill execute.
- If the exception was caught, the program continues normally from just after the block.
- If no catch matched, the exception keeps propagating up to the caller — after
finallystill runs first.
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 Type | Thrown When… |
|---|---|
System.Exception | The root of everything. Catching this catches all exceptions. |
SystemException | Base for exceptions thrown by the .NET runtime itself. |
ArgumentException | An argument passed to a method is invalid. |
ArgumentNullException | An argument that shouldn't be null, was null. (Derives from ArgumentException.) |
ArgumentOutOfRangeException | An argument is outside its allowed range. (Derives from ArgumentException.) |
InvalidOperationException | A method call is invalid given the object's current state. |
NullReferenceException | Code dereferenced a null object reference. |
DivideByZeroException | Integer division by zero. |
IndexOutOfRangeException | An array index is outside its bounds. |
FormatException | A string isn't in the format a parsing method expects. |
IOException | A 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:
numerator / denominatorthrows before the assignment ever completes —resultnever gets a value.- The
catchblock's declared type,DivideByZeroException, matches the thrown exception's type exactly, so it runs. finallyruns after the catch block completes, before the program moves on.- Because the exception was caught, the program doesn't crash — it continues normally after the whole
try/catch/finallyblock.
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}");
}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.
- A
throwstatement instantiates the exception (or reuses an existing instance) and hands it to the CLR.
- For each method frame, it checks: does this frame have a
tryblock active right now, with acatchwhose type matches? - If not, that frame is popped off the stack — and crucially, any
finallyblock active in that frame runs first, before the frame is discarded.
- If a matching
catchis found, execution resumes there. Everything below it on the stack is gone — those methods never get to finish their remaining code (except their ownfinallyblocks, which already ran during unwinding). - If the stack unwinds all the way to the top with no match, the runtime treats it as unhandled — the process terminates (or, in ASP.NET Core, the request fails with a 500 and the exception is logged).
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?
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
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
- Exceptions exist because error codes are silently ignorable — exceptions cannot be, they forcibly interrupt execution.
trywatches for trouble,catch (Type)handles a specific kind of trouble,finallyalways runs.- Every exception derives from
System.Exception, and a catch block matches its declared type or any subtype. - An uncaught exception unwinds the call stack —
finallyblocks still run along the way even when nothing catches it. - Use exceptions for genuinely unexpected failures, not for routine, anticipated input validation.
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.