Inspecting and changing state at a breakpoint with Locals, Autos, Watch, QuickWatch, DataTips and the Immediate window (Debug Console in VS Code), format specifiers, Object IDs, and the side effects of function evaluation.
At a breakpoint, Locals shows the variables in scope, Autos shows the ones used around the current line, and Watch shows expressions I pick and keep across steps. The Immediate window, which is the Debug Console in VS Code, lets me evaluate expressions, call methods and even assign new values to test an idea. The catch is that evaluating an expression runs real code in the paused process. A property getter with side effects, a LINQ query or a lazy-loaded EF Core navigation can change state or hit the database. When that matters I add the nse (no side effects) format specifier, as in customer.Orders, nse, or read backing fields directly. I also remember evaluation can time out because other threads are frozen.
"At a breakpoint, Locals shows the variables in scope, Autos shows the ones used around the current line, and Watch shows expressions I choose and keep across steps. The Immediate window, which is the Debug Console in VS Code or Evaluate Expression in Rider, lets me evaluate expressions, call methods and assign values to test an idea, and because a value only makes sense in the right frame, I use it alongside the Call Stack.
The catch is that evaluating anything other than a field or local is function evaluation: the debugger runs real code inside the paused process. A getter with side effects, a LINQ query or an EF Core lazy-loaded navigation can change state or hit the database, and the same applies to a
DebuggerDisplayexpression on the type. Because the other threads stay frozen, a method that needs a lock they hold can't finish, so the evaluation times out or deadlocks.So when state is fragile I add the
nseformat specifier, as incustomer.Orders, nse, or read the backing field instead of a property with logic. Other specifiers help too, such asnq,h,resultsandraw, and Make Object ID lets me follow one object after it leaves scope. The mistake I see is treating a value changed at a breakpoint as a fix, when it only tests a hypothesis.The debugger is not a passive observer, so I change one value at a time, note what I changed, and turn the confirmed hypothesis into a test."
| Window | Shows | Best for |
|---|---|---|
| Locals | All locals and parameters in the current frame | A quick overview after stopping |
| Autos | Variables on the current and previous lines | Following a few values while stepping |
| Watch 1–4 | Expressions you add; they persist between sessions | Tracking the same values over many steps |
| QuickWatch / DataTips | One-off evaluation; hover tooltips that can be pinned to the editor | Ad hoc checks |
| Immediate | Free-form evaluation: ? expr, statements, method calls, assignments |
Testing a hypothesis without editing code |
| Parallel Watch | The same expression across many threads or tasks | Concurrency bugs |
In VS Code the equivalents are the Variables and Watch panes and the Debug Console; in Rider, Variables, Watches and Evaluate Expression.
, h (hex), , nq (string without quotes), , results (force the Results View of an IEnumerable), , raw (ignore debugger display customisations), , nse (no side effects).$1 that you can watch even after it goes out of scope, which is useful for following one object through a pipeline.order.Status = "Paid"), then continue to see whether the bug disappears.Evaluation is function evaluation: the debugger runs a method inside your paused process. So:
IQueryable runs it.While debugging an order service, a developer expanded customer.Orders in the Watch window on every step. With EF Core lazy-loading proxies, each expansion sent a query to the database and changed what the DbContext was tracking, so the bug moved. Switching to customer.Orders, nse and reading the change tracker made the real issue visible: a missing Include caused an N+1 pattern in production.
The interviewer is checking practical debugging fluency and awareness that evaluation changes the program.
Green flags
nse, results or Object IDs, and the EF Core lazy-loading trap.Red flags
SaveChanges() from the Immediate window against shared data.Every value you see in a debugger window comes from one of two places:
expression in Watch: customer.Orders
|
v
is it a local, parameter or field?
|
+-- yes --> debugger READS MEMORY directly. No code runs. Safe.
|
no (property getter, method call, ToString, DebuggerDisplay,
| LINQ, indexer with logic)
v
FUNCTION EVALUATION:
borrow one stopped thread
run the code inside your paused process
(every other thread stays frozen)
|
+-- finishes --> show the value
+-- takes too long --> abort, show "evaluation timed out"
+-- throws --> show the exception as the value
That distinction explains every risk in this question. A field read can't change anything. Function evaluation is your code running for real.
| Window (Visual Studio) | VS Code | Rider | What it shows | Evaluates code? |
|---|---|---|---|---|
| Locals | Variables | Variables | Every local and parameter in the selected frame | Yes, to show properties when you expand objects |
| Autos | (n/a) | (n/a) | Variables on the current and previous lines, plus return values of methods you just stepped over | Yes |
| Watch 1 to 4 | Watch | Watches | Expressions you choose; they persist between sessions | Yes, on every step |
| QuickWatch, DataTips | Hover | Hover / Quick Evaluate | One-off evaluation; DataTips can be pinned | Yes |
| Immediate | Debug Console | Evaluate Expression | Free-form expressions and statements | Yes, including assignments and method calls |
| Parallel Watch | (n/a) | (n/a) | One expression across many threads or tasks | Yes, per thread |
A Watch expression is re-evaluated on every step, so a side-effecting Watch runs its side effect every time you press F10.
Add them after a comma in Watch, QuickWatch or Immediate:
| Specifier | Effect | Example |
|---|---|---|
nse |
No side effects: refuse to run anything that might change state | customer.Orders, nse |
ac |
Force evaluation of an expression the debugger skipped | order.Total, ac |
h / d |
Hexadecimal / decimal | flags, h |
nq |
String without quotes and escapes | json, nq |
results |
Show only the Results View of an IEnumerable |
query, results |
raw |
Ignore DebuggerDisplay and type proxies |
order, raw |
hidden |
Show all public and non-public members | order, hidden |
dynamic |
Show the Dynamic View of a dynamic object |
expando, dynamic |
Useful pseudo-variables: $exception (the current exception), $ReturnValue (the value just returned), and Object IDs such as $1.
Right-click a value and choose Make Object ID. The debugger keeps a weak handle to that specific object, named $1, $2 and so on.
frame A: var order = Load("A-102"); --> Make Object ID --> $1
frame B: Process(order) --> watch $1.Status
frame C: order is out of scope --> $1.Status still works
...later, another variable 'x' looks the same
? object.ReferenceEquals(x, $1) --> is it the SAME object?
It answers questions such as "is this the same instance or a copy?" and "what happened to this object after it left my method?". It doesn't keep the object alive; if it is collected, $1 shows that it is no longer available.
Trap 1: a getter with a side effect.
public IReadOnlyList<string> Numbers { get { _readCount++; return _numbers; } }
Watch: batch.Numbers --> _readCount++ on every step
Your log later says "getter called 7 times" -- 5 of those were the debugger
Trap 2: lazy loading and IQueryable.
| You expand | What actually happens |
|---|---|
customer.Orders with EF Core lazy-loading proxies |
A SQL query runs and the entities become tracked by the DbContext |
The Results View of db.Orders.Where(...) |
The whole query runs against the database from your machine |
query.Count() in Immediate |
Another SELECT COUNT(*) |
The bug you were chasing (an N+1 pattern, a missing Include, a tracking problem) can disappear or change because the debugger loaded the data first.
Trap 3: deadlock during evaluation.
Thread 1 (frozen) holds lock L
Thread 5 (borrowed for evaluation) runs your getter, which needs lock L
--> it waits forever --> evaluation times out
Visual Studio may then disable further implicit evaluation for the session
The same happens with getters that call .Result on a task that needs a frozen thread to complete.
You can edit a value in Locals or Watch, or run order.Status = "Paid" in the Immediate window. Use it as an experiment:
Remember the Immediate window runs real code: db.SaveChanges() saves to the database, and httpClient.PostAsync(...) sends a real request.
| Setting (Tools > Options > Debugging > General) | Effect |
|---|---|
| Enable property evaluation and other implicit function calls | Turn off to stop the debugger running getters automatically; values then show a refresh icon you click to evaluate |
| Call string-conversion function on objects in variables windows | Turn off if ToString() overrides are slow or have side effects |
Turning these off is a good idea when debugging code with fragile state or expensive getters.
Watch and Immediate show whatever is in memory: tokens, connection strings, customer records and personal information. Debug with test data, don't share screen recordings of sessions on real data, and remember that pinned DataTips and Watch expressions are saved in your user settings for the solution.
These windows are IDE features and work the same for .NET 10 and .NET 11 previews. The expression evaluator follows new C# syntax as each IDE release adds support.
batch.Numbers to Watch, step a few times, and watch batch.ReadCount climb even though your code didn't read it.batch.Numbers, nse and read the message.batch._numbers instead and confirm it shows the list without changing the count.batch.Add("A-999"), then ? batch.Numbers.Count.batch, choose Make Object ID, step out of the method and watch $1.A class whose property has a side effect:
var batch = new OrderBatch();
batch.Add("A-100");
batch.Add("A-101");
Console.WriteLine($"Orders: {batch.Numbers.Count}"); // set a breakpoint here
Console.WriteLine($"Getter called {batch.ReadCount} time(s)");
public sealed class OrderBatch
{
private readonly List<string> _numbers = [];
private int _readCount;
public IReadOnlyList<string> Numbers
{
get
{
_readCount++; // side effect: watching this property changes state
return _numbers;
}
}
public int ReadCount => _readCount;
public void Add(string number) => _numbers.Add(number);
}
Watch expressions and Immediate window commands to try:
batch.Numbers, nse // refuses to run the getter
batch._numbers // reads the backing field directly, no code runs
batch.ReadCount, h // hexadecimal
batch, raw // ignores any DebuggerDisplay customisation
? batch.Numbers.Count // Immediate: evaluate (runs the getter)
batch.Add("A-999") // Immediate: call a method in the paused process
Mistake: assuming watching is read-only.
Getters, ToString(), DebuggerDisplay, LINQ and indexers all run code inside your process, and a Watch expression re-runs on every step.
Instead: watch fields and locals where possible, add , nse when state is fragile, and turn off implicit property evaluation for code with side-effecting getters.
Mistake: expanding an IQueryable Results View or a lazy-loaded navigation against a real database.
The debugger sends the SQL. On a large table that is a slow, full query from your machine, and with tracking it also loads entities into the DbContext, changing what SaveChanges() will do and hiding N+1 problems.
Instead: use , nse, inspect db.ChangeTracker or the generated SQL in logs, and debug against a local database with test data.
Mistake: calling methods that need locks or async results from Watch or Immediate. Other threads are frozen, so a method waiting for a lock or a task they own never finishes. The evaluation times out and the debugger may stop evaluating for the rest of the session. Instead: evaluate simple field reads. If you need the result of a complex method, step to a point where your code calls it.
Mistake: treating a value changed in the debugger as proof of a fix. Changing state at a breakpoint shows what would happen if the value were different, not why it was wrong. Instead: use it to confirm the hypothesis, then fix the code and add a test that fails without the fix.
Mistake: making several changes at once. If the bug disappears, you don't know which change mattered, and the process is now in a state real code never produces. Instead: change one value at a time, note it, and restart the session when an experiment goes wrong.
Mistake: not telling the team what you changed. When pairing or reproducing a bug together, an unmentioned edit in Locals or the Immediate window makes everyone draw the wrong conclusion. Instead: say it aloud, or note it in the ticket alongside the reproduction steps.
Mistake: running state-changing commands in the Immediate window.
SaveChanges(), sending emails or calling an API from the Immediate window does it for real.
Instead: keep the Immediate window for reading values and small experiments on local data. Write a test or a scratch app for anything that changes external state.
Mistake: losing track of a value as Autos changes.
Autos only shows variables near the current line, so values disappear as you step.
Instead: add the value to Watch, or use Make Object ID ($1) to keep following one object after it leaves scope.
Mistake: applying local debugging habits to production.
These windows need a live, paused process, and pausing production stops every request.
Instead: answer production questions with logs, traces, dotnet-counters and memory dumps.
Mistake: debugging on production data. Watch windows, pinned DataTips and screenshots expose personal information and secrets. Instead: debug with test or anonymised data, and never share recordings of sessions on real customer data.
A senior engineer knows the debugger is not a passive observer. They prefer fields and nse when state is fragile, avoid evaluating anything that does I/O, and keep production-like data out of debug sessions.
When they do change state on purpose, they do it deliberately: change one value, write down what they changed, and treat everything after that point as an experiment rather than real behaviour. If an experiment goes wrong, they restart the session instead of trying to undo it by hand. For questions that need repeated mutation, they write a small unit test or a scratch console app instead of driving the real app from the Immediate window.
They use the Immediate window as a fast experiment loop ("if this value were X, would the bug go away?") and then encode the answer as a test, because a hypothesis confirmed in the debugger is not a fix until code and tests change. On a team they make types debugger-friendly with cheap, side-effect-free DebuggerDisplay expressions so everyone's Watch window is useful.
These attributes change only what the debugger shows; they don't change runtime behaviour. [DebuggerDisplay] sets the one-line summary in Watch and DataTips, so it should be cheap and side-effect free. The usual pattern is a private DebuggerDisplay property with nq. [DebuggerBrowsable(Never)] hides noisy fields, and [DebuggerTypeProxy] gives a custom view. [DebuggerStepThrough] skips trivial code when stepping, but overused it hides bugs. In this class I'd remove the email and password hash from the display, replace the display expression that calls the database, make the side-effecting property a method, and delete the leftover Debugger.Launch().
The Call Stack shows every method that is currently active on the thread, newest at the top: who called whom to reach this line. I double-click a lower frame to jump to that caller, and Locals and Watch switch to that frame's variables, which is how I find where a bad value came from. Just My Code hides framework frames as [External Code]. For async code the real thread stack after an await starts in the thread pool, so Visual Studio stitches the logical async chain back together, and Parallel Stacks shows it across tasks. In Release builds, inlining can hide frames.
Last updated 1 Nov 2026.