📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between Debug and Release builds in .NET, and why can code behave differently between them?

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.

Beginner Theory Beginner build-configuration debug-vs-release jit msbuild

🎯 Key points

  • A configuration is a named set of MSBuild properties; the SDK defines Debug and Release
  • Debug: Optimize=false and DefineConstants DEBUG;TRACE; Release: Optimize=true and RELEASE;TRACE (the SDK adds the configuration name as a symbol)
  • Both produce portable PDBs by default, so Release can still be debugged, just less precisely
  • Optimised IL and JIT can inline, reorder and shorten variable lifetimes, which changes timing and garbage collection behaviour
  • Code inside #if DEBUG and calls to [Conditional("DEBUG")] methods such as Debug.Assert disappear in Release
  • dotnet build, run and test default to Debug; dotnet publish and pack default to Release since .NET 8
  • Ship and performance-test Release builds; don't create per-environment build configurations

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 DEBUG and TRACE, so the debugger can show every variable and step line by line. Release sets Optimize to true and defines RELEASE and TRACE instead. 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 like Debug.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, run and test use Debug, while dotnet publish and pack use 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."


What each configuration sets

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.

Defaults per command

  • 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).

Why behaviour differs

  1. Removed code: #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.
  2. Optimisation: the C# compiler and the JIT inline methods, hoist reads out of loops, and reorder memory operations within what the memory model allows. Code that relied on accidental ordering or on a non-volatile flag changing can break.
  3. Lifetimes: in optimised code, the JIT can treat an object as unreachable after its last use, so the GC may collect it earlier than in Debug. A System.Threading.Timer held only in a local variable can stop firing in Release.
  4. Timing: Release is faster, which exposes race conditions that Debug's slowness hid.

Custom configurations

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).

Production example

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.

Best practices, performance and security

  • Always deploy, load-test and benchmark Release builds; Debug numbers are meaningless for performance.
  • Run CI tests in Release so the tested code is the shipped code.
  • Keep PDBs for Release builds (publish them to a symbol server or artefact store) so production crash dumps and stack traces are readable.
  • Never put secrets or security checks only under #if DEBUG / #if !DEBUG; you'll ship the wrong one eventually.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. A configuration is just a set of properties

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

2. Where the differences come from

               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.

3. Four reasons behaviour differs

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.

4. Build configuration is not the environment

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.

5. Defaults per command

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.

6. Debugging Release builds

  • Release PDBs give file names and line numbers in stack traces and dumps; keep them (artefact store or symbol server).
  • Stepping can jump, some locals show as "optimised away", and inlined methods have no frame of their own.
  • Visual Studio's "Suppress JIT optimization on module load" makes Release debuggable but also hides JIT-caused bugs, so turn it off when chasing a Release-only problem.
  • For Release-only bugs, prefer logging, dotnet-dump dumps from the real environment, and small reproductions run with -c Release.

7. Custom configurations

<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.

8. Performance

  • Debug code can be several times slower than Release for CPU-bound work, so Debug benchmarks are meaningless. BenchmarkDotNet refuses to run (or warns) on non-optimised builds.
  • Release startup also benefits from tiered compilation and, if you publish with it, ReadyToRun.
  • Load tests, profiling and performance baselines should always use the same Release artefact you deploy.

9. Security

  • Never guard authentication, authorisation or data handling with #if DEBUG / #if !DEBUG; one wrong flag ships the bypass.
  • Don't leave secrets or test credentials inside #if DEBUG blocks; they're still in source control, and a Debug build can end up deployed.
  • Developer-only behaviour belongs behind the environment check (app.Environment.IsDevelopment()), which is visible in configuration and easier to audit.

10. .NET 11 awareness

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.

11. Try it yourself

  1. Run the code example with dotnet run, then with dotnet run -c Release.
  2. Compare: the #if DEBUG line changes and "Side effects ran" drops from 1 to 0.
  3. Let the timer loop run for 30 seconds in both configurations and compare the tick counts; then uncomment GC.KeepAlive(timer) and repeat.
  4. Run dotnet msbuild -getProperty:DefineConstants -p:Configuration=Release and look for RELEASE.
  5. Add a Profiling configuration, build with -c Profiling, and check Optimize and DefineConstants.
  6. Open the Debug and Release DLLs in ILSpy and compare the IL of the same method.
  7. Run the Release build as DOTNET_ENVIRONMENT=Development to see that configuration and environment are independent.

💻 Code

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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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:

  • CI builds and tests with -c Release, and the artefact that passed is the one that's deployed (build once, promote everywhere).
  • Release PDBs are archived or published to a symbol server next to each build, so dumps and stack traces from production can be read later.
  • Performance tests and BenchmarkDotNet runs use Release only (BenchmarkDotNet warns you if you don't).
  • Code review flags #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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions that Release can behave differently. Why: Tests whether the candidate can debug the consequences of optimisation.
    How to answer

    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.

  2. When: Candidate mentions #if DEBUG or Debug.Assert. Why: Digs into the DEBUG symbol and conditional compilation.
    How to answer

    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.

  3. When: Candidate says CI should test the Release build. Why: Checks that the candidate keeps configuration consistent across CI steps.
    How to answer

    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.

↩ Usually asked after

More in Build System

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me