The basics of interactive debugging in Visual Studio, VS Code and Rider: setting breakpoints, stepping commands, Run to Cursor and Set Next Statement, and why Debug builds and symbols matter.
A breakpoint tells the debugger to pause the process when execution reaches a line, so I can look at variables and the call stack. I set one with F9 or a click in the gutter and start with F5. Then I step: Step Over (F10) runs the current line, Step Into (F11) goes into the method being called, and Step Out (Shift+F11) runs to the end of the current method and returns to the caller. Run to Cursor and Set Next Statement move faster or re-run a line. It works best on a Debug build with symbols, because optimised code can skip or reorder lines.
"A breakpoint tells the debugger to pause the process when execution reaches a line, so I can inspect variables and the call stack. I set one with F9 or a click in the gutter and start debugging with F5; when it's hit, the runtime pauses all managed threads and hands control to the debugger.
Then I step. Step Over, F10, runs the current line including any calls; Step Into, F11, goes into the method being called; and Step Out, Shift+F11, finishes the current method and returns to the caller. Run to Cursor and Set Next Statement move faster or re-run a line, though Set Next Statement skips code, so state can end up inconsistent. If a breakpoint is hit too often, I make it conditional rather than pressing F5 again and again.
It works best on a Debug build with matching PDB symbols, because the PDB maps IL back to source lines. The mistake I see is debugging a Release build without realising it, where lines are merged or skipped and locals are optimised away. A hollow breakpoint means it won't be hit, usually because symbols aren't loaded or the running binary isn't the one I built, so I check the Modules window. And because pausing stops every thread, HTTP and database timeouts fire, which is why I never pause a production process and never leave
Debugger.Break()in shipped code.I use the debugger to test a hypothesis, not to wander, and I turn what I learn at the breakpoint into a failing unit test before I fix the bug."
When you start a project under the debugger (F5), the debugger attaches to the process. A breakpoint marks a source line; when a thread reaches the code for that line, the runtime stops all threads and hands control to the debugger. You can then inspect locals, the call stack and other threads, change values, and continue.
| Command | Visual Studio / VS Code key | What it does |
|---|---|---|
| Continue | F5 | Run until the next breakpoint |
| Step Over | F10 | Execute the current line, including any calls, and stop on the next line |
| Step Into | F11 | If the line calls a method, stop at its first line |
| Step Out | Shift+F11 | Run the rest of the current method and stop in the caller |
| Run to Cursor | Ctrl+F10 (Visual Studio) | Run to the line the cursor is on |
| Set Next Statement | Ctrl+Shift+F10 (Visual Studio) | Move the instruction pointer to another line in the same method, without running the code in between |
Rider has the same commands; the shortcuts depend on the keymap you choose (the Visual Studio keymap uses the keys above).
Main.order.Number == "A-102") instead of pressing F5 repeatedly.The compiler writes a PDB (program database) next to each DLL. It maps IL offsets to source file, line and column (sequence points) and records local variable names, and it carries a unique id that must match the DLL. Without a matching PDB the debugger cannot bind a source breakpoint or show the source line.
In a Debug build the compiler emits IL that matches your source closely and the JIT does not optimise it. In an optimised Release build, lines can be merged, reordered or inlined, so breakpoints may be skipped and some locals show as optimised away. Debug locally for logic bugs; switch to Release only when a bug appears only there.
A hollow circle ("The breakpoint will not currently be hit") means the debugger could not bind it. Hover over it for the reason, then check:
A GST total on an invoice service was consistently short by the first line item. Setting a breakpoint in the total method and stepping over the loop showed the index started at 1 instead of 0. Two minutes with the debugger replaced an hour of adding log lines.
The interviewer checks basic fluency with the debugger and whether you understand why it behaves differently in some builds.
Green flags
Red flags
Console.WriteLine. F5 (Start Debugging)
|
v
IDE builds the project (Debug configuration by default)
| compiler writes MyApp.dll + MyApp.pdb (portable PDB)
v
debugger launches the process (or attaches to an existing one)
| and connects through the runtime's debugging interface
v
runtime reports every module (DLL) it loads
| debugger looks for a matching PDB for each one
v
pending breakpoints in that module are BOUND
(solid red circle) or stay UNBOUND (hollow circle)
A breakpoint you set before the code has loaded is "pending". It binds as soon as the runtime loads the module and the debugger finds matching symbols.
source line 42
| PDB sequence point
v
IL offset 0x1A in method OrderMath.Total
| JIT compiles the method to native code
v
native address 0x7FF6...
| debugger asks the runtime to patch it
v
breakpoint instruction (int 3 on x64, brk on Arm64)
| the CPU traps when it reaches it
v
runtime suspends ALL managed threads --> notifies debugger
--> IDE highlights line 42 and shows locals
The PDB is the link between your source and the compiled code. It contains:
| PDB content | Used for |
|---|---|
| Sequence points (IL offset to file, line, column) | Binding breakpoints and highlighting the current line |
| Local variable names and scopes | The Locals and Watch windows |
| A unique id that must match the DLL | Refusing a stale PDB from another build |
| Optional Source Link and embedded source | Finding source for code you didn't build |
| Command | Visual Studio / VS Code | What the debugger actually does |
|---|---|---|
| Continue | F5 | Resumes all threads until a breakpoint or exception |
| Step Over | F10 | Runs the current line, including every call on it, and stops at the next sequence point in the same method |
| Step Into | F11 | If the line calls a method that has symbols and is "your code", stops at its first line. Otherwise behaves like Step Over |
| Step Out | Shift+F11 | Sets a temporary breakpoint at the return address in the caller and continues |
| Run to Cursor | Ctrl+F10 (VS) | A one-off temporary breakpoint at the cursor line |
| Set Next Statement | Ctrl+Shift+F10 (VS), or drag the yellow arrow | Moves the instruction pointer without running the code in between |
| Step Into Specific | Right-click (VS) | Chooses one call when a line has several, such as Save(Map(order)) |
Rider has the same commands. With its Visual Studio keymap the keys match the table.
Stepping is implemented with temporary breakpoints and single-stepping. That is why Step Over a call that waits on I/O looks like a hang: the call really is still running, and the debugger is waiting for it to return.
An await splits a method into a state machine. The code after the await may run on a different thread.
async Task<decimal> TotalAsync()
{
var rates = await _rates.GetAsync(); <-- Step Over here
return Sum(rates); <-- debugger stops here when
} the await completes,
maybe on another thread
Modern debuggers are async-aware: Step Over on an await line waits for that specific continuation and stops on the next line of the same logical method, even if another thread runs it. Use the Tasks and Parallel Stacks windows in Visual Studio to see where async work is waiting.
| Debug | Release | |
|---|---|---|
| Compiler optimisation | Off; extra nop instructions keep one sequence point per line |
On; lines can be merged and dead code removed |
| JIT optimisation | Off when a debugger launches the process (default) | On; methods can be inlined and locals kept in registers |
| Breakpoints | Bind to almost every line | Some lines can't be bound or are skipped |
| Locals | All visible | Some show "optimised away" |
| Stepping | Follows the source closely | Can jump around |
Debug locally for logic bugs. Switch to Release only when a bug only happens there, and expect a rougher stepping experience.
Hover over the hollow circle for the reason, then work through this table:
| Cause | How to check | Fix |
|---|---|---|
| Module not loaded yet | Debug > Windows > Modules | Normal for code that hasn't run. It binds when the module loads |
| Symbols not loaded | Modules window, Symbol Status column | Rebuild, or right-click > Load Symbols and point at the matching PDB |
| Stale binary | Module path in the Modules window | Clean and rebuild. Check you aren't running an old copy or a NuGet package instead of your project reference |
| Wrong process | Debug > Attach to Process | Attach to the process that hosts the app, such as the IIS worker or the container process |
| Release or optimised build | Configuration dropdown | Use Debug, or accept that some lines can't bind |
| Just My Code hides it | Tools > Options > Debugging | Turn off Just My Code to debug library code (see the follow-up question) |
| Tool | How |
|---|---|
| VS Code with C# Dev Kit | F5 on a project, or a launch.json entry of type coreclr for a specific DLL |
| Rider | Debug a run configuration, or attach to a local or remote process |
| Attach to a running app | Visual Studio Ctrl+Alt+P, Rider Ctrl+Alt+F5, VS Code "Attach" configuration |
| Containers | Visual Studio and VS Code container tools install the debugger in the container and attach |
Debugger.Break() and Debugger.Launch() left in code will try to start a debugger on a server or stop a test run. Remove them before committing, or guard them with #if DEBUG.The stepping model is stable. Debugger improvements arrive through Visual Studio, Rider and the C# extension releases rather than the runtime, so check their release notes alongside .NET 11.
dotnet new console -n StepDemo and paste the code example.decimal total = OrderMath.Total(orders); and press F5.Total, then F10 through the loop, and watch i in the Locals window.StepDemo.dll and its symbol status.A small program with a bug to find by stepping:
var orders = new List<Order>
{
new("A-100", 120.50m),
new("A-101", 89.95m),
new("A-102", 230.00m),
};
decimal total = OrderMath.Total(orders);
Console.WriteLine($"Total: {total:0.00}");
public sealed record Order(string Number, decimal Amount);
public static class OrderMath
{
public static decimal Total(IReadOnlyList<Order> orders)
{
decimal total = 0;
for (var i = 1; i < orders.Count; i++) // bug: the first order is skipped
{
total += orders[i].Amount;
}
return total;
}
}
Run it under the debugger from the CLI-built output in VS Code with a minimal launch.json entry:
{
"name": "StepDemo",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/bin/Debug/net10.0/StepDemo.dll",
"cwd": "${workspaceFolder}"
}
Mistake: debugging a Release build without realising it. Optimised code merges and reorders lines, so breakpoints are skipped and variables show as "optimised away". Instead: debug logic bugs in the Debug configuration. Switch to Release only for a bug that only happens there.
Mistake: stepping into everything. Pressing F11 on every line walks you through property getters, LINQ and framework code you don't care about, and you lose track of the bug. Instead: Step Over by default, Step Into only the call you suspect (Step Into Specific when a line has several calls), and Step Out as soon as you are in the wrong method.
Mistake: pressing F5 again and again until the bad iteration.
It is slow and easy to overshoot the case you need.
Instead: use a conditional breakpoint, for example order.Number == "A-102", or a hit count.
Mistake: trusting state after Set Next Statement. Moving the instruction pointer skips code without running it, so variables, open transactions and side effects may be inconsistent. Instead: use it to re-run a pure calculation after changing a value. For anything with side effects, restart the scenario.
Mistake: ignoring a hollow breakpoint. It means the code you are looking at isn't the code that is running, so any conclusion you draw is wrong. Instead: hover for the reason, then check the Modules window for the module path and symbol status. Clean and rebuild if the binary is stale.
Mistake: chasing timeouts caused by the debugger. While paused, every thread stops, so HTTP and database timeouts expire, and the error you see after continuing is not the original bug. Instead: keep pauses short around I/O, raise timeouts for local debugging only, or use tracepoints and logs for timing-sensitive code.
Mistake: expecting async code to stay on one thread.
After an await the method may resume on another thread, so the Threads window jumps and it looks like stepping is broken.
Instead: use Step Over on the await line and let the debugger follow the continuation. Use the Tasks and Parallel Stacks windows to see waiting work.
Mistake: debugging a shared or production process interactively.
A breakpoint pauses every request for every user.
Instead: use logs, distributed traces, dotnet-counters and memory dumps in production, and reproduce locally to step through.
Mistake: committing Debugger.Break() or Debugger.Launch().
On a server or CI agent it can stop the process or pop up a debugger prompt.
Instead: use normal breakpoints. If you need one in code temporarily, guard it with #if DEBUG and remove it before the pull request.
Seniors use the debugger to test hypotheses quickly, not to wander. Before pressing F5 they decide what they expect to see and where, place one or two targeted breakpoints, and use Step Out and Run to Cursor aggressively.
In multi-threaded and async code they adapt: a breakpoint stops every thread, so they check the Threads window to see which thread hit it, add a filter or condition so only the interesting request stops, and remember that after an await the method may resume on a different thread. For timing-sensitive bugs they switch to tracepoints or structured logs, because pausing changes the interleaving and can make the bug disappear.
They also know when the debugger is the wrong tool: production issues need logs, traces and dumps instead. A good habit is to turn what you learned at a breakpoint into a failing unit test before fixing, so the bug cannot come back.
A plain breakpoint stops every time; the others let me stop only when it matters. A conditional breakpoint stops when an expression is true, such as order.Number == "A-102", or when a value changes. A hit count stops on, say, the 500th iteration. A filter limits it to one thread or process. A tracepoint, called a logpoint in VS Code, prints a message with values and keeps running, like a log line I don't have to commit. Function breakpoints stop on a method by name, and data breakpoints stop when a field changes. The cost is speed: conditions are evaluated by the debugger on every hit, so in a hot loop they can make the app crawl.
A PDB file maps compiled IL back to source lines and variable names; without it the debugger can't show source or bind breakpoints. Just My Code tells the debugger to step only through my code and treat everything else as external. To step into ASP.NET Core or a NuGet package, I turn Just My Code off, enable the Microsoft and NuGet.org symbol servers so the debugger can download PDBs, and enable Source Link. Source Link stores the repository URL and commit in the PDB, so the debugger fetches the exact source file that was compiled. Library authors make this work by publishing .snupkg symbol packages from deterministic CI builds.
Last updated 1 Nov 2026.