Advanced .NET Debugging: Conditional Breakpoints, Tracepoints, Data Breakpoints and Exception Settings
The shortest way to find who changes a value is a data breakpoint: in Visual Studio, for .NET Core 3+ projects, the debugger stops on the line that writes the property. With real output we cover conditional, hit count and dependent breakpoints, tracepoints, Exception Settings for swallowed exceptions, Set Next Statement, Hot Reload, DebuggerDisplay and Source Link, testing Copilot suggestions as hypotheses, and the Rider and Visual Studio Code equivalents.
Version note: The code in this post was built and run with .NET 10. Visual Studio 2026 and Rider 2026.2 are not installed on this machine (macOS), so they were not run; the features, menu names and shortcuts of these two IDEs (Integrated Development Environments) come from Microsoft Learn and the JetBrains documentation.
Guess First
In Bookstore, order 1042 was cancelled by the fraud check, yet at the end of the day it shows up as Paid. The Order.Status property is written from 30 different places in the codebase. You could put a breakpoint on each of them and press F5 thirty times. Or you could tell the debugger "stop the moment this order object's Status changes". Do you think Visual Studio can do that for a property in .NET 10? And within the same problem, a client that fetches shipping quotes silently swallows hundreds of exceptions a day; there isn't a single line in the log. How would you see them in the debugger?
The basics of breakpoints, stepping and the call stack are in the C# Fundamentals series post "Debugging in C#: Breakpoints, the Call Stack, Debug.Assert and Compiler Warnings". This post picks up from there: using the debugger as an experiment tool that tests your hypothesis in the fewest steps.
Conditional Breakpoints and Tracepoints: Stopping at the Right Moment, or Not at All
If a loop processes 10,000 orders and only 1042 is broken, a plain breakpoint is useless. In Visual Studio, when you right-click a breakpoint and choose Conditions (or press Alt+F9, C), there are three kinds of conditions:
- Conditional Expression: an expression such as
order.Id == 1042; "Is true" stops when it is true, "When changed" stops when the value of the expression changes. - Hit Count: stops after the line has run a given number of times, for example on the 500th iteration or on every second one.
- Filter: limits stopping to a particular thread or process with
ThreadId,ThreadName,ProcessIdorMachineName.
If the condition's syntax is invalid, or it fails while being evaluated, the debugger stops; the breakpoint is skipped only when the condition is valid and evaluates to false. That's a good thing: a condition you got wrong doesn't silently turn into a breakpoint that never fires.
There are three helper kinds next to these. A dependent breakpoint ("Only enable when the following breakpoint is hit") only becomes active if another breakpoint was hit first: PriceCalculator.Calculate is called on every request, but you only want to see it when it comes from the checkout flow. A temporary breakpoint stops once and disables itself. A function breakpoint (Ctrl+K, B) is set by method name instead of by line; you type Bookstore.Pricing.PriceCalculator.Calculate, it stops in all overloads, and you don't even need to find the file.
A tracepoint is created in the same Breakpoint Settings window by ticking the Action box, and the red circle turns into a diamond. In the "Show a message in the Output Window" field you type:
Order {order.Id} status={order.Status} total={order.Total} thread=$TID caller=$CALLER
Expressions in curly braces are evaluated; keywords such as $TID, $CALLER, $FUNCTION and $CALLSTACK print the thread ID, the calling method and the call stack. As long as "Continue code execution" stays on, the program doesn't stop. So you add a temporary log without changing code, without rebuilding, and without the risk of a stray Console.WriteLine slipping into a commit. In a timing-sensitive bug, stopping can change the behavior; that's why a tracepoint is often a more honest observation tool than a breakpoint. Combine it with a condition ("log only when order.Total > 500") and the noise is gone too.
Data Breakpoints: Who Is Changing the Value?
Back to the question from Guess First. Visual Studio has data breakpoints for projects on .NET Core 3.0 and later (including .NET 5+): when the debugger is paused, you right-click order.Status in the Locals, Autos or Watch window and choose Break when value changes. Whatever code path changes that property of that object, the debugger stops on the line that made the change, and the call stack shows you the writer. The limits are clear in the docs: it doesn't work on static fields, on fields inside structs, or on classes that use DebuggerTypeProxy; the number of data breakpoints you can set at once depends on the hardware and ranges from two to four depending on the processor architecture. The breakpoint only lives for that debug session.
If you don't have the IDE, or the bug only happens in a test environment, you can move the same idea into code. We put a trace in the property setter that is only compiled into Debug builds:
public OrderStatus Status
{
get => _status;
set
{
if (value != _status)
{
// a "data breakpoint" without a debugger: who is changing the value?
Trace($"Status {_status} -> {value}");
}
_status = value;
}
}
[Conditional("DEBUG")]
private static void Trace(string message)
{
StackFrame caller = new(2);
Console.WriteLine($"{message} by {caller.GetMethod()?.DeclaringType?.Name}.{caller.GetMethod()?.Name}");
}
The real output of the Debug build:
Status Created -> Cancelled by FraudCheck.Review
Status Cancelled -> Paid by PaymentStep.Complete
final status: Paid
The story fits in two lines: FraudCheck correctly cancelled the order, then PaymentStep.Complete wrote Paid without checking the status. The bug is not in the cancellation but in the payment step. Thanks to [Conditional("DEBUG")], calls to Trace aren't compiled at all in a Release build; the same program printed only final status: Paid in Release. new StackFrame(2) is fragile in the face of inlining, so don't rely on it in Release; it is diagnostic scaffolding, not permanent code.
Where data breakpoints aren't available (a static field in Visual Studio, an old .NET Framework project), the docs suggest Object IDs: when the debugger is paused, right-click the object in Locals and choose Make Object ID, and the object gets the name $1. Then you put a conditional breakpoint with order == $1 on the suspicious line; out of 10,000 orders you only stop when that object arrives. An Object ID is a weak reference, it doesn't prevent the garbage collector from collecting the object, and it is only valid for that session.
Exception Settings: Seeing Swallowed Exceptions
Let's look at the client that fetches shipping quotes:
public decimal GetQuoteWithRetry(string postalCode)
{
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
return CallCarrier(postalCode);
}
catch (TimeoutException)
{
// retry silently
}
}
return 0m;
}
From the outside everything looks fine: the quote arrives. But every call throws and swallows a few exceptions, and that may explain why latency is high. By default the debugger only stops on unhandled exceptions. If you tick System.TimeoutException under Common Language Runtime Exceptions in the Exception Settings window (Debug > Windows > Exception Settings, shortcut Ctrl+Alt+E), the debugger stops the moment the exception is thrown, before it reaches the catch. This is called a first-chance exception. The window's search box can filter namespaces such as System.IO, and you can add conditions for specific modules ("stop only in Bookstore.Shipping.dll").
You can collect the same information without a debugger. The runtime raises an event for every first-chance exception:
int firstChance = 0;
AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
{
firstChance++;
Console.WriteLine($"first-chance #{firstChance}: {e.Exception.GetType().Name}: {e.Exception.Message}");
};
ShippingQuoteClient client = new();
decimal quote = client.GetQuoteWithRetry("34000");
Console.WriteLine($"quote: {quote}, first-chance exceptions: {firstChance}");
first-chance #1: TimeoutException: Carrier did not answer for 34000.
first-chance #2: TimeoutException: Carrier did not answer for 34000.
quote: 79.90, first-chance exceptions: 2
Two timeouts for a single quote. If each of the carrier's timeouts takes a few seconds, it's clear where the user's waiting time goes. Don't leave this handler on in production; it runs for every exception, and if you throw inside it you can end up in an infinite loop. Turn it on briefly for diagnosis, count, turn it off.
One more detail about Exception Settings: with Just My Code on, you can stop at the "user-unhandled" point for errors that ASP.NET Core's own exception handler would turn into a 500; for an exception that leaves your code and is caught in the framework, the debugger still stops you in your own code.
Changing the Running Program: Set Next Statement, Immediate and Hot Reload
You don't have to restart the program every time you test a hypothesis. In Visual Studio, while the debugger is paused, you change the next line to run by dragging the yellow arrow or by right-clicking a line and choosing Set Next Statement (Ctrl+Shift+F10). You can try "if I skip FraudCheck.Review, does the order still end up Paid?" in a second. Take the warnings in the docs seriously: skipped lines don't run, going back doesn't undo lines that already ran, and in managed code you can't jump into another method. Be very careful with this tool around code that has side effects (writing to the database, sending messages).
The Immediate Window (Ctrl+Alt+I) and Watch let you run expressions inside the paused program: you type order.Lines.Sum(l => l.Quantity * l.UnitPrice) and compare it with Total, or even set order.Status = OrderStatus.Cancelled by hand and continue. Evaluation really runs code; if a property getter has side effects, merely looking at the Watch window can change the program's state.
You may not need to restart to try a fix either. Hot Reload and Edit and Continue in Visual Studio, and Hot Reload in Rider, do this job. We ran the same mechanism for real without an IDE, using dotnet watch. An endpoint that applies the discount twice:
static class PriceRules
{
public static decimal Discounted(decimal listPrice) => Math.Round(listPrice * 0.90m * 0.90m, 2);
}
$ curl -s http://localhost:5202/api/books/7/price
{"id":7,"price":283.42}
While the app is running, we delete one of the * 0.90m factors and save the file:
dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch ⌚ Waiting for changes
dotnet watch 🔥 C# and Razor changes applied in 263ms.
$ curl -s http://localhost:5202/api/books/7/price
{"id":7,"price":314.91}
The process didn't restart, the change was applied in 263 ms, and the same request returned the corrected price. Hot Reload doesn't accept every change (some type signature changes, for instance, count as "rude edits" and need a restart); when it can't apply one, the tool tells you.
Shaping What the Debugger Shows: DebuggerDisplay, Just My Code and Source Link
A row in the Watch window that says {Bookstore.Debugging.Order} tells you nothing. With DebuggerDisplay you choose what appears in the object's value column:
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class Order(int id)
{
private OrderStatus _status = OrderStatus.Created;
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private readonly Dictionary<string, object> _cache = [];
public int Id { get; } = id;
public List<OrderLine> Lines { get; } = [];
public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);
private string DebuggerDisplay => $"Order {Id}: {Status}, {Lines.Count} lines, total {Total}";
}
We produced the text the debugger would show for this object by reading the same private property through reflection:
debugger shows: Order 1042: Paid, 1 lines, total 699.80
Pointing the attribute at a private property instead of writing a complex expression into it is what the docs recommend: expressions in curly braces are evaluated on every display, with the evaluator of the current stack frame's language, and they can slow down stepping with large collections. ,nq (no quotes) removes the quotes. DebuggerBrowsable(Never) hides noisy fields such as _cache from the view. In Visual Studio, if "Show raw structure of objects in variables windows" is on, DebuggerDisplay is ignored.
Just My Code is on by default and limits the debugger to your code: you don't step into the framework, and framework frames are collapsed into "External Code" in the call stack. If you suspect the bug is inside a library, you turn off Enable Just My Code under Tools > Options > Debugging > General and enable the symbol servers (Microsoft Symbol Servers, NuGet.org Symbol Server). The source code itself comes through Source Link: inside the PDB (Program Database, the symbol file) there is a mapping that points at the commit the build was made from, and the debugger downloads the file from there. Since the .NET 8 SDK (Software Development Kit), Source Link ships inside the SDK; in a repo with a GitHub remote, a plain dotnet build produces this file:
$ dotnet build -c Release -p:ContinuousIntegrationBuild=true
$ cat obj/Release/net10.0/Bookstore.Pricing.sourcelink.json
{"documents":{"/_/*":"https://raw.githubusercontent.com/example/bookstore-pricing/f500bfabfd7d940fcb0f8adcbaa89b8bcef24dcb/*"}}
The commit hash is exactly the same as git rev-parse HEAD. ContinuousIntegrationBuild replaces the machine-specific folder path with /_/; it should be on in CI (Continuous Integration) builds. Since Visual Studio 2022 17.7, when symbols can't be found, .NET code can be decompiled automatically; Rider also decompiles external sources by default. Decompiled code is readable but not the original: comments and local variable names are missing.
AI Assistance: Testing a Suggestion Like a Hypothesis
Visual Studio's AI (Artificial Intelligence) assistant, Copilot, now lives inside the debugger. According to the docs, when you're paused on an exception the Analyze with Copilot button takes the exception, the call stack and the variables as context and offers an explanation and a fix. In the conditional breakpoint and tracepoint window, clicking the expression field makes Copilot suggest conditions specific to your code. The Debugger Agent in Visual Studio 2026 goes a step further: you pick Debugger from the mode list in Copilot Chat, or type @debugger in Agent mode, and describe the bug with a GitHub or Azure DevOps issue or a plain sentence. The agent tries to reproduce the bug, forms hypotheses, instruments the app with tracepoints and conditional breakpoints, runs a debug session, looks at the runtime data and suggests a targeted fix; the final validation is yours.
These steps are exactly the observation, hypothesis, experiment loop from the first post of this series. The difference is who forms the hypothesis. Copilot saying "Status was probably overwritten by two concurrent requests" is a hypothesis, not a conclusion. Test it with the tools in this post: does the data breakpoint really show writes from two different threads, are the $TID values in the tracepoint different? A suggestion produces a guess; the debugger produces evidence. We didn't run these features on this machine; the descriptions come from the "Debug with GitHub Copilot" page on Microsoft Learn.
Rider and Visual Studio Code Equivalents
The same ideas go by different names in the three tools. The table was compiled from Microsoft Learn, the JetBrains Rider 2026.2 documentation and the C# debugging page of Visual Studio Code; where it says "not in the docs", the feature may be missing or simply undocumented. Rider shortcuts depend on the keymap you chose.
| Feature | Visual Studio 2026 | Rider 2026.2 | Visual Studio Code (C# Dev Kit) |
|---|---|---|---|
| Conditional breakpoint | Conditions > Conditional Expression | Condition in breakpoint properties | Edit Condition (Expression) |
| Hit count | Conditions > Hit Count | Hit count in breakpoint properties (More) | Not in the docs; $HITCOUNT in logpoints |
| Dependent breakpoint | Only enable when the following breakpoint is hit | Disabled until the selected breakpoint is hit | Triggered breakpoint |
| Tracepoint | Action: Show a message in the Output Window | Suspend off, Evaluate and log | Logpoint |
| Data breakpoint | Break when value changes (.NET Core 3+) | Data breakpoint (Windows only, .NET Core 3+) | Not in the docs |
| Stopping on exceptions | Exception Settings (Ctrl+Alt+E) | Exception breakpoint | All Exceptions / User-Unhandled Exceptions |
| Changing the next line | Set Next Statement (Ctrl+Shift+F10) | Moving the execution pointer | Not in the docs |
| Marking an object | Make Object ID ($1) | Mark Object (label) | Not in the docs |
| Running expressions | Immediate Window, Watch | Immediate window, Evaluate | Debug Console, Watch |
| Hot Reload | Hot Reload, Edit and Continue | Hot Reload | csharp.experimental.debug.hotReload |
| Limiting to your own code | Enable Just My Code | External source debugging (decompile) setting | csharp.debug.justMyCode |
In Production
Attaching an interactive debugger to a process in production means freezing that process at a breakpoint: every request arriving meanwhile waits, health checks fail, and the orchestrator may restart the process. For diagnosis in production, use tools that don't stop the process: logs, dumps and traces. Those are the subjects of the ninth and tenth posts of this series.
Know the cost of a conditional breakpoint. Every time the line runs, the debugger stops, evaluates the condition and continues; in a hot loop this can slow the program down noticeably and make a timing-dependent bug disappear. On a line that runs very often, putting the condition into code under the debugger (if (order.Id == 1042) Debugger.Break();) is faster; don't forget to delete it when you're done.
When you debug a Release build, optimizations make variables show up as "optimized away" and some lines get skipped. That isn't a debugger bug; the compiler rearranged the code. If needed you can turn off JIT (Just-In-Time) optimizations during debugging with settings such as csharp.debug.suppressJITOptimizations in Visual Studio Code, but the behavior you see may then differ from production.
Don't put PII (Personally Identifiable Information) such as customer emails or card numbers into DebuggerDisplay strings or tracepoint messages. Even though tracepoint output stays in the Output window, it travels through screen shares, recordings and copied logs.
Common Pitfalls
Running expressions with side effects in the Watch window. Adding queue.Dequeue() or a lazily loaded property to Watch changes the program's state while you look; some "the bug disappears when I look at it in the debugger" cases are exactly this.
Forgetting the side effects of code skipped with Set Next Statement. Skip the start of a using block and the connection never opens; go back and run a line twice and you may write two records to the database.
Leaving "break on all CLR (Common Language Runtime) exceptions" on in Exception Settings. The framework throws and catches many exceptions internally; the debugger stops on each of them and you never reach the point you are looking for. Tick only the type you suspect.
Applying an AI suggestion without testing it. The suggested fix may cover the symptom and leave the cause; in the order example above, adding "don't touch it if Cancelled" to PaymentStep is correct, but removing FraudCheck would also turn the tests green.
Not knowing that data breakpoints don't work on struct fields and static fields. If the breakpoint won't set, check these limits first, then fall back to an Object ID or an in-code trace.
A Small Experiment
★☆☆ Keep the FirstChanceException handler on in one of your own projects for 30 seconds and print the counter. How many exceptions are thrown and caught, which come from your code and which from the framework?
★★☆ Make Order a struct instead of a class and run the Status trace again. Does the output change? Why can't a data breakpoint be set in Visual Studio in this case, and why doesn't an Object ID help either?
★★★ In the endpoint running under dotnet watch, change the signature of PriceRules.Discounted (add a second parameter, for example). What does Hot Reload say? Make a list of which changes it applies without trouble and which ones ask for a restart.
In a single-threaded program the debugger is a powerful microscope. But Bookstore's checkout is async and multi-threaded; stopping there sometimes means hiding the bug with your own hands. In the next post we read async call stacks with Parallel Stacks and the Tasks window and go hunting for deadlocks and race conditions.