Debugging and Best Practices
Every programmer writes bugs; good programmers find them quickly and write code that makes them less likely. This lesson covers reading stack traces, using a debugger, letting the compiler catch more mistakes for you, and the conventions that make C# code easy to read and maintain.
Reading a stack trace
When a program crashes, the full output includes a stack trace: the chain of method calls that led to the error, most recent first. Take this program:
var orders = new List<decimal> { 20m, 35m }; Console.WriteLine(Report.Average(orders, skip: 2)); static class Report { public static decimal Average(List<decimal> values, int skip) { var remaining = values.Skip(skip).ToList(); return Sum(remaining) / remaining.Count; } static decimal Sum(List<decimal> values) => values.Sum(); }
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.
In the terminal, that first line is followed by the trace (shortened here):
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero. at System.Decimal.DecCalc.VarDecDiv(DecCalc& d1, DecCalc& d2) at System.Decimal.op_Division(Decimal d1, Decimal d2) at Report.Average(List`1 values, Int32 skip) in /home/sam/demo/Program.cs:line 9 at Program.<Main>$(String[] args) in /home/sam/demo/Program.cs:line 2
Read it like this:
- The first line says what went wrong: the exception type and message.
- Skip the frames inside .NET itself (
System.…) until you reach your code —Report.Average, line 9. - The frames below show how you got there:
MaincalledAveragefrom line 2.
Here, skipping 2 of 2 orders left an empty list, so the division by remaining.Count divided by zero. The fix might be to check for an empty list, or to reject invalid skip values.
Using the debugger
Printing values works, but a debugger is far more powerful. In VS Code (with C# Dev Kit), Visual Studio or Rider:
- Breakpoint — click in the margin next to a line (or press F9). Execution pauses there.
- Start debugging — F5. The program runs until it hits a breakpoint or an exception.
- Step over (F10) runs the current line; step into (F11) follows a method call inside; step out (Shift+F11) finishes the current method.
- Variables and Watch panels show current values; hover over any variable in the editor to see it.
- Conditional breakpoints pause only when an expression is true — for example
i == 999in a long loop. - Call stack shows the same chain as a stack trace, and you can click any frame to inspect its variables.
Debuggers can also be set to break when an exception is thrown, before anything catches it — ideal for finding where an error really starts.
Assertions and logging
Debug.Assert documents assumptions and checks them in Debug builds only (in Release builds the calls are removed). For diagnostics, write to the standard error stream, which stays separate from normal output:
using System.Diagnostics; static decimal ApplyRate(decimal amount, decimal rate) { Debug.Assert(rate is >= 0 and <= 1, "rate must be a fraction"); Console.Error.WriteLine($"[debug] amount={amount} rate={rate}"); return amount * rate; }
Larger applications use a logging library — Microsoft.Extensions.Logging, built into ASP.NET Core — with levels (Debug, Information, Warning, Error) that can be switched on and off without changing code.
Let the compiler help
The cheapest bug is one the compiler catches. Treat warnings seriously, and consider making them errors so they can't be ignored. In the project file:
<PropertyGroup> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <AnalysisLevel>latest-recommended</AnalysisLevel> </PropertyGroup>
AnalysisLevel turns on more of the built-in .NET code analysers, which flag issues like culture-sensitive string comparisons, disposable objects that aren't disposed, and inefficient LINQ usage. An .editorconfig file in the repository sets formatting and naming rules for everyone, and dotnet format applies them automatically.
Common C# bugs and how to avoid them
| Bug | Prevention |
|---|---|
NullReferenceException | Keep nullable reference types on and fix every warning (lesson 43). |
Integer division (7 / 2 == 3) | Make one operand a double or decimal (lesson 8). |
Money stored in double | Use decimal. |
| Off-by-one loops | Prefer foreach; use i < Length, not <=. |
Modifying a collection during foreach | Use RemoveAll, loop backwards, or build a new list (lesson 27). |
| Forgetting to dispose | using declarations (lesson 53). |
| Culture-dependent parsing | CultureInfo.InvariantCulture for data (lesson 9). |
| Repeated enumeration of a LINQ query | Materialise with ToList() once (lesson 49). |
| Blocking on async code | await, never .Result (lesson 57). |
| Shared data between threads | Avoid sharing, or use locks and concurrent collections (lesson 58). |
Writing readable C#
- Names: PascalCase for types, methods and properties; camelCase for locals and parameters;
_camelCasefor private fields; interfaces start withI; async methods end withAsync. - Small methods that do one thing, with names that say what.
- Guard clauses at the top of a method instead of deep nesting.
- Immutability by default:
readonlyfields,initproperties, records. - Encapsulation: private fields, public properties and methods that keep objects valid.
- Pattern matching and switch expressions instead of long
ifchains. - Comments that explain why, not what — the code already says what.
varwhen the type is obvious from the right-hand side; the explicit type when it isn't.
Before and after
The same logic, first written in a hurry, then cleaned up:
Console.WriteLine(Calc(new List<int> { 3, 8, 12 }, 1)); static string Calc(List<int> l, int t) { string r = ""; if (l != null) { if (l.Count > 0) { int s = 0; for (int i = 0; i <= l.Count - 1; i++) { s = s + l[i]; } if (t == 1) { r = "Total: " + s; } else { r = "Average: " + (s / l.Count); } } } return r; }
Total: 23
Console.WriteLine(Summarise([3, 8, 12], SummaryKind.Total)); Console.WriteLine(Summarise([3, 8, 12], SummaryKind.Average)); static string Summarise(IReadOnlyCollection<int> values, SummaryKind kind) { if (values.Count == 0) return ""; return kind switch { SummaryKind.Total => $"Total: {values.Sum()}", SummaryKind.Average => $"Average: {values.Average():F1}", _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; } enum SummaryKind { Total, Average }
Total: 23 Average: 7.7
The second version has meaningful names, an enum instead of a magic number, a guard clause instead of nesting, LINQ instead of a manual loop, and no accidental integer division in the average. It also accepts any read-only collection, not just a List.
Debug and Release builds
dotnet build and dotnet run use the Debug configuration: little optimisation, full debugging information. For anything you measure or ship, use Release (dotnet run -c Release, dotnet publish), which can be much faster. Never judge performance from a Debug build — and measure with a benchmarking tool such as BenchmarkDotNet before optimising.
Try it yourself
This method has a bug. Run it, read the output, and fix it so the average of 1 and 2 is 1.5.
1 4