Portable PDB, SourceLink & Symbol Servers
Key Points
- Portable PDB — the cross-platform .NET symbol format, replacing legacy Windows PDBs. Smaller, faster, and the default since .NET Core.
- Embedded PDBs — pack symbols into the assembly itself (
<DebugType>embedded</DebugType>). Trade-off: larger DLL, simpler distribution. - SourceLink — embeds source-control URLs into PDBs so debuggers can step into NuGet package source code without local clones.
- Symbol servers — host PDBs separately (NuGet.org, internal Azure Artifacts) so consumers download symbols on demand.
- Senior setup: deterministic build + embedded SourceLink + published symbols package (
.snupkg) for libraries; embedded for apps; CI publishes both.
Concepts (deep dive)
PDB formats
| Format | Platform | Default since |
|---|---|---|
| Windows PDB | Windows-only, MSF format | Pre-.NET Core |
| Portable PDB | Cross-platform, ECMA-335 metadata format | .NET Core 1.0 |
| Embedded PDB | Inside the DLL/EXE | .NET Core 2.0 |
<PropertyGroup>
<DebugType>embedded</DebugType> <!-- inside the DLL -->
<!-- or -->
<DebugType>portable</DebugType> <!-- separate .pdb file -->
</PropertyGroup>
Why PDBs matter
- Stack traces — without PDBs, traces show only
Method+0x47; with PDBs, file/line numbers. - Debugger — set breakpoints in source.
- Profilers — sample reports map to functions.
Without symbols, a production crash dump is much harder to diagnose.
SourceLink
<PropertyGroup>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
</ItemGroup>
Effect: build embeds a srcsrv map in the PDB pointing each source file to its raw URL on GitHub (or other host). Visual Studio / Rider / VS Code can fetch source on demand and step into NuGet package code.
Deterministic builds
<PropertyGroup>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
Produces byte-identical assemblies across machines, given the same inputs. Required for reproducible builds and SourceLink path normalization. CI pipelines should set ContinuousIntegrationBuild=true to strip local paths from PDBs.
Symbol packages (.snupkg)
Produces MyLib.1.0.0.nupkg + MyLib.1.0.0.snupkg. Push both:
dotnet nuget push MyLib.1.0.0.nupkg --api-key $KEY --source nuget.org
dotnet nuget push MyLib.1.0.0.snupkg --api-key $KEY --source nuget.org
Consumers' debuggers fetch symbols from https://symbols.nuget.org/download/symbols.
Symbol servers
- NuGet.org symbol server — public, hosts
.snupkg-published symbols. - Microsoft public symbol server —
https://msdl.microsoft.com/download/symbolsfor OS / runtime symbols. - Internal Azure Artifacts / NuGet feeds — host private package symbols.
Configure VS / Rider:
Or nuget.config:
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<symbolServers>
<add key="nuget.org symbols" value="https://symbols.nuget.org/download/symbols" />
</symbolServers>
</configuration>
dotnet symbol tool
Downloads matching PDBs (and runtime symbols, if requested) for a directory of binaries. Used in dump-investigation workflows.
Embedded vs separate PDB trade-off
Embedded:
+ Single artifact, no PDB to ship
- Larger DLL (~20-50% bigger)
- Symbols always present
Separate:
+ Smaller DLL
- Two files; need to deploy/index PDBs
For OSS libraries — embedded is friendlier. For products — separate, ship PDBs to symbol server.
Compiler-emitted info
Modern PDBs include: - File paths and line numbers per IL offset. - Local variable names (debug builds; release strips most). - Source file content (<EmbedAllSources>true</EmbedAllSources>). - Source URL map (SourceLink). - Async state-machine info for friendlier async stack traces.
Crash dumps + symbols
dotnet-dump analyze core.dump
> setsymbolpath SRV*c:\symbols*https://msdl.microsoft.com/download/symbols
> clrstack
Without symbols, frames are unreadable. Always ship/publish symbols for production binaries.
Code: correct vs wrong
❌ Wrong: ship release binary without symbols
Production crashes are unsearchable.
✅ Correct: portable, deterministic, with SourceLink
<PropertyGroup>
<DebugType>embedded</DebugType>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
</ItemGroup>
❌ Wrong: NuGet package without symbols
✅ Correct: include .snupkg
dotnet pack -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg
dotnet nuget push *.snupkg --source nuget.org
Design patterns for this topic
Pattern 1 — Library SDK template
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
<DebugType>embedded</DebugType>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
</ItemGroup>
</Project>
Reusable as a Directory.Build.props snippet.
Pattern 2 — Internal symbol server
Host .snupkg to a private feed (Azure Artifacts, MyGet, JFrog). CI publishes symbols on every release; engineers configure their IDEs to use the internal feed.
Pattern 3 — Production dump → symbol fetch pipeline
- Capture dump (
dotnet-dump collect) on incident. - Upload to incident store.
- CI job runs
dotnet symbolto fetch matching PDBs from internal/public servers. - Engineer opens dump in
dotnet-dump analyzeor VS with full call stacks.
Pattern 4 — EmbedAllSources for self-contained debugging
Pack the source code of every file referenced by the PDB into the PDB. Larger but completely portable — no source-control fetch needed.
Pros & cons / trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Embedded PDB | Single artifact, foolproof | Larger DLL |
| Separate PDB | Smaller DLL | Two files to ship |
| SourceLink only | Tiny PDB, source on demand | Requires source-control access |
EmbedAllSources | Fully self-contained | Largest size |
.snupkg to NuGet | Public step-in support | Two pushes, separate auth |
When to use / when to avoid
- Apps: embedded + deterministic + SourceLink. One artifact, debuggable.
- Libraries:
.snupkgpublished alongside, deterministic, SourceLink to public repo. - Closed-source products: separate PDBs to internal symbol server only.
- Avoid
<DebugType>none</DebugType>in production builds. Always have something.
Interview Q&A
Q1. What's a portable PDB? A. Cross-platform .NET symbol format using ECMA-335 metadata, replacing the Windows-only MSF PDB format. Smaller, faster, and works identically on Linux/macOS/Windows.
Q2. Why use embedded PDBs? A. Single deployment artifact — no separate .pdb file to lose. Trade-off: larger DLL. Common for OSS libraries.
Q3. What does SourceLink do? A. Embeds a map in the PDB linking source-file paths to a public URL (e.g., GitHub raw). Debuggers fetch source on demand, letting you step into NuGet packages without local clones.
Q4. What's a .snupkg? A. NuGet's symbol package format — separate from the main .nupkg. Hosts PDBs and SourceLink data. Pushed alongside the regular package.
Q5. Why deterministic builds matter? A. Same inputs produce byte-identical outputs across machines and times. Enables reproducible builds, supply-chain verification, and SourceLink (which embeds normalized paths).
Q6. How does the NuGet symbol server work? A. Consumers configure their debugger to query https://symbols.nuget.org/download/symbols; the server returns matching PDBs by GUID/Age (PDB ID). Without it, debuggers can't find symbols for NuGet packages.
Q7. What's EmbedAllSources? A. Build option that copies every source file referenced by the PDB into the PDB itself. Makes the PDB fully self-contained — no source-control fetch needed for debugging — at the cost of size.
Q8. Why does CI fail with Source path is not under root on PDB? A. PDBs embed source paths; on different machines those paths differ. Set ContinuousIntegrationBuild=true to normalize paths via SourceLink, restoring determinism.
Gotchas / common mistakes
- Pushing
.nupkgto NuGet but not the.snupkg— consumers can't step in. - Forgetting
ContinuousIntegrationBuild=truein CI — paths leak local user directories. <DebugType>none</DebugType>in production — unstable for crash diagnosis.- Symbols built from a different commit than the binary — lookup fails (PDB GUID mismatch).
- Not including
SourceLinkpackage — PDBs lack source URLs. - Embedded PDBs that bloat AOT-trimmed apps — measure size; consider portable for AOT.
- Symbol server timeout in restricted networks — configure offline cache.