Exceptions and Debugging: try, catch, throw, Breakpoints – C# | Lesson 36
Introduction
In the last few lessons, you saw programs stop with messages like FormatException and
NullReferenceException. These are exceptions: objects that describe something going
wrong while the program runs.
In this lesson, you learn to catch exceptions and respond sensibly, to throw your own when a school rule is broken, and to find bugs with the debugger instead of guessing.
By the end of this lesson, you will be able to:
- Catch exceptions with
try,catch, andfinally. - Order several
catchblocks correctly. - Throw an exception when a rule is broken, such as a mark above the maximum.
- Explain why
throw;is right andthrow ex;is wrong. - Use breakpoints, stepping, and the Locals and Call Stack windows.
Why this matters
In Lesson 23, SQL Server refused marks above the maximum with error 50001. The C# code needs the same protection, before the data even reaches the database.
How a program fails matters as much as how it works. A teacher who types 105 by mistake should see "Marks must be between 0 and 100", not a crash and not a silently saved wrong mark. When something does go wrong in production, the error details are your only clue, so they must not be thrown away.
The concept
Example: try, catch, finally
try
{
int rollNumber = int.Parse("three");
}
catch (FormatException ex)
{
Console.WriteLine($"FormatException: {ex.Message}");
}
finally
{
Console.WriteLine("This always runs");
}
| Block | Runs |
|---|---|
try | The code that might fail |
catch (SomeException ex) | Only if that kind of exception happens in try |
finally | Always: after try succeeds, or after a catch |
When an exception is thrown, C# skips the rest of try and looks for a matching catch.
If no method on the way up catches it, the program stops and prints "Unhandled exception".
Think of a school fire drill. The alarm (throw) interrupts every lesson, and the marshal on each floor (catch) handles it in an orderly way.
The headcount outside (finally) happens every time, whatever caused the alarm.
Example: reading an unhandled exception
When nobody catches an exception, .NET prints it and stops. This small program reads a roll number in a method and crashes. The folder path is shortened here:
Reading roll number...
Unhandled exception. System.FormatException: The input string 'three' was not in a correct format.
at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
at System.Int32.Parse(String s)
at Program.<<Main>$>g__ReadRollNumber|0_0(String text) in ...\Program.cs:line 7
at Program.<Main>$(String[] args) in ...\Program.cs:line 2
| Part | Tells you |
|---|---|
System.FormatException | What kind of problem |
| The message after the colon | What exactly went wrong, often with the bad value |
The at ... lines | The stack trace: the chain of calls, newest first |
in ...Program.cs:line 7 | The exact line in your code |
Read from the top, and skip lines in System. until you reach your own code. Here, line 7
in ReadRollNumber called int.Parse, and line 2 called ReadRollNumber. The odd name
<<Main>$>g__ReadRollNumber|0_0 is how the compiler names a local function.
Example: common exception types
| Exception | Typical cause | Seen in |
|---|---|---|
FormatException | Parsing text that is not a number | Lesson 31 |
OverflowException | A number too big for its type | This lesson |
IndexOutOfRangeException | A position outside an array | Lesson 33 |
NullReferenceException | Using a member of null | Lesson 35 |
InvalidOperationException | Doing something not allowed right now, such as .Value on a null decimal? | Lesson 35 |
ArgumentException / ArgumentOutOfRangeException | A method received a bad argument | This lesson |
All of them inherit from the base type Exception, so catch (Exception) catches every
kind. Lesson 39 explains inheritance.
Example: throwing your own
When your code finds a broken rule, it can throw an exception itself:
throw new ArgumentOutOfRangeException(
nameof(marksObtained), marksObtained, $"Marks must be between 0 and {maxMarks}.");
nameof(marksObtained) gives the parameter's name as text, so the message stays correct
even if you rename it later. Choose the most specific existing type; Lesson 37 shows how to
write your own exception classes.
Apply it to the SMS
SchoolRules.cs gets ValidateMark, the C# twin of the SQL trigger from Lesson 23:
Show src/SchoolApp.Console/SchoolRules.cs
// Lesson 34: school rules as reusable methods.
// A static class is a named container for methods; Lesson 37 explains classes in full.
static class SchoolRules
{
public const int PassMark = 33;
// Grade for a percentage: the same scale as fn_GetGrade in SQL (Lesson 21).
public static string GetGrade(decimal percentage)
{
return percentage switch
{
>= 91 => "A1",
>= 81 => "A2",
>= 71 => "B1",
>= 61 => "B2",
>= 51 => "C1",
>= 41 => "C2",
>= 33 => "D",
_ => "E"
};
}
// Lesson 35: an absent student has no mark (NULL), so no grade, like fn_GetGrade(NULL).
public static string? GetGrade(decimal? percentage)
{
if (percentage is null)
{
return null;
}
return GetGrade(percentage.Value);
}
// An expression-bodied method: a one-line method written with =>.
public static bool HasPassed(decimal percentage) => percentage >= PassMark;
// An optional parameter: maxMarks is 100 unless the caller says otherwise.
public static decimal CalculatePercentage(decimal marksObtained, decimal maxMarks = 100m)
{
return Math.Round(marksObtained / maxMarks * 100, 2);
}
// Two overloads: the same name, different parameters.
public static decimal CalculateAttendancePercentage(int attendedDays, int markedDays)
{
if (markedDays == 0)
{
return 0;
}
return Math.Round(100m * attendedDays / markedDays, 2);
}
public static decimal CalculateAttendancePercentage(string week)
{
int attended = 0;
foreach (char day in week)
{
if (day != 'A')
{
attended++;
}
}
return CalculateAttendancePercentage(attended, week.Length);
}
// The Try pattern: return true or false, and hand back results through out parameters.
public static bool TryParseAdmissionNumber(string text, out int year, out int serial)
{
year = 0;
serial = 0;
string[] parts = text.Split('-');
return parts.Length == 3
&& parts[0].Equals("ADM", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(parts[1], out year)
&& int.TryParse(parts[2], out serial);
}
// Lesson 36: refuse impossible marks, the same rule as error 50001 in SQL (Lesson 23).
public static void ValidateMark(decimal marksObtained, decimal maxMarks)
{
if (marksObtained < 0 || marksObtained > maxMarks)
{
throw new ArgumentOutOfRangeException(
nameof(marksObtained), marksObtained, $"Marks must be between 0 and {maxMarks}.");
}
}
// ref: the method changes the caller's own variable.
public static void ApplyPayment(ref decimal balance, decimal amount)
{
balance -= amount;
}
// params: the caller can pass any number of marks.
public static decimal Average(params decimal[] marks)
{
if (marks.Length == 0)
{
return 0;
}
decimal total = 0;
foreach (decimal mark in marks)
{
total += mark;
}
return Math.Round(total / marks.Length, 2);
}
}
Show src/SchoolApp.Console/Program.cs
// Lesson 36: exceptions and debugging.
// 1. Catch a specific exception: int.Parse on text that is not a number.
try
{
int rollNumber = int.Parse("three");
Console.WriteLine($"Roll number {rollNumber}");
}
catch (FormatException ex)
{
Console.WriteLine($"FormatException: {ex.Message}");
}
// 2. Our own rule throws: a mark above the paper's maximum is refused.
decimal[] enteredMarks = { 92m, 105m, 67m };
foreach (decimal mark in enteredMarks)
{
try
{
SchoolRules.ValidateMark(mark, maxMarks: 100m);
Console.WriteLine($"{mark}: accepted");
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"{mark}: refused. {ex.Message}");
}
}
// 3. Several catch blocks, most specific first, and finally, which always runs.
string[] amounts = { "45500", "forty", "99999999999" };
foreach (string text in amounts)
{
try
{
int amount = int.Parse(text);
Console.WriteLine($"Parsed {amount}");
}
catch (FormatException)
{
Console.WriteLine($"'{text}' is not a number");
}
catch (OverflowException)
{
Console.WriteLine($"'{text}' is too big for an int");
}
finally
{
Console.WriteLine(" checked one amount");
}
}
// 4. throw; keeps the original location; throw ex; loses it.
try
{
MarkEntry.RecordWithThrow(105m);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"throw; -> error came from {ex.TargetSite?.Name}");
}
try
{
MarkEntry.RecordWithThrowEx(105m);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"throw ex; -> error came from {ex.TargetSite?.Name}");
}
// Two ways to log an error and pass it on. Classes are explained in Lesson 37.
static class MarkEntry
{
public static void RecordWithThrow(decimal mark)
{
try
{
SchoolRules.ValidateMark(mark, 100m);
}
catch (Exception)
{
Console.WriteLine("Logging the problem, then passing it on");
throw;
}
}
public static void RecordWithThrowEx(decimal mark)
{
try
{
SchoolRules.ValidateMark(mark, 100m);
}
catch (Exception ex)
{
Console.WriteLine("Logging the problem, then passing it on");
throw ex; // warning CA2200, left here on purpose to show the difference
}
}
}
FormatException: The input string 'three' was not in a correct format.
92: accepted
105: refused. Marks must be between 0 and 100. (Parameter 'marksObtained')
Actual value was 105.
67: accepted
Parsed 45500
checked one amount
'forty' is not a number
checked one amount
'99999999999' is too big for an int
checked one amount
Logging the problem, then passing it on
throw; -> error came from ValidateMark
Logging the problem, then passing it on
throw ex; -> error came from RecordWithThrowEx
Example: refusing a mark
For 105, ValidateMark throws, the catch in the loop shows the message, and the loop
moves on to 67. One bad entry does not stop the other marks.
The message includes the parameter name and the actual value, both added by
ArgumentOutOfRangeException. Those details make the error easy to understand, whether a
teacher sees it on screen or a developer reads it in a log.
Example: catch order and finally
Each amount goes through one try. "forty" matches the FormatException block. The value
99999999999 is a number, but too big for an int, so it matches OverflowException.
C# checks catch blocks from top to bottom and uses the first that fits. Put specific
types first and Exception last; otherwise the general block catches everything. The
finally line printed three times: once per amount, success or failure.
Example: throw; versus throw ex;
Both helper methods log the error and pass it on. The difference shows in where the error seems to come from:
| Rethrow with | Error appears to come from | Original location |
|---|---|---|
throw; | ValidateMark, where it really happened | Kept |
throw ex; | RecordWithThrowEx, the method that rethrew | Lost |
throw ex; restarts the error's history, the stack trace, from the rethrow. The build
even warns about it: warning CA2200: Re-throwing caught exception changes stack information.
That warning is left in this lesson's code on purpose. In your own code, always use throw;.
Example: when to use exceptions
| Situation | Use |
|---|---|
| Expected bad input, such as a typed amount | TryParse and an if (Lessons 31 and 34) |
| A broken rule inside your code, such as a mark of 105 | Throw an exception |
| A failure you cannot fix here | Let it travel up to a place that can respond |
| A failure you can handle sensibly | Catch the specific type and respond |
Never write an empty catch { }. It hides the problem, and the bug resurfaces later as a
mystery with no clues.
Example: the debugger
The debugger pauses your program so you can look inside. In Visual Studio 2026 and in VS Code, the keys are the same:
| Key | Action |
|---|---|
| F9 | Set or remove a breakpoint on the current line: the program pauses there |
| F5 | Start with the debugger, or continue to the next breakpoint |
| F10 | Step over: run this line and stop at the next |
| F11 | Step into: go inside the method called on this line |
| Shift+F11 | Step out: finish this method and return to the caller |
| Shift+F5 | Stop debugging |
Set a breakpoint on the throw line inside ValidateMark, and press F5. The program pauses
when 105 arrives.
The Locals window shows marksObtained = 105 and maxMarks = 100. The Call Stack
window shows how you got there: Main, then ValidateMark.
Right-click a breakpoint to add a condition, such as marksObtained > 100. The program
then pauses only for the interesting case, which saves time when a loop runs thousands of
times.
Example: stop where an exception is thrown
By default, the debugger stops only on exceptions that nobody catches. In Visual Studio,
open Debug > Windows > Exception Settings and tick a type, such as
System.ArgumentOutOfRangeException.
The debugger then pauses at the exact throw, even when a catch handles it later. This is
the fastest way to find an exception that is caught and logged deep inside the code, when
you cannot see where it started.
Common mistakes
- Empty
catchblocks. The error disappears, and so do the clues. Fix: handle it, log it, or let it travel up. throw ex;instead ofthrow;. The original location is lost. Fix:throw;, and treat warning CA2200 as an error.catch (Exception)first. Later, specific blocks never run. Fix: specific types first.- Exceptions for normal input. Slow and noisy. Fix:
TryParsefor expected bad input. - Debugging with
Console.WriteLineonly. Fix: use breakpoints, Locals, and Call Stack; they show everything without changing your code.
In real projects
The hardest bug I chased took three days. The fee module "sometimes" saved no payment.
The cause was an empty catch { } someone had added years earlier "to stop a crash". Every
failed save vanished silently.
The fix was to remove the empty catch and let the real error appear. It showed in minutes: a database timeout that needed an index.
On my teams, an empty catch fails code review, and throw ex; is a build error. Both
rules cost nothing and save days.
Best practices
- Catch only exceptions you can handle; let the rest travel up.
- Order
catchblocks from specific to general. - Rethrow with
throw;, neverthrow ex;. - Throw the most specific exception type with a clear message and
nameof. - Use
finallyfor cleanup that must always happen. - Reach for the debugger before adding print statements.
Recap
- An exception describes a failure while the program runs; uncaught, it stops the program.
tryholds risky code,catchhandles a matching type, andfinallyalways runs.ValidateMarkthrowsArgumentOutOfRangeException, like SQL error 50001 in Lesson 23.- C# uses the first matching
catch, so put specific types first. throw;keeps the stack trace;throw ex;loses it, and CA2200 warns you.- Breakpoints, stepping, Locals, and Call Stack find bugs faster than guessing.
Homework
Library module task: Write CalculateFine(issuedOn, returnedOn). It throws an
ArgumentException when the return date is before the issue date, because that is
impossible. Otherwise, it returns the fine: 2 per day after the 14-day loan period.
Call it for a book issued on 10 July 2026 and returned on 28 July, then on 5 July. Catch
the exception and show its message, and print "return checked" in a finally block.
A correct result looks like:
Returned 2026-07-28: fine 8
return checked
Returned 2026-07-05: refused. A book cannot be returned before it was issued. (Parameter 'returnedOn')
return checked
Show the solution
// Library homework, Lesson 36: refuse impossible returns with an exception, and handle it.
// Run with: dotnet run library-assignment/036-library-exceptions.cs
DateOnly issuedOn = new DateOnly(2026, 7, 10);
DateOnly[] returnDates = { new DateOnly(2026, 7, 28), new DateOnly(2026, 7, 5) };
foreach (DateOnly returnedOn in returnDates)
{
try
{
decimal fine = CalculateFine(issuedOn, returnedOn);
Console.WriteLine($"Returned {returnedOn:yyyy-MM-dd}: fine {fine}");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Returned {returnedOn:yyyy-MM-dd}: refused. {ex.Message}");
}
finally
{
Console.WriteLine(" return checked");
}
}
static decimal CalculateFine(DateOnly issuedOn, DateOnly returnedOn)
{
if (returnedOn < issuedOn)
{
throw new ArgumentException("A book cannot be returned before it was issued.", nameof(returnedOn));
}
DateOnly dueOn = issuedOn.AddDays(14);
int daysLate = returnedOn.DayNumber - dueOn.DayNumber;
return daysLate > 0 ? daysLate * 2m : 0m;
}
The check happens first, before any calculation. Refusing bad data at the start of a method, often called a guard clause, keeps the rest of the method simple.
Quiz
When does a finally block run?
Show answer
Answer: C. Always, whether or not an exception happened finally is for cleanup that must always happen.
int.Parse with the text 99999999999 throws which exception?
Show answer
Answer: B. OverflowException It is a valid number, but too big for an int.
What is wrong with throw ex; inside a catch block?
Show answer
Answer: B. It loses the original stack trace Use throw; to keep the original location; the build warns with CA2200.
catch (Exception) is placed before catch (FormatException). What happens with a FormatException?
Show answer
Answer: B. The Exception block catches it first The first matching catch wins, and Exception matches everything.
Which debugger key goes inside the method called on the current line?
Show answer
Answer: D. F11 F11 steps into; F10 steps over.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: C# interview questions.
Fresher (0-2 yrs)
F What do try, catch, and finally do? concept
try holds code that might fail. catch runs when a matching exception is thrown in try.
finally always runs afterwards, success or failure, so it is used for cleanup.
F What happens when an exception is not caught anywhere? concept
It travels up through every calling method. If none catches it, the program stops and prints "Unhandled exception" with the exception type, message, and stack trace.
F What is a breakpoint, and what do F10 and F11 do? concept
A breakpoint (F9) pauses the program at a line so you can inspect variables. F10 steps over the current line; F11 steps into the method called on that line. The Locals and Call Stack windows show the values and the chain of calls.
Mid (3-5 yrs)
M What is the difference between throw; and throw ex;? concept
throw; rethrows the current exception and keeps its original stack trace. throw ex; restarts
the stack trace from the rethrow, so the place where the error really happened is lost. The
analyzer warns with CA2200. Always use throw;.
M Why does the order of catch blocks matter? concept
C# uses the first catch block whose type matches. If catch (Exception) comes first, it matches
everything, and later specific blocks never run. Put the most specific types first.
M When should you throw an exception, and when should you return a result such as false? concept
Return a result for expected situations, such as invalid user input, using the Try pattern. Throw when a rule is broken in a way the method cannot handle, such as a mark above the maximum reaching the save method. Exceptions are for exceptional cases, not normal flow.
Senior (6-9 yrs)
S Payments sometimes go missing with no error anywhere. What would you suspect, and how would you investigate? scenario
A swallowed exception: an empty catch or one that logs nothing. Search for empty catch blocks
around the save path, use the debugger's Exception Settings to stop where exceptions are thrown,
and check database logs. Remove the empty catch so the real error appears, then fix the cause.
How to answer: Show a structured investigation: reproduce, break on thrown exceptions, read logs, then fix the root cause. Add prevention: review rules against empty catch blocks, and logging with context.
S How do you read a stack trace to find a bug? concept
Read the exception type and message first, then the at lines from the top, skipping framework
methods (System.) until you reach your own code. The first line in your code with a file and
line number is usually where to start; the lines below show who called it.
How to answer: Walk through a real trace. Mention that release builds still give method names, that missing
.pdb files remove line numbers, and that throw ex; destroys the useful part.
Architect / Lead (10+ yrs)
A Design an exception-handling strategy for a layered application with an API. design
Validate input early and return clear validation results. Let lower layers throw specific exceptions for broken rules, and do not catch what you cannot handle. Handle everything once, at the edge: global middleware logs the full exception with a correlation id and returns a safe, consistent error response, without exposing internal details.
How to answer: Cover where exceptions are thrown, where they are caught (one place), what is logged, and what the client sees. Mention custom exception types for domain rules and mapping them to HTTP status codes.
Resources
- Code for this lesson: git tag
lesson-36-exceptions-debugging(code repository coming soon) - Files:
src/SchoolApp.Console/SchoolRules.cs,src/SchoolApp.Console/Program.cs,library-assignment/036-library-exceptions.cs - Video: coming soon
What's next
Module 6 is complete: you can write C# programs with types, decisions, loops, methods,
null safety, and error handling. Module 7 turns school data into objects, starting with
classes: one Student that holds a name and a mark together. Lesson 37: Classes and
objects
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.