Stop Squinting at .NET Stack Traces: Debug Them With Claude Inside Visual Studio
An unhandled exception fires, and Visual Studio drops forty lines of stack trace into your lap. Thirty-eight of them live in Microsoft.EntityFrameworkCore.* or System.Private.CoreLib, one is an async state-machine frame with a name like <<Main>$>b__0_0, and exactly one points at code you wrote. Your job is to find that one line, understand why it blew up, and fix the actual cause — not the symptom.
We've all pasted a trace into a search engine and gotten back a single Stack Overflow question from 2015, zero answers, one comment that just says "did you ever solve this?". There's a better way, and it now lives right inside Visual Studio: hand the trace to Claude, get back a ranked diagnosis tied to your line, and a fix you can actually reason about.
Why .NET Stack Traces Are Hard to Read
Stack traces aren't hard because they're long. They're hard because modern .NET code rarely runs in a straight line, and the trace reflects the machinery, not your intent.
-
Async unwinding.
awaitcompiles into a state machine. When an exception crosses anawait, the frames you see areMoveNext()calls andExecutionContext.Run, not the tidy call chain you wrote. The method name shows up as<SomethingAsync>d__4, and the line that logically threw might be three awaits up. -
Inner exceptions. The message on top is often the least useful one. The real cause is nested — a
DbUpdateExceptionwrapping aSqliteException, or aTargetInvocationExceptionwrapping the thing you actually care about. If you only read the first line, you're debugging the wrapper. -
AggregateException. Anything involvingTask.WhenAllor parallelism can bundle several failures into one. The outer message says "One or more errors occurred" — thanks, very helpful. - Inlined and framework frames. The JIT inlines small methods, so frames disappear. And the frames that remain are mostly library internals you can't change and don't need to read.
An LLM is genuinely good at this specific chore. It has seen these exception shapes thousands of times, it can tell the framework noise from the signal, and — this is the part that matters — when it runs inside your IDE, it can correlate the trace with the actual source file and line.
Two Ways to Put Claude Inside Visual Studio
You don't need to leave the IDE or paste anything into a browser. There are two solid paths, and they suit different moments.
1. Copilot Chat with a Claude model (in-IDE, conversational)
Visual Studio 2022 (17.14+) ships GitHub Copilot with a model picker. Click the Copilot badge, open the model dropdown in the chat prompt box, and pick a Claude model — Sonnet for fast everyday diagnosis, Opus when the bug is genuinely nasty. From then on, every Copilot Chat reply is generated by Claude.
Two things make this path shine for debugging:
- When an unhandled exception pops the Exception Helper while debugging, there's an inline Ask Copilot link right on that dialog. With Claude selected as your model, that one-click analysis runs on Claude.
- In the chat box you can pull real context in with
#references —#Program.cs,#solution, or a highlighted selection — so Claude reads your actual code, not a paraphrase.
If you'd rather bring your own Anthropic key instead of using Copilot's models, the same picker has Manage Models → add Anthropic → paste your API key. Handy if you're already paying for API access.
2. Claude Code in the integrated terminal (agentic)
There's no official Claude Code extension for the full Visual Studio yet, but you don't need one — Claude Code runs in any terminal, including View → Terminal (Ctrl+) inside VS. Runclaude`, paste the trace, and because Claude Code can read the whole repository, it will open the offending file itself, trace the call path, and propose the fix as a diff you approve or reject.
The trade-off is simple: Copilot Chat is faster for a quick "what does this mean?", while Claude Code is better when the fix touches several files or you want it to reproduce and verify. Use whichever the bug deserves.
A Real Example: The EF Core Trace That Blames the Wrong Thing
Let's debug something concrete. Here's a .NET 10 minimal API endpoint that returns an order summary. It looks reasonable, and it compiles cleanly:
`csharp
// Program.cs — .NET 10 minimal API
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext(o => o.UseSqlite("Data Source=shop.db"));
var app = builder.Build();
app.MapGet("/orders/{customerId:int}/summary", async (int customerId, ShopContext db) =>
{
// Fire both queries "in parallel" to be fast — right?
var ordersTask = db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
var totalTask = db.Orders.Where(o => o.CustomerId == customerId).SumAsync(o => o.Total);
await Task.WhenAll(ordersTask, totalTask);
return Results.Ok(new { Orders = ordersTask.Result, Total = totalTask.Result });
});
app.Run();
`
Hit the endpoint and it throws. Here's the trace Visual Studio shows (trimmed to the frames that appear):
text1 source, CancellationToken ct)
System.InvalidOperationException: A second operation was started on this context
instance before a previous operation completed. This is usually caused by different
threads concurrently using the same instance of DbContext. For more information on
how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
at Microsoft.EntityFrameworkCore.Internal.ConcurrencyDetector.EnterCriticalSection()
at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.ExecuteAsync[TResult](Expression query, CancellationToken ct)
at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.ExecuteAsync[TResult](Expression expression, CancellationToken ct)
at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable
at Program.<>c.<
`
Four of those five frames are EF Core internals. The one that's yours — Program.cs:line 12 — is the ToListAsync() call, which makes it look like ToListAsync is the villain. It isn't.
Feeding it to Claude the right way
The quality of the answer depends entirely on what you paste. A one-line message gets you a one-line guess. Give Claude the whole picture:
`text
.NET 10, EF Core 10, SQLite. This endpoint throws on every request under normal load
(not just concurrency). Full exception and the endpoint code below — what's the root
cause, and what's the idiomatic fix?
[paste the FULL exception via ex.ToString() — message + all inner exceptions + trace]
[paste the MapGet handler]
`
Three things make that prompt good:
-
The full exception, not the headline.
ex.ToString()includes every inner exception and the complete trace. In Copilot Chat,#Program.cspulls the source in for you. -
The first frame that's yours. Point Claude at
Program.cs:line 12and the code around it. The framework frames are context; your frame is the crime scene. - The conditions. "Every request" vs "only under load" vs "intermittent" changes the diagnosis completely. Say what you observed.
What Claude tells you
Instead of "line 12 failed", you get the actual mechanism: EF Core's DbContext is not thread-safe and does not allow two operations on the same instance at once. Task.WhenAll starts the second query before the first finishes, both on the same injected db, and EF's ConcurrencyDetector throws. The line number was a red herring — the bug is the pattern, not the call.
And it gives you both fixes, with the trade-off:
csharp
// Fix A — simplest: run them sequentially. One context, one operation at a time.
var orders = await db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
var total = await db.Orders.Where(o => o.CustomerId == customerId).SumAsync(o => o.Total);
return Results.Ok(new { Orders = orders, Total = total });
`csharp
// Fix B — real parallelism: give each query its OWN context via a factory.
builder.Services.AddDbContextFactory(o => o.UseSqlite("Data Source=shop.db"));
app.MapGet("/orders/{customerId:int}/summary",
async (int customerId, IDbContextFactory factory) =>
{
await using var ordersDb = await factory.CreateDbContextAsync();
await using var totalDb = await factory.CreateDbContextAsync();
var ordersTask = ordersDb.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
var totalTask = totalDb.Orders.Where(o => o.CustomerId == customerId).SumAsync(o => o.Total);
await Task.WhenAll(ordersTask, totalTask);
return Results.Ok(new { Orders = await ordersTask, Total = await totalTask });
});
`
Fix A is what you want 90% of the time — two SQLite queries are fast, and the parallelism was never worth the bug. Fix B is the answer when the queries are genuinely slow and independent, because IDbContextFactory<T> hands you a fresh, short-lived context per operation. That distinction — not the line number — is the thing you were actually missing, and it's the kind of judgment a good explanation gives you and a search result doesn't.
A .NET async stack trace has two kinds of frames: the forty that belong to the runtime, and the one that's your fault. Claude's job is to skip the forty and explain the one.
Verify — Don't Trust Blindly
Claude is a very fast senior colleague who is occasionally, confidently wrong. Treat its diagnosis as a strong hypothesis, not a verdict:
- Reproduce and confirm. Apply the fix, re-run, and check the exception is actually gone — ideally under the same conditions that triggered it. In Visual Studio, set a breakpoint on the fixed line and watch the state.
- Ask why, not just what. "Explain why this fixes it" surfaces a wrong root cause fast. If the reasoning doesn't hold, the fix probably doesn't either.
- Watch for plausible-but-wrong causes. For intermittent bugs especially, Claude may propose a cause that fits the trace but isn't yours. Make it rank candidates and tell you how to distinguish them.
The debugger still owns state. Claude owns meaning. When you need to know the actual value of a variable at the moment of the crash, that's a watch window and a breakpoint, not a chat prompt.
When to Reach for Claude — and When Not To
Reach for it when:
- The exception type or message is unfamiliar, or the trace is mostly framework internals.
- The real cause is buried in inner exceptions or an
AggregateException. - You're staring at async frames and can't tell which of your lines is the origin.
- You want the idiomatic fix and the trade-offs, not just a patch that makes the red go away.
Don't bother when:
- You already know the cause — a
NullReferenceExceptionon a line you just wrote doesn't need an LLM. - You need the runtime value of something — use the debugger.
- The trace contains secrets or customer data. Connection strings, tokens, and PII live in exception messages and payloads more often than you'd like. Scrub before you paste, especially with a cloud model.
The rule of thumb: if you'd otherwise open a browser to search the exception, ask Claude instead — it's in the IDE, it can read your code, and it won't send you to a dead 2015 forum thread.
Key Takeaways
- The trace shows machinery, not intent — async state machines, inner exceptions, and framework frames bury the one line that's yours. Reading traces is pattern recognition, which is exactly where an LLM helps.
- Claude lives in Visual Studio two ways — pick it in Copilot Chat's model picker for quick, conversational diagnosis; run Claude Code in the integrated terminal when the fix is agentic and spans files.
-
Context is the whole game — paste the full
ex.ToString(), point at the first frame that's yours, and describe when it happens. A headline message gets a headline guess. -
The line number is often a red herring — the EF Core example throws at
ToListAsync, but the bug is sharing oneDbContextacross parallel queries. The value is the explanation, not the location. - Verify, then trust — reproduce the fix, ask why it works, and never paste secrets. The debugger owns state; Claude owns meaning.



Top comments (0)