C# Debugging

Debugging is the process of finding out why a C# program behaves differently from what you expected. It matters because most real bugs are not syntax errors: the program compiles, runs, and still produces the wrong value, takes the wrong branch, or fails only for certain inputs. A debugger lets you pause execution, inspect memory through variables, walk through code line by line, and understand the actual path the CLR is taking.

Overview: How C# Debugging Works

When you build a C# project in Debug configuration, the compiler emits extra information that maps compiled instructions back to source lines, local variables, and method names. This information is stored in program database files, commonly called PDB files. The Common Language Runtime executes the same kind of IL, or intermediate language, but the debugger can use symbols to show you source code, variable values, the current call stack, and exception locations.

A breakpoint tells the debugger to pause when execution reaches a particular source line. While paused, the process is still alive, but the executing thread is stopped. You can inspect locals, hover over variables, evaluate expressions in a watch window, and decide whether to step to the next statement or continue running. This is much more precise than guessing from output because you see the program state at the exact moment the decision is made.

Stepping is the main way to move through paused code. Step Over runs the current line and stops on the next line in the same method. Step Into enters a method call so you can debug inside it. Step Out finishes the current method and returns to the caller. These commands help you move at the right level of detail: inspect your own code closely, but step over library calls unless you have a reason to enter them.

The call stack shows how the program arrived at the current line. If Main called ProcessOrder, which called CalculateTotal, which threw an exception, the stack shows that chain. This is essential when a method is correct for some callers but not others. Bugs often depend on the input a caller supplied, not just the line where the failure finally appeared.

Debugging is not only an IDE feature. C# also has diagnostic APIs such as System.Diagnostics.Debug.Assert, Debug.WriteLine, and logging frameworks used in larger applications. Assertions document assumptions that should be true during development. Logging records what happened when you cannot attach a debugger, such as in production or on another machine. The best debugging habit is to use the debugger to understand the bug, then improve the code or tests so the same bug is harder to reintroduce.

Syntax

using System;
using System.Diagnostics;

class Program
{
    static void Main()
    {
        int quantity = 3;
        decimal unitPrice = 19.99m;

        Debug.Assert(quantity > 0, "Quantity should be positive.");

        decimal total = quantity * unitPrice;
        Console.WriteLine($"Total: {total:F2}");
    }
}
Tool or statement Purpose
Breakpoint Pauses the running program at a selected line so you can inspect state.
Step Over Runs the current line without entering method calls on that line.
Step Into Enters a method call and pauses inside it.
Step Out Runs the rest of the current method and pauses when control returns to the caller.
Watch Evaluates a variable or expression while the debugger is paused.
Call Stack Shows the active chain of method calls that led to the current line.
Debug.Assert Checks a development-time assumption in Debug builds.

Examples

Example 1: Inspect Variables To Find A Logic Bug

using System;

class Program
{
    static void Main()
    {
        int quantity = 4;
        decimal unitPrice = 25.00m;
        decimal discountRate = 0.10m;

        decimal subtotal = quantity * unitPrice;
        decimal discount = subtotal * discountRate;
        decimal total = subtotal - discount;

        Console.WriteLine($"Subtotal: {subtotal:F2}");
        Console.WriteLine($"Discount: {discount:F2}");
        Console.WriteLine($"Total: {total:F2}");
    }
}

Output:

Subtotal: 100.00
Discount: 10.00
Total: 90.00

Set a breakpoint on the line that calculates total. When the debugger pauses, inspect quantity, unitPrice, subtotal, and discount. If the total is wrong in a real program, this style of debugging tells you which intermediate value first became wrong. The fix should target that line of logic, not the final output statement.

Example 2: Step Into Methods And Read The Call Stack

using System;

class Program
{
    static void Main()
    {
        decimal total = CalculateInvoiceTotal(80m, 12m);
        Console.WriteLine($"Invoice total: {total:F2}");
    }

    static decimal CalculateInvoiceTotal(decimal itemsTotal, decimal shipping)
    {
        decimal taxableAmount = AddShipping(itemsTotal, shipping);
        return AddTax(taxableAmount, 0.075m);
    }

    static decimal AddShipping(decimal amount, decimal shipping)
    {
        return amount + shipping;
    }

    static decimal AddTax(decimal amount, decimal taxRate)
    {
        return amount + (amount * taxRate);
    }
}

Output:

Invoice total: 98.90

Put a breakpoint on the call to CalculateInvoiceTotal. Use Step Into to enter it, then step into AddShipping and AddTax. While paused inside AddTax, the call stack shows AddTax, then CalculateInvoiceTotal, then Main. That stack answers the important debugging question: not only what line is executing, but who asked for it.

Example 3: Debug An Exception At The Throw Site

using System;

class Program
{
    static void Main()
    {
        string[] names = { "Ava", "", "Mina" };

        foreach (string name in names)
        {
            try
            {
                PrintBadge(name);
            }
            catch (ArgumentException ex)
            {
                Console.WriteLine($"Skipped: {ex.Message}");
            }
        }
    }

    static void PrintBadge(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            throw new ArgumentException("Name is required.");
        }

        Console.WriteLine($"Badge: {name.ToUpperInvariant()}");
    }
}

Output:

Badge: AVA
Skipped: Name is required.
Badge: MINA

The program catches the exception, so it does not crash. During debugging, however, you can enable breaking when ArgumentException is thrown. The debugger then stops on the throw line inside PrintBadge, before the catch block handles it. This is useful because the throw site contains the bad input and the violated assumption.

Example 4: Use Assertions For Development Assumptions

using System;
using System.Diagnostics;

class Program
{
    static void Main()
    {
        int score = 82;
        string grade = GetLetterGrade(score);

        Debug.Assert(score >= 0 && score <= 100, "Score should be in the grade range.");
        Console.WriteLine($"Grade: {grade}");
    }

    static string GetLetterGrade(int score)
    {
        if (score >= 90) return "A";
        if (score >= 80) return "B";
        if (score >= 70) return "C";
        if (score >= 60) return "D";
        return "F";
    }
}

Output:

Grade: B

Debug.Assert is a development check. If the condition is false in a Debug build with a debugger attached, it can interrupt execution and show the message. It is not a replacement for real validation at public boundaries, because assertions may be removed from Release builds. Use assertions to catch impossible internal states while you are building and testing.

How Debugging Works Step By Step

  1. You build with debugging symbols so source lines, method names, and variables can be mapped to compiled code.
  2. You place a breakpoint on a line that is close to the suspicious behavior.
  3. The CLR runs the program normally until execution reaches that breakpoint or an exception rule asks the debugger to pause.
  4. When paused, you inspect locals, watches, the current statement, and the call stack.
  5. You step through the smallest useful region of code and compare actual values with expected values.
  6. When you find the first incorrect state, you change the underlying logic, not merely the printed output.
  7. You rerun the program and, when possible, add a test or assertion that would have exposed the bug earlier.

A good debugging session moves from symptom to cause. The symptom might be Total: 100.00 when you expected Total: 90.00. The cause might be that a discount was calculated but never subtracted. The debugger is valuable because it lets you observe the transition between correct and incorrect state.

Common Mistakes

Guessing Instead Of Stopping Near The Bug

Console.WriteLine("Made it here");
Console.WriteLine("Still here");
Console.WriteLine("Value changed somewhere");

Temporary output can help, but a long trail of vague messages often creates noise. A breakpoint with a watch expression such as subtotal - discount tells you more with less code, and it does not need to be cleaned out later.

Only Looking At The Line That Crashed

using System;

class Program
{
    static void Main()
    {
        string? customerName = null;

        if (customerName == null)
        {
            Console.WriteLine("Missing customer name.");
            return;
        }

        Console.WriteLine(customerName.ToUpperInvariant());
    }
}

Output:

Missing customer name.

A crash line is often where bad data was used, not where it was created. In this corrected version, the code checks customerName before calling a method on it. In the debugger, you would work backward through the call stack and variable assignments to learn why the value was null in the first place.

Changing Code Without Reproducing The Bug

using System;

class Program
{
    static void Main()
    {
        int[] numbers = { 2, 4, 6 };
        int sum = 0;

        foreach (int number in numbers)
        {
            sum += number;
        }

        Console.WriteLine(sum);
    }
}

Output:

12

Before changing code, create the smallest input that reproduces the problem. Here the expected sum is obvious, so it is a good debugging case. If a later edit breaks the loop, this tiny scenario immediately reveals it.

Best Practices

  • Start with a reproducible case. A debugger is most effective when you can make the bug happen on demand.
  • Set breakpoints near the first suspicious decision or value, not randomly throughout the program.
  • Use Step Into for your own methods and Step Over for code you trust.
  • Watch expressions that represent business rules, such as subtotal - discount, not only raw variables.
  • Read the call stack whenever a method receives surprising input.
  • Enable exception breakpoints when caught exceptions are hiding the original failure location.
  • Use assertions for internal assumptions, but use normal validation and exceptions for user input, public methods, and external data.
  • Remove temporary debug prints before committing code unless they are replaced by intentional logging.
  • After fixing a bug, add a focused test or example input that protects the behavior.

Practice Exercises

  1. Create a program that calculates the average of 10, 20, and 30. Place a breakpoint before the division and inspect the sum and count.
  2. Write three methods where Main calls A, A calls B, and B throws an InvalidOperationException. Catch it in Main, then debug the call stack.
  3. Add Debug.Assert to a method that accepts a percentage. Use the debugger to see what happens when the value is outside 0 through 100.

Summary

  • Debugging means observing a running program so you can find the first point where reality differs from expectation.
  • Debug builds and PDB symbols let the debugger map compiled code back to source lines and variables.
  • Breakpoints pause execution; stepping commands control how far execution moves next.
  • Watches, locals, and the call stack reveal the data and caller path behind a bug.
  • Exception breakpoints help you stop where an exception is thrown, even when it is later caught.
  • Debug.Assert is useful for development-time assumptions, but it is not a substitute for real runtime validation.
  • The best fix is confirmed by rerunning the reproducible case and adding a test or assertion where appropriate.