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.
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.
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.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();-1 doesn't say why it failed. Insufficient funds? Account frozen? Network timeout?if check, and it's easy to get the check subtly wrong.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.
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.
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.
try
{
Console.WriteLine("Step A");
Console.WriteLine("Step B"); // ← an exception happens here
Console.WriteLine("Step C"); // ← this line is skipped entirely
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"Math problem: {ex.Message}");
}
Exception) also matches any derived exception type.finally
{
Console.WriteLine("Cleanup runs here — always.");
}
finally will execute.finally still runs first.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.
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 / denominator throws before the assignment ever completes — result never gets a value.catch block's declared type, DivideByZeroException, matches the thrown exception's type exactly, so it runs.finally runs after the catch block completes, before the program moves on.try/catch/finally block.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.
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.
Two things are worth understanding about what actually happens when an exception is thrown.
throw statement instantiates the exception (or reuses an existing instance) and hands it to the CLR.try block active right now, with a catch whose type matches?finally block active in that frame runs first, before the frame is discarded.catch is found, execution resumes there. Everything below it on the stack is gone — those methods never get to finish their remaining code (except their own finally blocks, which already ran during unwinding).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.
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.
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.
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.
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
} 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.
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.
TryXxx pattern. Reserve exceptions for conditions that are genuinely exceptional — the kind of thing that, if it happens, something has actually gone wrong.
finally is the one thing guaranteed to run — that's what makes it the home for cleanup.
try watches for trouble, catch (Type) handles a specific kind of trouble, finally always runs.System.Exception, and a catch block matches its declared type or any subtype.finally blocks still run along the way even when nothing catches it.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?
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");
}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?
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?
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) { }?
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.