How the debugger finds source for code you did not write: portable PDBs, symbol servers and .snupkg packages, Just My Code, Source Link and embedded sources, with settings for Visual Studio, VS Code and Rider.
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.
"A PDB file maps compiled IL back to source lines and local variable names, and the .NET SDK produces portable PDBs by default. Without a matching PDB the debugger can't show source or bind breakpoints, and the match is strict: a PDB from a different build won't load, which is a common reason for 'symbols not loaded'. Just My Code tells the debugger to step only through my own code and collapse everything else into
[External Code]on the Call Stack, which keeps everyday stepping and exception breaking focused.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 downloads the PDBs, and make sure Source Link is on. Source Link records the repository URL and commit in the PDB, so the debugger fetches the exact source that was compiled, without cloning anything. Framework code is optimised, so some variables may still show as unavailable.
For libraries we publish, I ship a
.snupkgsymbol package with Source Link and deterministic CI builds usingContinuousIntegrationBuild. For applications, I deploy the PDBs so production exception logs have file and line numbers and memory dumps stay readable. The mistake I see is leaving Just My Code off permanently, so I turn it off only for the session where I need to read library code, and I only use trusted symbol servers, because downloaded source is someone else's code. Debuggability is a feature of what you ship."
When the C# compiler builds an assembly it also writes a PDB (program database). The .NET SDK produces portable PDBs by default, which work on every OS. The PDB contains:
The debugger matches a PDB to its DLL by a unique id, so a PDB from a different build will not load. That is why "symbols not loaded" is a common reason a breakpoint will not bind.
With Just My Code on (the default in Visual Studio, "justMyCode": true in VS Code), the debugger treats code as yours when it is non-optimised and has symbols loaded. Stepping skips other code, the Call Stack collapses it into [External Code], and exceptions are reported where they leave framework code and enter yours.
| Visual Studio | VS Code (C# extension) | Rider | |
|---|---|---|---|
| Just My Code | Tools → Options → Debugging → General → Enable Just My Code (untick) | "justMyCode": false in launch.json |
Debugger settings → Enable external source debug |
| Symbols | Debugging → Symbols → tick Microsoft Symbol Servers and NuGet.org Symbol Server | "symbolOptions" with Microsoft and NuGet.org servers |
Uses symbol servers, and can decompile assemblies without symbols |
| Source | Enable Source Link support (on by default) | Source Link supported | Source Link or decompiled source |
The first time, symbol downloads are slow; they are cached locally afterwards. To make optimised framework code easier to inspect, Visual Studio can also Suppress JIT optimisation on module load, at a performance cost.
Source Link writes the repository URL and commit hash into the PDB. The .NET 8 and later SDKs include Source Link support for common hosts (GitHub, Azure Repos, GitLab, Bitbucket), so for a library you mostly set publishing properties: produce a .snupkg symbol package, set ContinuousIntegrationBuild in CI for deterministic paths, and push both packages to the feed.
A team suspected a bug in an internal NuGet package that handled ABN validation. Because the package published .snupkg symbols to the internal Azure Artifacts feed with Source Link, a developer turned off Just My Code, stepped straight into the package at the exact commit, and found a missing check-digit case in minutes, without cloning the other repository or guessing at the version.
The interviewer is checking whether you can debug beyond your own code and understand how the tooling finds source.
Green flags
Red flags
Orders.Api.dll is loaded by the runtime
|
v
debugger reads the DLL's debug directory: PDB id (GUID + stamp) and file name
|
v
search for a PDB with the SAME id, in order:
1. embedded inside the DLL (<DebugType>embedded</DebugType>)
2. next to the DLL
3. local symbol cache
4. symbol servers: Microsoft, NuGet.org, your Azure Artifacts feed
|
+-- not found or id mismatch --> "symbols not loaded": no source, no breakpoints
|
v
PDB gives: sequence points + local names + Source Link JSON
| {"documents": {"/_/*": "https://raw.githubusercontent.com/org/repo/<commit>/*"}}
v
debugger maps /_/src/Abn/AbnValidator.cs to that URL at that exact commit
|
v
downloads the file (once, then cached) --> shows source, binds breakpoints
The id match is strict. A PDB from a different build of the same code won't load, which protects you from seeing source that doesn't match the running code.
| Content | Used for |
|---|---|
| Sequence points | Map IL offsets to file, line and column for breakpoints and stepping |
| Local variable and constant names, scopes | Locals and Watch windows |
| Document table with checksums | Verify the source file matches what was compiled |
| Source Link JSON | Where to download each document |
| Embedded source (optional) | Show source without any download |
| Async and iterator method info | Stepping through state machines |
The .NET SDK produces portable PDBs by default on every OS. The older Windows-only PDB format is only needed for a few legacy tools.
Visual Studio treats a module as user code when:
[DebuggerNonUserCode]).| With Just My Code on | With it off |
|---|---|
| Step Into skips framework and package code | Step Into enters any code with symbols |
Call Stack collapses other code into [External Code] |
Every frame is shown |
| Exceptions break where they leave framework code and enter yours | Exceptions break where thrown, even inside libraries |
| Breakpoints in library code may not bind | Breakpoints bind if symbols load |
The default is on because most debugging is about your own code.
| Step | Visual Studio | VS Code (C# extension) | Rider |
|---|---|---|---|
| 1. Turn off Just My Code | Tools > Options > Debugging > General > untick Enable Just My Code | "justMyCode": false in launch.json |
Debugger settings > enable external source debugging |
| 2. Enable symbol servers | Debugging > Symbols > tick Microsoft Symbol Servers and NuGet.org Symbol Server | "symbolOptions" with both servers |
Uses symbol servers automatically |
| 3. Source | Enable Source Link support (on by default) | Source Link supported | Source Link, or decompiled source |
| 4. Optional: make framework code easier to inspect | Suppress JIT optimization on module load | "suppressJITOptimizations": true |
Similar option in debugger settings |
| 5. Restart debugging |
The first session is slow while symbols download; they are cached afterwards. In Visual Studio, "Load only specified modules" avoids downloading symbols for everything.
When no symbols exist at all, Visual Studio and Rider can decompile the assembly so you can still read and step through it, with generated names instead of the original locals.
Since the .NET 8 SDK, Source Link support for GitHub, Azure Repos, GitLab and Bitbucket is built in, so a library mostly needs publishing properties:
| Property | Effect |
|---|---|
IncludeSymbols + SymbolPackageFormat=snupkg |
Produces a .snupkg symbol package alongside the .nupkg |
PublishRepositoryUrl |
Records the repository URL in the package and PDB |
EmbedUntrackedSources |
Embeds files not in git, such as source generator output |
ContinuousIntegrationBuild (on CI only) |
Deterministic, path-mapped build: local paths become /_/ |
DebugType=embedded (optional) |
PDB inside the DLL; no separate symbol package needed |
Pushing the .nupkg to NuGet.org or Azure Artifacts pushes the .snupkg next to it, and the feed serves it as a symbol server.
Without ContinuousIntegrationBuild, a PDB contains paths such as D:\a\1\s\src\Abn\AbnValidator.cs from the build agent. With it, the compiler maps the repository root to /_/, so:
Set it only on CI. Locally you want real paths so the IDE opens your own files.
dotnet-symbol tool downloads matching symbols for the modules in the dump, for example dotnet-symbol -o ./symbols ./core_20261022. Then point dotnet-dump or Visual Studio at that folder.Portable PDBs, Source Link and .snupkg are stable. Watch the .NET 11 SDK release notes for changes to default packaging or Source Link providers.
[External Code].Microsoft.AspNetCore.Routing.dll..csproj and open the .snupkg as a zip to see the PDB.ContinuousIntegrationBuild=true and compare the DLL hashes: they match.Library .csproj set up for symbols and Source Link:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<PackageId>Contoso.Abn</PackageId>
<Version>1.4.0</Version>
<!-- Symbols published as a separate .snupkg -->
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- Source Link: record the repo URL and embed files not in source control -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<!-- Deterministic, path-mapped builds on CI (GitHub Actions sets GITHUB_ACTIONS=true) -->
<ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
</Project>
Pack and push both packages:
dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
VS Code launch.json settings to step into external code:
{
"name": "Api (external code)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Api/bin/Debug/net10.0/Api.dll",
"justMyCode": false,
"suppressJITOptimizations": true,
"symbolOptions": {
"searchMicrosoftSymbolServer": true,
"searchNuGetOrgSymbolServer": true
}
}
Mistake: turning off Just My Code permanently. Everyday stepping wanders into framework code, the Call Stack fills with external frames, and exceptions break inside libraries that handle them. Instead: turn it off only for the session where you need to read library code, then turn it back on.
Mistake: expecting optimised framework code to be fully inspectable.
Framework assemblies are Release builds, so some locals show as unavailable and stepping can jump.
Instead: enable Suppress JIT optimization on module load (or suppressJITOptimizations in VS Code) while you investigate, and accept it changes performance.
Mistake: mixing a DLL from one build with a PDB from another. The ids don't match, so the debugger refuses the PDB and breakpoints stay hollow. Instead: keep each DLL and its PDB together from the same build, or embed the PDB in the DLL.
Mistake: configuring only one IDE.
Visual Studio keeps these settings under Tools > Options > Debugging; VS Code needs justMyCode and symbolOptions in launch.json. Changing one doesn't change the other.
Instead: set them in each tool your team uses, and commit a shared launch.json configuration for external code debugging.
Mistake: loading symbols for every module on a slow connection. Every session waits on hundreds of downloads. Instead: use "Load only specified modules", or load symbols on demand from the Modules window.
Mistake: publishing a library without symbols or Source Link.
Consumers can't step into it, so they clone the wrong version or guess.
Instead: publish a .snupkg, set PublishRepositoryUrl and EmbedUntrackedSources, and build with ContinuousIntegrationBuild on CI.
Mistake: setting ContinuousIntegrationBuild for local builds.
Paths in the PDB become /_/..., so the IDE can't open your local files when you debug.
Instead: set it conditionally, only when a CI environment variable is present.
Mistake: adding untrusted symbol servers. The debugger downloads and opens whatever they serve. Instead: use the Microsoft and NuGet.org servers and your organisation's own feed only.
Mistake: deleting PDBs from deployments "to protect the code". Production stack traces lose line numbers and dumps become much harder to read, while the DLL can be decompiled anyway. Instead: ship PDBs with the app or keep them in a symbol store for every release, and keep secrets out of source.
Seniors treat debuggability as a feature of what they ship. For internal libraries they make symbols, Source Link and deterministic builds part of the package template, so anyone can step into the exact version they use instead of guessing. For applications they keep PDBs with the deployment (or in a symbol server) so stack traces have line numbers and dumps are readable. And they turn Just My Code off only for the session where they need to read library code, because the default is optimised for the common case.
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.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
Last updated 1 Nov 2026.