Debugging in C#: Breakpoints, the Call Stack, Debug.Assert and Compiler Warnings
Why doesn't Debug.WriteLine show up in the terminal, and why does Debug.Assert vanish in Release? Debugger concepts independent of the IDE, finding an integer division bug with a watch, reading a stack trace, the anatomy of dotnet build warnings, and turning nullable warnings into errors with WarningsAsErrors.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We added two "debugging" lines to a stock check: one prints an intermediate value, the other verifies that there is enough stock. When you run the program from the terminal with dotnet run, what do you see on the screen? What changes when you build and run the same program in Release mode?
using System.Diagnostics;
int stock = 3;
Debug.WriteLine($"Stock before sale: {stock}");
Debug.Assert(stock >= 5, "Stock is too low");
Console.WriteLine("Sale completed");
The Debug Class Does Not Write to the Console
This is the output I got with dotnet run:
Process terminated. Assertion failed.
Stock is too low
at Program.<Main>$(String[] args) in /.../Bookstore.Debugging/Program.cs:line 5
The program shut down before it could print "Sale completed", and on macOS the exit code was 134. I removed the NETSDK1138 build warning that dotnet run prints at the very top and shortened the beginning of the file path; the rest is exactly like this. We will come back to that warning below. Notice that the "Stock before sale: 3" line is nowhere to be seen. Debug.WriteLine does not write to the console; it writes to trace listeners. The default listener sends the output to the Output or Debug Console window of an attached debugger, if there is one. When you run from the terminal, no debugger is listening, so the text is lost.
Debug.Assert, on the other hand, stops the program if the condition is false. This is not an exception, and you cannot catch it with try/catch; it is an alarm that says "we should never have got here". With a debugger attached, the IDE stops right on that line and gives you a chance to inspect what happened.
In Release mode the picture changes completely:
$ dotnet build -c Release
$ dotnet bin/Release/net6.0/Bookstore.Debugging.dll
Sale completed
I left out the build output. The assert's condition is still false, but the program finished without a problem. The methods of the Debug class carry the [Conditional("DEBUG")] attribute: if the DEBUG symbol is not defined, the compiler removes these calls from the code entirely, and their arguments are not even evaluated. The Debug configuration defines this symbol; Release does not. So Debug.Assert is a tool that warns you while you develop; it does not protect users in production. For checks that must hold in production too, use a normal if and an exception; we discussed this in the exception handling post.
If you want to see Debug.WriteLine output in the terminal, you add a listener:
using System.Diagnostics;
Trace.Listeners.Add(new ConsoleTraceListener());
int stock = 3;
Debug.WriteLine($"Stock before sale: {stock}");
Debug.Indent();
Debug.WriteLine("Checking reservations...");
Debug.Unindent();
Debug.WriteLineIf(stock < 5, "Stock is running low");
Console.WriteLine("Sale completed");
Stock before sale: 3
Checking reservations...
Stock is running low
Sale completed
Use this for small experiments. In a real application you record what happens with logging; we covered it in detail with ILogger in the logging post of the ASP.NET Core Web API series.
The Basic Concepts of a Debugger
Whether you use Visual Studio, VS Code (with the C# extension) or Rider, the debugger is built on the same few concepts. The shortcuts differ; the ideas do not.
A breakpoint is a line where the program will stop. Click next to a line number and a red dot appears. When you start the program with the debugger (F5 in Visual Studio and VS Code), everything freezes the moment execution reaches that line: variables, the call stack, the current state. The line has not run yet; the line you stopped on is the one that will run in the next step.
Once stopped, you move forward step by step. Step over (F10) runs the current line and moves to the next; if the line calls a method, it does not go inside it. Step into (F11) goes inside the called method. Step out (Shift+F11) runs the rest of the current method and takes you back to its caller. Continue (F5) runs until the next breakpoint. In Rider the shortcuts depend on the keymap you choose, but the names in the menu are the same.
When you are stopped, there are three places to see values. The Locals (or Variables) window shows all variables in the current scope. In the Watch window you type any expression, and it is re-evaluated at every step: not just variables, but expressions such as price * rate or counts.Length as well. Hovering the mouse over a variable in the code also shows its value.
Hunting a Bug With the Debugger
Let's try the theory on a bug. The discount function should apply a 15% discount to a book that costs 200, but the result does not change:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(ApplyDiscount(200m, 15)); // 200
Console.WriteLine(ApplyDiscountFixed(200m, 15)); // 170.00
decimal ApplyDiscount(decimal price, int percent)
{
decimal rate = percent / 100;
return price - price * rate;
}
decimal ApplyDiscountFixed(decimal price, int percent)
{
decimal rate = percent / 100m;
return price - price * rate;
}
Put a breakpoint on the return line and start the program with the debugger, and the Locals window shows price = 200, percent = 15 and rate = 0. The problem is now visible: the rate is zero. Type percent / 100 into the Watch window and you see 0; type percent / 100m and you see 0.15. This is the integer division trap from the post on numbers: dividing two ints gives an int again, and the result is converted to decimal only after the division. Without a debugger you would add Console.WriteLine line by line to find this bug; with the debugger you saw it in a single stop.
Stopping on every iteration is annoying when the bug is inside a loop. That is what a conditional breakpoint is for: right-click the breakpoint, write a condition (i == 2 or quantity > 100), and the debugger stops only when the condition is true. With a hit count you can say "stop on the 100th hit", and some IDEs offer logpoints (tracepoints) that only print a message without stopping. Logpoints are much cleaner than adding Console.WriteLine to the code and then forgetting to remove it.
Reading the Call Stack
The call stack shows which chain of calls led to the current line. The debugger has a separate window for it, but you also see the same information in the stack trace of every unhandled exception. In this code that calculates a cart total, the price and quantity arrays have different lengths:
decimal[] prices = { 120m, 85.5m, 240m };
int[] quantities = { 1, 2 };
decimal total = CalculateTotal(prices, quantities);
Console.WriteLine($"Total: {total}");
decimal CalculateTotal(decimal[] unitPrices, int[] counts)
{
decimal sum = 0;
for (int i = 0; i < unitPrices.Length; i++)
{
sum += LineTotal(unitPrices[i], counts[i]);
}
return sum;
}
decimal LineTotal(decimal price, int quantity) => price * quantity;
Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
at Program.<<Main>$>g__CalculateTotal|0_0(Decimal[] unitPrices, Int32[] counts) in /.../Program.cs:line 12
at Program.<Main>$(String[] args) in /.../Program.cs:line 4
A stack is read from top to bottom. The top line is where the exception was thrown: CalculateTotal, line 12. Each line below is the place that called the one above it: CalculateTotal was called by the top-level code on line 4. LineTotal is not in the list, because the exception was thrown while evaluating the counts[i] argument, that is, before LineTotal was even called.
The odd names are the compiler's work. Top-level statements turn into a method called <Main>$ in a Program class, and the local functions inside get generated names such as <<Main>$>g__CalculateTotal|0_0. It is enough to find the name you gave inside these names.
In the debugger, double-clicking a line in the call stack window takes you to where that call happened, and you see the local variables at that moment. Going to line 4 and seeing that quantities.Length is 2 explains the bug. To stop at the moment an exception is thrown, IDEs have a "break when thrown" option in their exception settings; it is especially useful for finding exceptions that are caught and swallowed somewhere.
Reading Compiler Warnings
You can catch some bugs without running the program at all. This code has at least five problems:
string? coupon = Environment.GetEnvironmentVariable("BOOKSTORE_COUPON");
int discount = 10;
int unused;
if (coupon.Length > 0)
{
Console.WriteLine($"Coupon: {coupon}");
}
string code = Environment.GetEnvironmentVariable("BOOKSTORE_CODE");
Console.WriteLine(code);
return;
Console.WriteLine("Done");
dotnet build tells you all of them. I shortened the paths:
.../Microsoft.NET.EolTargetFrameworks.targets(32,5): warning NETSDK1138: The target framework 'net6.0' is out of support and will not receive security updates in the future. Please refer to https://aka.ms/dotnet-core-support for more information about the support policy. [.../Bookstore.Debugging.csproj]
Program.cs(5,5): warning CS8602: Dereference of a possibly null reference. [.../Bookstore.Debugging.csproj]
Program.cs(10,15): warning CS8600: Converting null literal or possible null value to non-nullable type. [.../Bookstore.Debugging.csproj]
Program.cs(14,1): warning CS0162: Unreachable code detected [.../Bookstore.Debugging.csproj]
Program.cs(2,5): warning CS0219: The variable 'discount' is assigned but its value is never used [.../Bookstore.Debugging.csproj]
Program.cs(3,5): warning CS0168: The variable 'unused' is declared but never used [.../Bookstore.Debugging.csproj]
Every line has the same structure: the file, the line and column in parentheses, the severity (warning or error), the code, the message, and the project in square brackets. The prefix of the code tells you who is talking. CS comes from the C# compiler. NETSDK comes from the .NET SDK; this one reminds you that .NET 6, which this series targets on purpose, is out of support. MSB comes from MSBuild and NU from NuGet. If you do not recognize a code, searching for it usually brings up the Microsoft documentation as the first result.
Let's read the warnings one by one. CS8602: coupon can be null, and .Length uses it without a null check; if the environment variable is missing, this line throws a NullReferenceException. CS8600: a value that can be null is assigned to a variable marked as non-nullable. CS0162: the line after return will never run. CS0219 and CS0168: unused variables; they look harmless, but they are often the trace of unfinished work. The nullable warnings are the most valuable in this list: each one is a compile-time herald of a NullReferenceException that can happen at run time.
By the way, in an interactive terminal the .NET 10 SDK shows dotnet build output in a more compact view (the terminal logger); you see the classic lines above when the output goes to a file or another program. The information is the same.
Turning Warnings Into Errors
The biggest enemy of warnings is habit. If a project has 200 warnings, nobody sees the 201st. The way to prevent this is to turn important warnings into errors. I added this line to the csproj:
<WarningsAsErrors>nullable</WarningsAsErrors>
Program.cs(10,15): error CS8600: Converting null literal or possible null value to non-nullable type. [.../Bookstore.Debugging.csproj]
Program.cs(14,1): warning CS0162: Unreachable code detected [.../Bookstore.Debugging.csproj]
Program.cs(2,5): warning CS0219: The variable 'discount' is assigned but its value is never used [.../Bookstore.Debugging.csproj]
Program.cs(3,5): warning CS0168: The variable 'unused' is declared but never used [.../Bookstore.Debugging.csproj]
Program.cs(5,5): error CS8602: Dereference of a possibly null reference. [.../Bookstore.Debugging.csproj]
The value nullable is a shortcut that covers all nullable warnings. Now the two nullable warnings became errors and the build fails; the others stayed warnings. To turn all compiler warnings into errors there is <TreatWarningsAsErrors>true</TreatWarningsAsErrors>. It is easy to switch on from the start in a new project; in an old project with hundreds of warnings, start with the most important group using WarningsAsErrors.
If you are sure a warning really is a false alarm, you can silence it for just that line: #pragma warning disable CS0168, followed right after by #pragma warning restore CS0168. To turn it off for the whole project there is <NoWarn> in the csproj. Use both rarely and with a comment next to them; every silenced warning leaves the next person asking "why here?".
Common Pitfalls
Looking for Debug.WriteLine output in the terminal. By default it goes to the debugger's output window. If you want to see it in the terminal, add a listener, or better, use logging.
Writing a check that must hold in production with Debug.Assert. That line does not exist in a Release build. Validate user input, file contents and responses from external services with if and exceptions.
Putting code with side effects inside Debug.Assert. Debug.Assert(TryReserve(book)) reserves the book in Debug, and never reserves it in Release because the call is removed. Store the result in a variable first, then assert on it.
Trying to debug a Release build and being surprised that variables show up as "optimized away". In Release the compiler and the JIT optimize the code; some variables never exist and lines move around. Use the Debug configuration when debugging.
Ignoring warnings. Warnings that scroll by on every build become invisible after a while. Turn nullable warnings into errors with WarningsAsErrors and clean up the rest.
Reading a stack trace from the bottom up. The top line is where the problem is; the lines below show how you got there. Start from the first line where your own code appears.
A Small Experiment
★☆☆ Build the warning-producing code from this post in your own project, fix the five warnings one by one, and watch dotnet build go quiet until only NETSDK1138 is left.
★★☆ In the CalculateTotal example, put a conditional breakpoint with i == 1 on the for line, run it with the debugger, and when it stops type unitPrices.Length and counts.Length into the Watch window. Then add a check at the start of the function that compares the two lengths and throws an ArgumentException if they differ.
★★★ In the warning-producing project, switch on <TreatWarningsAsErrors>true</TreatWarningsAsErrors> and note which warnings became errors and which one stayed a warning. Explain why NETSDK1138 is not affected, starting from the prefixes. Then add <WarningsNotAsErrors>CS0168</WarningsNotAsErrors> and see CS0168 turn back into a warning.
In the last post of the series we bring together what we have learned: we write a small Bookstore application that runs in the console, and list, add, search and save books to a file. Enums, dates, files, exceptions and the debugging habits from this post will all work together there.