Debugging Code
Before you start
You need: error thinking (Article 01) and code to debug.
Time: about 50 minutes, at the keyboard.
Learning objective
Find the exact line where a value stops being correct, using a debugger rather than print statements.
Topics
- Breakpoints and stepping
- Conditional breakpoints
- Watch, Locals and Immediate
- The Call Stack
- Exception settings
- Debugging a loop
- Logging when a debugger cannot help
- Debugging async code
Breakpoints and stepping
| Key | Action | Use when |
|---|---|---|
F9 | Toggle breakpoint | — |
F5 | Continue | Move to the next breakpoint |
F10 | Step over | The next line is a call you trust |
F11 | Step into | You suspect the call |
Shift+F11 | Step out | You stepped into something irrelevant |
Ctrl+F10 | Run to cursor | Skip ahead without setting a breakpoint |
The default should be F10. Stepping into every call — including framework code — wastes the session. Step in only where you suspect the fault.
Set the breakpoint before the suspect line, not on it. You need to see the state going in.
public decimal CalculateTotalFees(int studentId)
{
FeeAccount account = _feeRepository.GetByStudentId(studentId); // breakpoint here
decimal total = account.TotalFees;
decimal discount = account.DiscountAmount;
return total - discount;
}
Break on line 3 and account is already null with no information about why. Break on the lookup and you can step over it and see it return null.
Conditional breakpoints
This is the feature that makes debugging real data practical.
Right-click a breakpoint → Conditions:
studentId == 12
student.RollNumber == "NCA-2024-0012"
result == null
marks < 0
i > 500
A loop over 800 students, failing on one: without a condition you press F5 hundreds of times. With studentId == 12 you arrive at the failure immediately.
Other kinds:
| Type | Effect |
|---|---|
| Conditional expression | Breaks when the expression is true |
| Hit count | Breaks on the 250th pass |
| Filter | Breaks only on a specific thread |
| Action / tracepoint | Logs a message and continues — no break at all |
A tracepoint is Console.WriteLine without editing the code. Message with {studentId} and {account?.TotalFees}, tick "Continue execution", and you get a log of every iteration without a rebuild.
VS Code calls the same thing a logpoint.
Watch, Locals and Immediate
| Window | Purpose |
|---|---|
| Locals | Every variable in scope, automatically |
| Autos | Variables used near the current line |
| Watch | Expressions you choose to track |
| Immediate | Evaluate anything, including method calls |
Watch takes expressions, not just names:
account?.TotalFees
students.Count(s => s.SchoolId != currentSchoolId)
results.Where(r => r.IsAbsent).Count()
DateTime.Now > feeAccount.DueDate
The second one is worth noting: a watch expression that counts rows from the wrong school is a multi-tenant leak test you can run mid-debug.
Immediate window:
> account
null
> _feeRepository.GetByStudentId(13)
{FeeAccount}
> students.Count
800
> students.Where(s => s.RollNumber == "NCA-2024-0012").ToList()
Count = 0
The Immediate window can call methods with different arguments, which is how you test a hypothesis without restarting: does the repository work for student 13 but not 12?
Careful — calling a method that writes to the database from the Immediate window really writes to the database.
The Call Stack
FeeService.CalculateTotalFees(studentId: 12) ← current
FeeController.GetSummary(studentId: 12)
[External Code]
The Call Stack answers "how did I get here?" — the question that matters when a method is called from six places and misbehaves in one.
Double-click a frame to jump there and inspect its locals. That is how you find that the caller passed 0 because a route parameter did not bind.
Reading the stack upward from the failure is how you find the wrong argument, rather than the code that merely received it. The bug is often two frames above the exception.
Exception settings
Debug → Windows → Exception Settings, tick Common Language Runtime Exceptions.
The debugger then breaks where the exception is thrown, not where it is caught.
try
{
return CalculateTotalFees(studentId);
}
catch (Exception)
{
return 0; // the bug is now invisible
}
A swallowed exception is the hardest bug to find, because there is no error anywhere — just a wrong number. With first-chance exceptions enabled, the debugger stops at the actual throw site and the cause is immediately visible.
Turn it on when a value is wrong and nothing is logged. Turn it off afterwards, since it also breaks on exceptions the framework handles normally.
Debugging a loop
foreach (ExamResult result in results)
{
if (result.MarksObtained >= subject.PassingMarks) // breakpoint, condition: result.StudentId == 12
{
passed++;
}
}
Techniques, in order of usefulness:
- Conditional breakpoint on the failing item.
result.StudentId == 12. - Hit count when you know it fails on the 250th.
- Tracepoint to log every iteration and read the pattern afterwards.
- A watch on the accumulator —
passed,total— to see the iteration where it goes wrong.
Look for the first iteration where the value is wrong, not the last. By the end, the damage has propagated.
The bug in the example above: MarksObtained is null for an absent student, so the comparison is false and absentees are silently counted as failed rather than excluded. The absent check must come first, and no exception ever fires to tell you.
Logging when a debugger cannot help
A debugger needs you present. Production, CI, race conditions and intermittent failures need logs.
_logger.LogInformation(
"Calculating fees for student {StudentId} in school {SchoolId}",
studentId, schoolId);
_logger.LogWarning(
"No fee account for student {StudentId}; returning zero",
studentId);
_logger.LogError(ex,
"Fee calculation failed for student {StudentId}",
studentId);
Use structured logging with named placeholders. LogInformation("Student {StudentId}", id) stores StudentId as a searchable field; LogInformation($"Student {id}") stores a flat string you can only grep.
| Level | For |
|---|---|
Trace | Very detailed; off in production |
Debug | Development diagnostics |
Information | Normal significant events |
Warning | Unexpected but handled |
Error | A failed operation |
Critical | The application cannot continue |
Never log a password, token, connection string or full card number. Logs are copied, shipped to third-party services and read by people without database access. Log the student id, not the parent's phone number.
Log the inputs at the point of failure. "Fee calculation failed" is useless; "failed for student 12 in school 3" is a reproduction.
Debugging async code
public async Task<FeeSummary> GetSummaryAsync(int studentId)
{
FeeAccount account = await _feeRepository.GetByStudentIdAsync(studentId);
List<FeePayment> payments = await _paymentRepository.GetByAccountAsync(account.Id);
return BuildSummary(account, payments);
}
Three specifics:
Stack traces are truncated. The frames before an await are gone, so the trace often starts mid-operation. Log the parameters instead of relying on it.
Stepping over await can jump somewhere unexpected, because control returns to the caller while the task completes. Use the Tasks window (Debug → Windows → Tasks) to see what is actually running.
A missing await is a bug the debugger will not point at:
_auditRepository.LogAsync(entry); // fire and forget — exceptions vanish
await _auditRepository.LogAsync(entry); // correct
The first compiles with a warning, runs, and silently discards any exception. Treat compiler warning CS4014 as an error.
Diagnosing
| Symptom | Approach |
|---|---|
| Breakpoint hollow, never hits | Symbols not loaded, or the running build is stale — rebuild |
| Wrong value, no exception | First-chance exceptions on; check for a swallowed catch |
| Fails on one record | Conditional breakpoint on that id |
| Fails on the 250th iteration | Hit-count breakpoint |
| Called with the wrong argument | Call Stack — inspect the caller's frame |
| Only fails in production | Logging, not the debugger |
| Intermittent | Tracepoints and logs; a breakpoint changes the timing |
| Wrong method entirely | Call Stack shows who called what |
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Breakpoint hollow, never hits | Stale build or wrong startup project | Rebuild; set the startup project |
| A wrong value and no exception | Something is swallowing it | Enable first-chance exceptions |
| Stepping takes forever | F11 into framework code | F10 by default |
| The failing item is one of 800 | No condition on the breakpoint | Add studentId == 12 |
| Async stack trace is missing frames | Awaits break the chain | Log the parameters instead |
Set the breakpoint before the failing line, not on it. You need to see the state going in.
Common mistakes
Console.WriteLinewhere a conditional breakpoint is faster- Stepping into framework code with
F11 - Breaking on the failing line instead of before it
- Ignoring the Call Stack
- Debugging a stale build and wondering why breakpoints do not hit
- Catch blocks that swallow exceptions
- Interpolated strings in structured logs
- Logging secrets or personal data
- Not awaiting a task
- Trusting an async stack trace
Practice
The course exercise is debug a broken method.
- Set a breakpoint before a suspect call and step over it, watching the return value.
- Loop over 800 students and set a conditional breakpoint on
studentId == 12. - Set a hit-count breakpoint that stops on the 250th iteration.
- Set a tracepoint that logs
{studentId}and continues. Read the output afterwards. - Add three watch expressions, including one that counts rows from the wrong school.
- Use the Immediate window to call the repository with a different id.
- Break inside a method called from three places, and use the Call Stack to find which caller passed the wrong value.
- Write a catch block that swallows an exception. Find the throw site using first-chance exceptions.
- Debug the absent-student loop: confirm null marks make the comparison false, and fix it by checking
IsAbsentfirst. - Convert an interpolated log message to structured logging and compare what each stores.
- Log a failure without the inputs, then with them. Try to reproduce from each.
- Remove an
awaitfrom a call and observe that the exception disappears entirely. - Rebuild after a code change and confirm your breakpoints become solid again.
Exercises 8 and 12 are the two bugs that produce no error at all. Those are the ones worth having seen.
You can now
- Locate the line where a value first goes wrong
- Use conditional breakpoints, hit counts and tracepoints
- Read Locals, Watch, Immediate and the Call Stack
- Find a swallowed exception's throw site
- Switch to logging when a debugger cannot help
Review questions
- Why set a breakpoint before the failing line rather than on it?
- What problem does a conditional breakpoint solve that stepping cannot?
- What does enabling first-chance exceptions let you find?
- Why is structured logging preferable to an interpolated string?