Build configurations: what Debug and Release change (optimisation, DEBUG/TRACE constants, debugging information, JIT behaviour), which commands default to which, custom configurations, and why behaviour can differ.
A configuration is a named set of build settings. Debug turns optimisation off and defines the DEBUG and TRACE symbols, so the debugger can show every variable and step line by line. Release turns optimisation on and defines RELEASE and TRACE instead of DEBUG. Both produce PDBs by default. Behaviour can differ for three reasons. Code under #if DEBUG, and calls such as Debug.Assert, are removed from Release. The optimising compiler and JIT inline and reorder code and end variable lifetimes earlier, which changes timing and when objects are garbage collected. And race conditions hidden by slow Debug code start to show. dotnet build defaults to Debug, while dotnet publish and dotnet pack default to Release, so you should test the build you ship.
"A build configuration is just a named set of MSBuild properties, and the SDK gives you Debug and Release. Debug turns optimisation off and defines
DEBUGandTRACE, so the debugger can show every variable and step line by line. Release setsOptimizeto true and definesRELEASEandTRACEinstead. Both produce portable PDBs by default, so a Release build can still be debugged, just less precisely.Behaviour differs for a few reasons. Code under
#if DEBUG, and calls to[Conditional("DEBUG")]methods likeDebug.Assert, are compiled out of Release, including the evaluation of their arguments. The compiler and especially the JIT inline and reorder code and shorten variable lifetimes, so an object can be garbage collected earlier, like a timer held only in a local that stops firing. And faster code changes timing, so races that Debug hid start to show, which is usually how you end up chasing a bug that only appears in Release.The trap is the defaults:
dotnet build,runandtestuse Debug, whiledotnet publishandpackuse Release since .NET 8. So in CI I build once in Release, then test and publish that same build with--no-build. The mistake I see is benchmarking Debug builds, which gives meaningless numbers. I also don't create Staging or Production configurations; I ship one Release build, vary behaviour by environment at run time, keep the Release PDBs, and treat any Debug-versus-Release difference as a real bug rather than something to hide with#if."
| Setting | Debug | Release |
|---|---|---|
Optimize |
false |
true |
DefineConstants |
DEBUG;TRACE |
RELEASE;TRACE |
DebugType |
portable |
portable |
| Output folder | bin/Debug/net10.0/ |
bin/Release/net10.0/ |
DebuggableAttribute |
Disables JIT optimisation | Allows full JIT optimisation |
So a Release build can be debugged, and its PDB lets you read stack traces with line numbers, but local variables may show as "optimised away" and stepping can jump around.
dotnet build, dotnet run, dotnet test: Debug unless you pass -c Release.dotnet publish, dotnet pack: Release by default since .NET 8 (still true in .NET 10).#if DEBUG blocks and calls to methods marked [Conditional("DEBUG")] (such as Debug.Assert and Debug.WriteLine) are compiled out of Release, including the evaluation of their arguments.volatile flag changing can break.System.Threading.Timer held only in a local variable can stop firing in Release.You can add configurations (<Configurations>Debug;Release;Profiling</Configurations>) for genuinely different builds, for example extra instrumentation. Don't create Staging or Production configurations for environment settings: that means building different binaries per environment. Build once in Release and change behaviour with configuration at run time (appsettings.{Environment}.json, environment variables, Azure App Configuration).
A background service in Release stopped processing work after a few minutes, while Debug runs on a laptop were fine. The service created a Timer in a local variable inside StartAsync and never stored it. In Release, the JIT considered the timer unreachable after the constructor and the GC collected it. Storing the timer in a field, and disposing it in StopAsync, fixed it.
#if DEBUG / #if !DEBUG; you'll ship the wrong one eventually.Checks fundamental understanding of build configurations.
Green flags: names the property differences; explains removed code, optimisation and lifetime effects; knows command defaults; tests and ships Release.
Red flags: "Release is just faster"; benchmarks Debug; uses build configurations per environment.
Debug and Release are names. The SDK attaches default property values to each, and -c Release simply sets the global property Configuration=Release.
| Property | Debug | Release | What it controls |
|---|---|---|---|
Optimize |
false |
true |
C# compiler optimisation (/optimize) |
DefineConstants |
DEBUG;TRACE |
RELEASE;TRACE |
Symbols for #if and [Conditional] |
DebugSymbols / DebugType |
true / portable |
true / portable |
Both produce a .pdb |
DebuggableAttribute |
Disables JIT optimisation | Allows full JIT optimisation | Emitted into the assembly by the compiler |
| Output folder | bin/Debug/<tfm>/ |
bin/Release/<tfm>/ |
Separate outputs, so switching doesn't overwrite |
The RELEASE symbol comes from the SDK's implicit configuration define: it adds the configuration name in upper case, so a custom Profiling configuration gets PROFILING. DisableImplicitConfigurationDefines=true turns that off. Target framework symbols (NET, NET10_0, NET10_0_OR_GREATER) are defined in both.
Check the real values for your project rather than trusting memory:
dotnet msbuild -getProperty:Optimize -getProperty:DefineConstants -getProperty:DebugType -p:Configuration=Release
C# source
|
+----------+-----------+
| Debug | Release
v v
csc /optimize- csc /optimize+
/define:DEBUG;TRACE /define:RELEASE;TRACE
extra nops for #if DEBUG code removed
breakpoints, locals Debug.Assert(...) calls removed,
kept for the debugger including their arguments
| |
IL + portable PDB IL + portable PDB
[Debuggable(DisableOptimizations)]
| |
v v
JIT: minimal opts JIT: tiered compilation (tier 0 -> tier 1),
locals live to end inlining, loop hoisting, dynamic PGO,
of method shorter object lifetimes
Two separate layers optimise: the C# compiler (modestly) and the JIT (much more). Most behaviour differences come from the JIT, which is why a Release build run under a debugger that suppresses JIT optimisation can hide the bug you're chasing.
| Cause | Example | Why it happens |
|---|---|---|
| Removed code | Debug.Assert(Init() > 0) |
[Conditional("DEBUG")] removes the call and the evaluation of Init() in Release |
| Debug-only branches | #if DEBUG seeding, logging or validation |
The code doesn't exist in the Release binary |
| Object lifetime | A Timer held only in a local stops firing |
In optimised code the JIT reports the local dead after its last use, so the GC can collect the object |
| Memory ordering and hoisting | A loop polling a non-volatile bool never exits |
The JIT may read the field once and keep it in a register |
| Timing | A race appears only in production | Faster code changes the interleaving of threads |
The second-order effect matters too: [Conditional] is decided by the caller's compilation symbols. NuGet packages are built in Release, so their Debug.Assert calls never run in your app, whichever configuration you build.
| Build configuration | Environment | |
|---|---|---|
| Set by | -c Debug / -c Release at build time |
DOTNET_ENVIRONMENT / ASPNETCORE_ENVIRONMENT at run time |
| Changes | Compiled code and optimisation | Which appsettings.{Environment}.json loads, developer exception page, logging levels |
| Example | Release binary | Running as Development on a laptop, Production in Azure |
A Release binary can run as Development, and a Debug binary can run as Production. Use the environment (and configuration providers such as environment variables, Key Vault or Azure App Configuration) for anything that differs between staging and production; use build configurations only for genuinely different builds.
| Command | Default configuration |
|---|---|
dotnet build, dotnet run, dotnet test |
Debug |
dotnet publish, dotnet pack |
Release (since .NET 8, still true in .NET 10) |
| Visual Studio / Rider | Whatever is selected in the toolbar |
The mismatch is the trap: a pipeline that runs dotnet test without -c Release and then dotnet publish tests one program and ships another.
dotnet-dump dumps from the real environment, and small reproductions run with -c Release.<PropertyGroup>
<Configurations>Debug;Release;Profiling</Configurations>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Profiling'">
<Optimize>true</Optimize>
<DebugType>portable</DebugType>
</PropertyGroup>
A custom configuration starts with no SDK defaults beyond the general ones, so set Optimize and anything else explicitly. Good uses: an instrumented build for profiling, or a build with extra diagnostics for a specific investigation. Bad uses: Staging and Production, which create different binaries per environment.
#if DEBUG / #if !DEBUG; one wrong flag ships the bypass.#if DEBUG blocks; they're still in source control, and a Debug build can end up deployed.app.Environment.IsDevelopment()), which is visible in configuration and easier to audit.The configuration model is stable. The JIT gets more aggressive with each release (inlining, PGO, escape analysis), which can expose new Release-only bugs in code that relied on undefined behaviour. Re-run your Release test suite when you trial .NET 11, and keep production on .NET 10 until you've done so.
dotnet run, then with dotnet run -c Release.#if DEBUG line changes and "Side effects ran" drops from 1 to 0.GC.KeepAlive(timer) and repeat.dotnet msbuild -getProperty:DefineConstants -p:Configuration=Release and look for RELEASE.Profiling configuration, build with -c Profiling, and check Optimize and DefineConstants.DOTNET_ENVIRONMENT=Development to see that configuration and environment are independent.using System.Diagnostics;
namespace ConfigDemo;
internal static class Program
{
private static int _sideEffects;
private static void Main()
{
#if DEBUG
Console.WriteLine("Built in Debug");
#else
Console.WriteLine("Built in Release");
#endif
// In Release the whole call, including CountCall(), is removed
Debug.Assert(CountCall() > 0, "never shown in Release");
Console.WriteLine($"Side effects ran: {_sideEffects}");
// A timer only referenced by a local can be collected in Release
var ticks = 0;
var timer = new Timer(_ => Interlocked.Increment(ref ticks), null, 0, 100);
for (var i = 0; i < 30; i++)
{
Thread.Sleep(1000);
GC.Collect();
Console.WriteLine($"ticks: {Volatile.Read(ref ticks)}");
}
// Uncomment to keep the timer alive in every configuration:
// GC.KeepAlive(timer);
}
private static int CountCall() => ++_sideEffects;
}
dotnet run # Debug: "Side effects ran: 1", ticks keep rising
dotnet run -c Release # Release: "Side effects ran: 0", ticks may stop after a GC
Mistake: benchmarking or load-testing Debug builds. Debug code can be several times slower and has different allocation and inlining behaviour, so you optimise the wrong code and set wrong capacity numbers. Instead: measure only Release builds, ideally the exact artefact you deploy, and use BenchmarkDotNet, which checks for optimised builds.
Mistake: putting side effects inside Debug.Assert or other [Conditional] calls.
In Release the whole call disappears, including the evaluation of its arguments, so work that ran in Debug silently stops happening.
Instead: do the work first, store the result, then assert on the variable.
Mistake: creating Staging and Production build configurations.
Each configuration is a different binary, so what you tested in staging is not what reaches production.
Instead: build once in Release and vary behaviour at run time with the environment name, environment variables and configuration providers.
Mistake: confusing the build configuration with the environment.
Teams assume a Debug build means "development settings" or that Release turns off the developer exception page; they're controlled by DOTNET_ENVIRONMENT / ASPNETCORE_ENVIRONMENT, not by -c.
Instead: check app.Environment for run-time behaviour, and set the environment explicitly in every deployment.
Mistake: running tests in Debug and shipping Release.
dotnet test defaults to Debug while dotnet publish defaults to Release, so lifetime bugs, races and debug-only code paths reach production untested.
Instead: run dotnet build -c Release once, then dotnet test -c Release --no-build and dotnet publish -c Release --no-build.
Mistake: throwing away Release PDBs.
Production stack traces lose line numbers and crash dumps become very hard to read.
Instead: keep the PDBs for every Release build in an artefact store or symbol server, or embed them with DebugType=embedded if size allows.
Mistake: assuming Release can't be debugged. Teams rebuild in Debug to investigate, and the bug disappears. Instead: debug the Release build with its PDB, accepting that some locals show as optimised away, and use dumps and logging for Release-only issues.
Mistake: fixing a Release-only bug with #if DEBUG or by turning optimisation off.
The real defect (a race, a lifetime assumption, a missing volatile) stays, and returns under different timing.
Instead: find the root cause: keep objects alive explicitly (fields, GC.KeepAlive), use proper synchronisation, and add a Release test that reproduces it.
Mistake: guarding security logic with #if DEBUG.
Skipping authentication or validation in Debug is one wrong configuration flag away from production.
Instead: keep security code identical in every build, and use the environment and explicit configuration for local conveniences.
Mistake: assuming Release defines no symbols except TRACE.
The SDK also defines RELEASE (the configuration name in upper case), and custom configurations get their own symbol, so #if checks can behave unexpectedly.
Instead: check DefineConstants with dotnet msbuild -getProperty:DefineConstants -p:Configuration=Release, and prefer #if DEBUG / #if !DEBUG over checks on other configuration names.
Debug and Release aren't just speed settings; they're different programs from the runtime's point of view. A senior engineer makes sure the team tests what it ships, keeps symbols for Release builds, and never encodes environment differences in build configurations.
In practice that looks like:
-c Release, and the artefact that passed is the one that's deployed (build once, promote everywhere).#if DEBUG around anything with behaviour, especially security or data handling.When behaviour differs between the two, they treat it as evidence of a real bug (a race, a lifetime assumption, a side effect in debug-only code) rather than a quirk to work around with #if.
First I reproduce it with the same Release build, using dotnet run -c Release or the published artefact, because a Debug build is a different program. Then I check for code that differs between the two: #if DEBUG blocks, Debug.Assert with side effects, and debug-only settings. If nothing differs, I look for things optimisation exposes. A data race or a non-volatile flag that a loop reads once and caches. An object collected early because only a local variable held it, such as a timer. Or plain timing changes. For evidence I use production logs, a dump opened with the matching PDBs, and dotnet-counters. Then I fix the real bug with proper synchronisation, Volatile or Interlocked, or by holding references in fields. Finally I make CI test the Release build.
Conditional compilation decides at compile time which code exists. #if/#else/#endif test symbols. Those come from DefineConstants in the project, #define in a file, or implicit SDK symbols: DEBUG, framework ones like NET10_0_OR_GREATER, and OS ones like WINDOWS for net10.0-windows. The [Conditional("DEBUG")] attribute on a void method is different. The method is always compiled, but calls to it are removed wherever the symbol isn't defined in the calling code, including the evaluation of their arguments. I use these for framework and platform differences, such as multi-targeting. Anything that varies by environment or customer belongs in run-time configuration or feature flags, so one build can be deployed everywhere.
Restore works out the full NuGet package graph, downloads packages to the global packages folder and writes obj/project.assets.json. Build compiles against those assets into bin/<Configuration>/<TFM>. Test runs the test projects against the built assemblies. Publish produces a deployable folder with the app, its dependencies and runtime config, in Release by default since .NET 8. Each later command restores and builds implicitly, so in CI I restore once and pass --no-restore and --no-build. That way every step uses exactly the same outputs and nothing is repeated. The configuration has to match across steps.
Last updated 1 Nov 2026.