Skip to main content
Published / updated

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

KeyActionUse when
F9Toggle breakpoint
F5ContinueMove to the next breakpoint
F10Step overThe next line is a call you trust
F11Step intoYou suspect the call
Shift+F11Step outYou stepped into something irrelevant
Ctrl+F10Run to cursorSkip 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:

TypeEffect
Conditional expressionBreaks when the expression is true
Hit countBreaks on the 250th pass
FilterBreaks only on a specific thread
Action / tracepointLogs 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

WindowPurpose
LocalsEvery variable in scope, automatically
AutosVariables used near the current line
WatchExpressions you choose to track
ImmediateEvaluate 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:

  1. Conditional breakpoint on the failing item. result.StudentId == 12.
  2. Hit count when you know it fails on the 250th.
  3. Tracepoint to log every iteration and read the pattern afterwards.
  4. A watch on the accumulatorpassed, 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.

LevelFor
TraceVery detailed; off in production
DebugDevelopment diagnostics
InformationNormal significant events
WarningUnexpected but handled
ErrorA failed operation
CriticalThe 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

SymptomApproach
Breakpoint hollow, never hitsSymbols not loaded, or the running build is stale — rebuild
Wrong value, no exceptionFirst-chance exceptions on; check for a swallowed catch
Fails on one recordConditional breakpoint on that id
Fails on the 250th iterationHit-count breakpoint
Called with the wrong argumentCall Stack — inspect the caller's frame
Only fails in productionLogging, not the debugger
IntermittentTracepoints and logs; a breakpoint changes the timing
Wrong method entirelyCall Stack shows who called what

Errors you will hit

What you seeCauseFix
Breakpoint hollow, never hitsStale build or wrong startup projectRebuild; set the startup project
A wrong value and no exceptionSomething is swallowing itEnable first-chance exceptions
Stepping takes foreverF11 into framework codeF10 by default
The failing item is one of 800No condition on the breakpointAdd studentId == 12
Async stack trace is missing framesAwaits break the chainLog the parameters instead

Set the breakpoint before the failing line, not on it. You need to see the state going in.

Common mistakes

  • Console.WriteLine where 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.

  1. Set a breakpoint before a suspect call and step over it, watching the return value.
  2. Loop over 800 students and set a conditional breakpoint on studentId == 12.
  3. Set a hit-count breakpoint that stops on the 250th iteration.
  4. Set a tracepoint that logs {studentId} and continues. Read the output afterwards.
  5. Add three watch expressions, including one that counts rows from the wrong school.
  6. Use the Immediate window to call the repository with a different id.
  7. Break inside a method called from three places, and use the Call Stack to find which caller passed the wrong value.
  8. Write a catch block that swallows an exception. Find the throw site using first-chance exceptions.
  9. Debug the absent-student loop: confirm null marks make the comparison false, and fix it by checking IsAbsent first.
  10. Convert an interpolated log message to structured logging and compare what each stores.
  11. Log a failure without the inputs, then with them. Try to reproduce from each.
  12. Remove an await from a call and observe that the exception disappears entirely.
  13. 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

  1. Why set a breakpoint before the failing line rather than on it?
  2. What problem does a conditional breakpoint solve that stepping cannot?
  3. What does enabling first-chance exceptions let you find?
  4. Why is structured logging preferable to an interpolated string?

Next: Debugging web applications