Debugging is the part of programming that takes the most time and gets the least training. A common approach is trial and error: sprinkle Console.WriteLine calls until something prints, then move on. C# and the .NET tooling can do much better than that. This chapter walks through the debugging tools built into Visual Studio, VS Code, and Rider, plus the production-grade tools to use when no debugger is attached.
A senior C# developer spends roughly as much time reading and debugging code as writing it. The gap between someone who fixes a tricky bug in fifteen minutes and someone who spends a day on the same bug is rarely about cleverness. It's about technique: knowing where to set a breakpoint, what state to inspect, which window shows the right view, and when to stop using the debugger and switch to a log or a memory dump instead.
Three concrete payoffs:
dotnet-dump, dotnet-trace, and dotnet-counters exist for this situation. Knowing them turns "we'll need to repro this" into "I'll have an answer in twenty minutes."The rest of the chapter is organized roughly from the cheapest tools (a single breakpoint) to the heaviest (analyzing a memory dump from a production process). Use whichever fits the bug at hand.
Tools matter, but mindset matters more. Bugs are easier to fix when you treat debugging as a small scientific experiment instead of a random walk through the code.
The loop looks like this:
Each cycle proves or rules out one specific belief about the program. A common failure mode is changing several things at once: "I'll fix the null check, refactor that loop, and try a different LINQ query." If the bug goes away, it's unclear which change fixed it, and one of the other "fixes" may have introduced a new bug that won't surface until next week.
The discipline:
Consider an order that shows a total of $80 instead of the expected $72:
Three hypotheses:
Each is testable with one breakpoint and one watch. Pick one, test it, move on. Don't refactor until you know which hypothesis is right.
The three main IDEs for C# (Visual Studio on Windows, Rider cross-platform, and VS Code with the C# Dev Kit extension) share the same debugger keys, with one or two differences. Learning the keys once allows switching tools without relearning the workflow.
| Action | Key | What it does |
|---|---|---|
| Start debugging | F5 | Builds, launches the app, attaches the debugger |
| Step over | F10 | Runs the current line, stops on the next |
| Step into | F11 | Steps into the method call on the current line |
| Step out | Shift+F11 | Runs until the current method returns, then stops |
| Run to cursor | Ctrl+F10 | Runs until execution reaches the cursor line |
| Continue | F5 | Resumes execution until the next breakpoint |
| Stop debugging | Shift+F5 | Terminates the debuggee |
| Toggle breakpoint | F9 | Adds or removes a breakpoint on the current line |
| Restart | Ctrl+Shift+F5 | Stops and restarts the debug session |
A small program makes these concrete. Save it, set a breakpoint on the line that calls CalculateTotal, and press F5:
When the debugger stops on the call line:
CalculateTotal as one unit and stops on Console.WriteLine.CalculateTotal and stops on decimal subtotal = ....Console.WriteLine.A summary of the choices:
The key skill is choosing the right step. Stepping into every method, including framework methods like Console.WriteLine, drowns the session in BCL internals. Step over until reaching the suspicious line, then step into. After stepping into something unintended, Shift+F11 gets back out.
Each step keystroke is a round-trip through the debugger. On a hot loop with 10,000 iterations, stepping is unworkable. Use a conditional breakpoint to stop only on the iteration that matters.
A breakpoint tells the debugger "stop here." That basic form, the line breakpoint, is the one everyone learns first. The other three forms exist because line breakpoints get noisy fast.
Line breakpoints. Click in the gutter next to a line, or press F9 with the cursor on the line. Execution stops on that line every time.
Conditional breakpoints. Right-click the breakpoint glyph and add a condition. The debugger evaluates the condition each time execution reaches the line and stops only when the condition is true. The condition can be any C# expression that's valid in the current scope.
Consider a list of orders where the bug appears only on the order for customer Charlie:
Put a breakpoint on the first line inside ProcessOrder, then add the condition order.Customer == "Charlie". The debugger now stops only on Charlie's iteration. The other three run through at full speed.
Hit count breakpoints. Useful when a bug appears on the 47th iteration of a loop. Right-click the breakpoint and set "Hit count = 47" (or "Hit count >= 47", or "Hit count is a multiple of 10"). The debugger keeps a counter for the breakpoint and stops when the counter matches.
Function breakpoints. Sometimes the target line isn't visible (it's in a method that isn't open, or it's in compiled framework code). A function breakpoint stops on any call to a named method. In Visual Studio, use Debug > New Breakpoint > Function Breakpoint, then type the method name like MyApp.Orders.Calculator.CalculateTotal. Useful for catching every call site without scrolling through the file.
A table of the four kinds:
| Type | When to use |
|---|---|
| Line | The simple case: stop here every time |
| Conditional | Stop only when an expression evaluates to true |
| Hit count | Stop on the Nth time the line runs |
| Function | Stop on any call to a method by name, no matter where it lives |
A tracepoint is a breakpoint that doesn't stop the program. Instead, it prints a message to the debugger output window and continues. The advantage is that tracepoints can be added, changed, and removed without recompiling or modifying source.
In Visual Studio, right-click a breakpoint and pick "Actions." Check "Log a message to Output Window" and type a message. Inside curly braces, any expression in scope can be referenced. Uncheck "Continue execution" only if the tracepoint should also break.
Example. Add a tracepoint inside the loop above with the message:
When the program runs, the Output window shows:
The $HITCOUNT token is a built-in. Others include $CALLSTACK, $FUNCTION, $PID, $TID, and $TIME. The curly-brace form {expression} evaluates any C# expression visible at the tracepoint location.
When tracepoints beat both Console.WriteLine and breakpoints:
Tracepoints don't stop the program, but evaluating expressions and writing to the output window does cost time. They're far cheaper than breakpoints, but on a tight loop (millions of iterations), they'll still slow things down. Remove them or convert to actual logging when the investigation ends.
When the debugger is paused, the IDE shows several windows alongside the source. Each answers a different question.
Locals. Every local variable in the current scope. The IDE picks the list. Use this for a quick overview without typing anything.
Autos. The variables the debugger thinks are interesting on the current line. In Visual Studio, that means variables read or written by the current statement and the previous one. Useful for spotting recent changes without scanning the full Locals list.
Watch. Variables and expressions added explicitly. Type any C# expression: order.Total, orders.Count, orders[3].Total > 100. The debugger evaluates the expression each time execution stops. Watch is the main tool for non-trivial debugging.
Call Stack. The chain of method calls leading to the current line. Each frame shows the method, file, and line. Click a frame to switch the Locals/Watch view to that frame's variables. Use this when an exception bubbles up from somewhere deep and the caller chain matters.
Immediate. A REPL pinned to the debug session. Type any C# expression and the debugger evaluates it in the current scope. Calling methods, mutating state, and trying out hypotheticals all work. Type orders.Sum(o => o.Total) to see the running total, or ProcessOrder(orders[2]) to invoke a method with a chosen argument.
A small worked example. The program is paused inside CalculateTotal:
The windows show:
| Window | Contents |
|---|---|
| Locals | price = 29.99, quantity = 3, discountRate = 0.10, subtotal = 89.97, discount = 8.997 |
| Autos | subtotal = 89.97, discount = 8.997 |
| Watch (you added) | subtotal - discount shows 80.973 |
| Call Stack | Program.CalculateTotal(29.99, 3, 0.10) <- Program.<Main>$() |
| Immediate | > subtotal * 2 prints 179.94 |
The order in which to use them:
A practical technique: paused in the middle of a bug, type the suspect expression into Immediate. If it returns the wrong answer, the bug is localized to that expression. If it returns the right answer, the bug is elsewhere, often in how the result is used.
DataTips are the pop-ups that appear when hovering over a variable in the source while paused. They show the variable's current value, and for complex objects, an expandable tree of fields and properties.
DataTips have two underused features:
quantity from 3 to 999 and continue execution. The program runs with the new value. Faster than restarting the debugger to test an edge case.A small example. While paused on the return line:
Hover over discount. The DataTip shows 8.997. Click the pin, drag it next to subtotal. Both are now visible side by side during stepping. Click into subtotal and change it to 1000m. Press F5. The return value, and the printed total, reflect the change.
Editing values mid-session is useful for "what if?" exploration. To check behavior when stock is zero, pause, edit the stock variable to 0, continue. To test the discount path on a customer other than Charlie, edit the customer name in place. No new test data and no restart required.
Edited values don't persist after the session ends. They're a quick-test tool, not a way to fix data. If a fix requires editing values every debug session, the real fix belongs in code or test data.
By default, the debugger shows objects as Namespace.TypeName with a tree of fields and properties. For simple types that's fine. For domain types that wrap a lot of state, the default view is noisy. Three attributes in System.Diagnostics control what the debugger shows.
`[DebuggerDisplay]`. Defines the one-line summary the debugger shows for the type. Without it, an Order shows as Order. With it, the summary can be any expression.
In the debugger Locals/Watch view, each Order now displays as Alice: $89.97 (Placed) instead of the default Order. The ,nq modifier means "no quotes," which avoids the debugger wrapping string values in literal quotes.
`[DebuggerTypeProxy]`. Defines a separate type that the debugger uses to render the object. Useful for collections or types with hidden state worth surfacing. The proxy type can compute and expose properties the original type doesn't have.
In the debugger, expanding a Cart now shows Items, Total, and ItemCount (the proxy's view), not the internal _items field directly. The RootHidden browsable state inlines the items at the top level, removing the need to drill in.
`[DebuggerStepThrough]` and `[DebuggerNonUserCode]`. Both tell the debugger to skip a method when stepping. The difference is subtle:
[DebuggerStepThrough] skips over the method on F11 (step into) but the debugger still stops on breakpoints inside it.[DebuggerNonUserCode] is stronger: the debugger treats the method as framework code, won't step into it, and (with "Just My Code" enabled) won't even break inside it on exceptions.Use these on small passthrough methods, generated code, or DI container internals that clutter the call stack without adding value:
F11 over GetById skips the one-line lookup, keeping focus on the calling code.
A summary table:
| Attribute | Purpose |
|---|---|
[DebuggerDisplay] | Customize the one-line summary for a type |
[DebuggerTypeProxy] | Customize the expandable view for a type |
[DebuggerBrowsable] | Show, hide, or inline a member in the expansion |
[DebuggerStepThrough] | Skip over a method on step-into |
[DebuggerNonUserCode] | Treat a method as framework code, skip entirely |
These attributes ship in production assemblies but cost nothing at runtime in a non-debug session. The debugger only consults them when it's attached.
By default, the debugger stops only on unhandled exceptions: ones that bubble all the way up and crash the program. That's fine for obvious crashes, but it misses two important cases:
The Exception Settings window (Debug > Windows > Exception Settings in Visual Studio, similar paths in Rider and VS Code) tells the debugger to stop on thrown exceptions too. Check the box next to "Common Language Runtime Exceptions" and every CLR exception will pause the debugger at the throw site, before any catch block runs.
When to flip it on:
NullReferenceException that gets caught and turned into "operation failed" with no detail. Breaking on thrown shows where the null came from.OperationCanceledException in async code. The default doesn't stop, because the exception is "expected."The downside: with "break on thrown" enabled, the debugger pauses constantly during normal operation, because frameworks (especially ASP.NET Core) routinely throw and catch exceptions internally. Narrow the setting to specific exception types instead of the whole category. In the Exception Settings window, check just System.NullReferenceException and leave the rest at "unhandled only."
A small program to demonstrate:
With "break on thrown" off (the default), the debugger doesn't pause. The exception is caught, the program continues. With "break on thrown" on for NullReferenceException, the debugger pauses inside LookupOrder on the line that dereferences customer. The actual cause shows, not the discarded symptom.
"Break on thrown" for broad exception categories will pause many times per second in some apps. Use it surgically: enable for one specific exception type, debug, disable.
Edit and Continue (EnC) allows code changes while paused at a breakpoint, with execution continuing afterward, without restarting. Hot Reload is the modern, broader version: it applies edits to a running app even when not paused. Both save significant time during exploratory debugging.
What you can change without restart (in .NET 6+):
What still requires a restart:
Hot Reload is enabled by default in Visual Studio 2022 and later. In VS Code with the C# Dev Kit, it's available via the run command. To use it: run the app under the debugger (or with dotnet watch run for command-line scenarios), edit code, save, and the running process picks up the change.
A practical example with a wrong discount calculation:
With Hot Reload, there's no need to stop the program. Change the body to:
Save. The next iteration of the loop uses the new body. The output changes from $90.07 to $80.97 on the next print, no restart needed.
Where Hot Reload is especially useful:
Hot Reload tracks state of the changed methods. Some changes (especially to closures or initialization logic) can leave the app in a partially-old, partially-new state. If behavior becomes inconsistent after several Hot Reloads, restart and re-test.
Async code is harder to debug because the call stack in a paused debugger doesn't always reflect the logical chain of awaits. The physical stack ends at the I/O completion thread; the chain of methods that awaited each other is reconstructed from continuations.
Two windows handle this:
Task in the process: ID, status, location, parent. Filter by status (Running, Awaiting, Completed) to find the relevant ones. Double-click to switch the debugger context to that task.A small async program:
Set a breakpoint inside FetchOrderAsync on the return line. Run the program. The debugger pauses with one of the three tasks completing. The Tasks window shows all three tasks, two of them Awaiting, one Running. Parallel Stacks shows the three logical paths from ProcessOrders through FetchOrderAsync.
A conceptual view of an async stack:
Three things to watch for in async debugging:
Task to track. Avoid async void outside of event handlers.ConfigureAwait(false) on an await changes which thread resumes after. If the symptom is "the UI thread isn't running this," check the Threads window to see which thread the continuation ended up on.A specific case to watch for: a Task that throws but is never awaited. The exception is discarded unless the task is observed. With "break on thrown" enabled for TaskCanceledException and similar types, these surface. Otherwise, set up TaskScheduler.UnobservedTaskException as a fallback.
Tests are programs too, and they get debugged the same way as any other code. The IDE integration adds two conveniences: running and debugging a specific test from the Test Explorer.
Run vs Debug. "Run Test" executes without the debugger attached. Fast, no breakpoints honored. "Debug Test" attaches the debugger, so every breakpoint (in test code or in the production code the test calls) hits.
A small xUnit test for the discount calculation:
To debug, click "Debug Test" in Test Explorer (or right-click the test name and pick "Debug"). The test runner attaches the debugger before executing your test method. Breakpoints inside the test or inside the code-under-test fire normally. F10 and F11 work as usual.
Attaching to a running process. Sometimes a test runs as part of a build pipeline and needs local debugging. The general pattern works for any process: Debug > Attach to Process in Visual Studio, then pick the process by name. Useful for:
For a locally started process, attach is straightforward. For a process on another machine, remote debugging is required, which involves running the Visual Studio remote debugger tool on the target machine and authenticating to it.
A short table of test-debug entry points:
| Scenario | Action |
|---|---|
| Single test in IDE | Right-click in Test Explorer, "Debug" |
| Whole class | Right-click the class name, "Debug Tests" |
| Test running in CI you want to reproduce | Run locally with the same fixture, attach |
| Test that fails only with a specific seed | Set up a conditional breakpoint on the seed value |
Once code is running in production, the IDE debugger is rarely usable. Either customer traffic can't be paused, or the production environment doesn't allow remote debugger ports, or the bug only appears under load that can't be reproduced locally. The .NET CLI ships a set of diagnostic tools for this situation. Install them once with the global tool commands:
Each tool answers a different question.
`dotnet-counters` shows live performance counters: CPU, working set, GC heap size, exception rate, gen 0/1/2 collection counts, ThreadPool queue length, and many more. Attach by process ID:
The terminal updates roughly every second. Useful for catching memory leaks (working set growing without bound), GC pressure (gen 2 collections climbing), thread pool starvation (queue length growing), and high allocation rates.
A sample of the output:
`dotnet-trace` records an event-level trace: which methods were called, how long they took, what allocations happened, what GC events fired. Output is a .nettrace file you open in PerfView or Visual Studio. Capture for 30 seconds:
For typical performance investigations, use the cpu-sampling profile (the default). For allocation-heavy diagnostics, switch to gc-verbose. The output file can be loaded in Visual Studio's Performance Profiler (Open File > Diagnostic Session) for a flamegraph view.
`dotnet-dump` captures a full memory dump of a running process and lets you inspect it offline. Capture:
Analyze:
The analyzer opens a SOS-like prompt. Useful commands:
| Command | What it shows |
|---|---|
clrstack | Managed call stack of the current thread |
clrthreads | All managed threads and their states |
dumpheap -stat | Histogram of objects on the GC heap by type |
dumpheap -mt <address> | Every instance of the type with that method table |
pe | Print exception on the current thread |
gcroot <address> | Why this object is still alive (who's keeping a reference) |
dso | Dump stack objects, useful for inspecting locals in the dump |
A common use case is a memory leak. Take two dumps an hour apart. Run dumpheap -stat on each, diff the counts. The type that grew the most is the leak. Then dumpheap -mt <address> to find instances, and gcroot on one of them to see what's holding it.
SOS in WinDbg. SOS is the underlying debugger extension that dotnet-dump analyze uses. WinDbg provides a fuller debugging experience for deeper investigations (especially on Windows, especially for native interop scenarios). Load SOS in WinDbg with:
Then the same clrstack, dumpheap, gcroot commands work. WinDbg adds a script engine, the ability to set breakpoints in native code, and integration with the kernel debugger for OS-level scenarios.
SOS and WinDbg aren't daily-use tools. Most production debugging is dotnet-counters for live observation and dotnet-dump after the fact. Knowing they exist, and the kinds of questions they answer, separates "I can't debug this" from "this will take an afternoon."
dotnet-trace captures a lot of data. A 30-second trace can produce hundreds of megabytes. Use the shortest practical --duration, and prefer cpu-sampling over gc-verbose unless allocation events are specifically needed.
When no debugger is attached and no dump is feasible, logs are the available tool. Production debugging leans on them heavily.
A few rules that make logs useful:
Microsoft.Extensions.Logging integrates with ActivityId for this. Without correlation, following one user's request through ten log lines mixed with thousands of others becomes impossible._logger.LogInformation("Order {OrderId} processed in {Ms} ms", orderId, elapsedMs) allows filtering by OrderId in a log aggregator. _logger.LogInformation($"Order {orderId} processed in {elapsedMs} ms") produces a string that can only be greped.Information for normal flow, Warning for recoverable problems, Error for actual failures, Debug for verbose internals to enable only when investigating.A small example using Microsoft.Extensions.Logging:
In production, the rendered log lines might look like:
Logging is a method-design decision. Methods that fail without diagnostic output are unsalvageable in production. Methods that log their inputs, their key decisions, and their outcomes provide something to read when the debugger isn't available.
Logging has a cost. Each log line costs an allocation (the parameter array), a formatter call, and an I/O write. At very high request rates, log-level filtering at the source is mandatory. if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug(...) avoids the allocation when Debug is off, which the structured overloads also handle internally.
Debug.Assert vs ThrowingThere are two ways to flag "this state shouldn't happen": Debug.Assert and throwing an exception. They serve different purposes.
`Debug.Assert` is compiled out of release builds. The body of the assertion is preserved, but the call itself is removed by the C# compiler when the DEBUG symbol isn't defined. Use it for invariants that should never fail in correct code:
In debug builds, calling ApplyDiscount(100m, 1.5m) triggers an assertion dialog (Windows) or terminal message (Linux/Mac) with the message and stack trace. In release builds, the assertion is gone, and the bad input produces a nonsense result. This is why Debug.Assert is for invariants: things that, if they ever fail, indicate a bug in this code that needs fixing. Not user input. Not external state.
Throwing is for conditions that can fail in correct code, just rarely. Bad user input. A missing file. A network call timed out. These are real possibilities, and the code has to handle them:
The exception is real code that runs in both debug and release. Callers can catch it, wrap it, or let it bubble. The validation is part of the contract.
A side-by-side rule:
| Situation | Use |
|---|---|
| "If this is ever wrong, the code has a bug" | Debug.Assert |
| "This can be wrong because of external input or state" | Throw an exception |
| Public API parameter validation | Throw |
| Private helper, internal invariants | Debug.Assert |
| Defensive check that costs a lot at runtime | Debug.Assert (compiled out) |
| Anything user-facing should see a real error | Throw |
A common mistake is using Debug.Assert for input validation. The "asserted" check disappears in release, the bad input flows through, and the bug manifests in production as a corrupted result instead of a clean exception. Avoid that pattern.
A third option, Trace.Assert, is the same idea as Debug.Assert but stays in release builds. Useful in rare cases where an assertion-style check should remain in production, but throwing is almost always a better fit for production-relevant checks.
A worked example. The bug: an e-commerce order shows Total: $100.00 when it should show $72.00. The order has subtotal $80, a 10% discount, and 10% tax. Expected breakdown: discount of $8, taxable amount of $72, tax of $7.20, grand total of $79.20. The actual breakdown is $0 discount, $80 taxable, $8 tax, $88 total. The reported total is $100, so two things are wrong.
The starting code:
Current (wrong) output:
Charlie's order is the easiest to reason about, so we focus there. Expected: $80 * 0.9 = $72, then $72 * 1.10 = $79.20. Actual: $96.
Step 1: Form the hypothesis. The reported total is too high. Two possibilities: the discount isn't reducing the subtotal, or the tax is being applied to the wrong number. Quick visual reading of ComputeTotal shows two suspicious things: tax is applied to order.Subtotal directly (not the discounted amount), and the return adds discount instead of subtracting it. Test the more surprising one first: "discount is being added to the total instead of subtracted."
Step 2: Set a conditional breakpoint. A line breakpoint on the return line of ComputeTotal with the condition order.Customer == "Charlie". The debugger stops only on Charlie's iteration; the other three run past.
Step 3: Inspect state. Locals window shows:
Watch expression taxed - discount shows 80.000. Watch expression taxed + discount shows 96.000. The return statement is taxed + discount, which explains why Charlie's total is $96 instead of $72. First bug confirmed.
The [DebuggerDisplay] attribute makes Locals readable. Without it, order would show as Order, requiring expansion to see the values.
Step 4: Fix the first bug, re-run. Change the return to taxed - discount. Charlie's output becomes $80. Still wrong; expected $79.20.
Step 5: Second hypothesis. Now the suspect is "tax is applied to subtotal instead of discounted amount." Test: with subtotal = 80, discount = 8, tax = 10%, the correct sequence is 80 - 8 = 72, then 72 * 1.10 = 79.20. The current sequence is 80 * 1.10 = 88, then 88 - 8 = 80. Reorder the math: compute the discounted amount first, then tax on that:
Step 6: Verify. Re-run. Charlie now shows $79.20. Sanity-check the other three:
$89.97 * 0.9 * 1.10 = $89.0703. Output: $89.0703. Correct.$49.50 * 1.0 * 1.08 = $53.46. Output: $53.46. Correct.$120 * 0.85 * 1.10 = $112.20. Output: $112.20. Correct.Step 7: Lock it in. Add an invariant for safety. A discount rate over 1.0 is a programmer error (user input would have been validated earlier), so Debug.Assert fits:
If a future change ever passes a rate of 1.5 (because someone treated 15% as 15 instead of 0.15), the assertion fires in development long before the bug ships.
What happened in total:
[DebuggerDisplay] made the Locals view readable.Debug.Assert was added to catch the same class of bug if it recurs.The whole investigation took five minutes, no Console.WriteLine was added, no rebuild was required between hypotheses.
10 quizzes