Preprocessor Directives and Compilation

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C# has directives, not a preprocessor. There is no separate text-substitution pass as there is in C: no macros, no include, no token pasting. What C has instead is a small set of directives the compiler itself recognises while lexing, which switch regions of source on and off, adjust diagnostics, and annotate the code for tooling. Everything else that a C programmer would reach for a macro to do is done in C# with generics, constants, or a source generator.

For the C model these directives deliberately diverge from, see the C Reference.

Conditional Compilation

#if, #elif, #else and #endif include or exclude regions of source based on conditional compilation symbols — boolean names, with no values:

public static class PlatformNotes
{
    public static string Describe()
    {
#if DEBUG
        string build = "debug";
#else
        string build = "release";
#endif

#if NET10_0_OR_GREATER
        build += " on .NET 10 or newer";
#elif NET8_0_OR_GREATER
        build += " on .NET 8";
#else
        build += " on an older target";
#endif

        return build;
    }
}

The expression accepts symbol names, true, false, the operators !, &&, ||, ==, != and parentheses. Nothing else: there are no numeric comparisons and no arithmetic, because symbols have no values.

Excluded code is not compiled — it is not even fully parsed beyond finding the matching directive — so it cannot produce errors, and it cannot be refactored or analysed by the IDE either. That is the main argument for keeping #if regions small and pushing the difference behind an ordinary abstraction wherever possible.

Defining Symbols

In source, #define and #undef must appear before any code in the file, and they affect only that file:

#define VERBOSE_LOGGING
#undef EXPERIMENTAL

public static class FileScoped
{
    public static string Mode =>
#if VERBOSE_LOGGING
        "verbose";
#else
        "quiet";
#endif
}

In practice, symbols come from the project rather than from source, so that they apply to the whole assembly:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <!-- $(DefineConstants) preserves what the SDK already defined -- never overwrite it. -->
    <DefineConstants>$(DefineConstants);FEATURE_TELEMETRY;VERBOSE_LOGGING</DefineConstants>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <DefineConstants>$(DefineConstants);OPTIMISED</DefineConstants>
  </PropertyGroup>
</Project>

Symbols the SDK Defines

The SDK defines a useful set automatically:

Symbol Defined when

DEBUG

The Debug configuration (<DefineDebug>), which is also what enables Debug.Assert.

TRACE

Both configurations by default, which is what keeps Trace.WriteLine alive in Release.

NET, NET10_0, NET10_0_OR_GREATER

Targeting .NET 10; the _OR_GREATER forms accumulate, so NET8_0_OR_GREATER is also defined.

NETSTANDARD2_0, NETSTANDARD2_0_OR_GREATER

Targeting .NET Standard.

WINDOWS, LINUX, MACOS, ANDROID, IOS (with versioned forms)

A platform-specific target framework such as net10.0-windows.

These matter most in a multi-targeting library, where one source file compiles for several frameworks:

public static class Compatibility
{
    public static int IndexOfNewline(string text)
    {
#if NET8_0_OR_GREATER
        // A newer BCL API, available only on modern targets.
        return text.AsSpan().IndexOfAny('\r', '\n');
#else
        return text.IndexOfAny(new[] { '\r', '\n' });
#endif
    }
}

Prefer a runtime check when one exists and the cost is irrelevant — OperatingSystem.IsWindows() is recognised by the platform-compatibility analyzer and keeps all the code compiled and analysable:

public static class RuntimeChecks
{
    public static string LineEnding() => OperatingSystem.IsWindows() ? "\r\n" : "\n";
}

#region

#region and #endregion mark a collapsible block for the IDE. They have no effect on compilation:

public sealed class Widget
{
    #region Fields

    private int _count;

    #endregion

    #region Public API

    public int Count => _count;

    public void Increment() => _count++;

    #endregion
}

Regions may nest, but must not overlap an #if block’s boundaries. Their reputation is mixed: a type that needs regions to be navigable is usually a type that should be split. Many style guides, including a number of .editorconfig rule sets, discourage them.

#nullable

#nullable controls the nullable reference-type context locally, overriding the project’s <Nullable> setting. The usual use is to opt a legacy file out, or a new file in, while the rest of the assembly migrates:

#nullable enable

public sealed class Modern
{
    public string Required { get; init; } = string.Empty;
    public string? Optional { get; init; }
}

#nullable disable

public sealed class Legacy
{
    // No nullable warnings are produced in this region.
    public string Whatever { get; set; }
}

#nullable restore

The directive takes enable, disable or restore (back to the project setting), optionally narrowed to warnings or annotations:

  • #nullable enable annotations — ? is meaningful, but no warnings are produced.

  • #nullable enable warnings — warnings are produced using whatever annotations are in effect.

#pragma

#pragma warning

Suppresses or restores specific warnings for a region. Always scope it as tightly as possible, and always say why:

public static class Legacy2
{
    [Obsolete("Kept for the 2.x compatibility shim.")]
    public static int Old() => 1;

    public static int Bridge()
    {
#pragma warning disable CS0618 // Type or member is obsolete -- deliberate, this IS the shim
        return Old();
#pragma warning restore CS0618
    }
}

disable/restore with no warning list affects all warnings, which is almost never what you want. #pragma warning restore is what re-enables the warning for the rest of the file — forgetting it silently extends the suppression to everything below.

For a whole-file or whole-project suppression, <NoWarn> in the project file or a [SuppressMessage] attribute on the specific member is usually clearer, since both are visible outside the source.

#pragma checksum

#pragma checksum "file" "{guid}" "hash" records the checksum of a source file in the PDB. It exists for generated code — a Razor view, a generated parser — so a debugger can map generated IL back to the original file and know whether it has changed. Hand-written code never needs it.

#line

#line reassigns the line numbers and file name the compiler reports, so diagnostics from generated code point at the file a human actually wrote:

public static class Generated
{
#line 42 "Template.cshtml"
    public static string Render() => "output";
#line default

    // `#line hidden` hides a region from the debugger's step-through.
#line hidden
    internal static void Plumbing()
    {
    }
#line default
}

C# 10 added the span form — #line (1, 1) - (1, 20) 5 "file.razor" — which maps a precise character range rather than a whole line, giving generated-code diagnostics accurate squiggles in the original file. Like #pragma checksum, this is machinery for source generators, not for application code.

#warning and #error

These emit a diagnostic at compile time:

public static class Guarded
{
#if !NET10_0_OR_GREATER
#warning This library is only tested on .NET 10; older targets are best-effort.
#endif

#if DEBUG && OPTIMISED
#error DEBUG and OPTIMISED must not be defined together.
#endif

    public static int Value => 1;
}

#error fails the build, which makes it the right tool for an unsupported configuration combination — a much better failure than code that compiles and then behaves unexpectedly.

File-Based App Directives (C# 14)

NET 10 and C# 14 introduced file-based apps: a single .cs file that runs directly with `dotnet run

app.cs`, with no project file at all. A small family of #: directives, which are file-based-app directives rather than preprocessor directives, supply what the .csproj otherwise would:

#!/usr/bin/env dotnet

#:sdk Microsoft.NET.Sdk
#:package Humanizer@2.14.1
#:property LangVersion=preview
#:property Nullable=enable

using Humanizer;

Console.WriteLine(DateTime.UtcNow.AddHours(-3).Humanize());
  • #:sdk — the SDK to build against (Microsoft.NET.Sdk.Web for a minimal API in one file).

  • #:package — a NuGet PackageReference, in name@version form.

  • #:property — any MSBuild property, exactly as it would appear in a <PropertyGroup>.

  • ! — a shebang on the first line makes the file directly executable on Unix (chmod +x app.cs; ./app.cs), and the C compiler ignores that line.

All : directives must appear before any code, after which the file is ordinary C — typically a top-level program. dotnet project convert app.cs turns a file-based app into a conventional project once it outgrows one file. This makes C# practical for scripts and one-off tools, a role it previously ceded to other languages.

The [Conditional] Attribute

[Conditional] is the type-safe alternative to wrapping every call in #if. The method is always compiled; its call sites are removed unless the symbol is defined where the caller is compiled:

using System.Diagnostics;

public static class Instrumentation
{
    [Conditional("DEBUG")]
    public static void Verify(bool condition, string message)
    {
        if (!condition)
        {
            throw new InvalidOperationException(message);
        }
    }

    [Conditional("TRACE")]
    [Conditional("DEBUG")]       // several attributes: the call survives if EITHER is defined
    public static void Log(string message) => Console.WriteLine(message);
}

public static class InstrumentationUser
{
    public static void Run(int[] data)
    {
        // In a Release build with no DEBUG symbol, this line -- including Describe(data) -- is gone.
        Instrumentation.Verify(data.Length > 0, Describe(data));
    }

    private static string Describe(int[] data) => $"length {data.Length}";
}

Why this is better than #if around the call: the method body is still compiled and type-checked, the IDE can still refactor it, and the caller reads as ordinary code. The constraints: the method must return void, must not be an override, and must not be an interface implementation — because the decision is made at the call site, and a virtual dispatch has no single call site to remove.

Note the subtlety that trips people up: what matters is whether the symbol is defined in the calling assembly’s compilation, not in the assembly that declares the method.

Compiler Options That Matter

These live in the project file and affect every file in the assembly.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>

    <!-- Language version. Defaults to the latest supported by the SDK; pin it deliberately. -->
    <LangVersion>14.0</LangVersion>

    <!-- Nullable reference types: enable for all new code. -->
    <Nullable>enable</Nullable>

    <!-- Treat warnings as errors in CI; a warning nobody fixes is noise. -->
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsNotAsErrors>CS0618</WarningsNotAsErrors>
    <NoWarn>$(NoWarn);CS1591</NoWarn>

    <!-- Warning level. 10 corresponds to the C# 14 warning wave. -->
    <AnalysisLevel>latest</AnalysisLevel>
    <EnableNETAnalyzers>true</EnableNETAnalyzers>

    <!-- Required for pointers, fixed-size buffers and [SkipLocalsInit]. -->
    <AllowUnsafeBlocks>false</AllowUnsafeBlocks>

    <!-- Reproducible builds: identical inputs produce byte-identical outputs. On by default. -->
    <Deterministic>true</Deterministic>
    <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>

    <!-- Emit the XML documentation file from /// comments. -->
    <GenerateDocumentationFile>true</GenerateDocumentationFile>

    <!-- Implicit global usings, and file-scoped namespace defaults. -->
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

A few of these deserve a note:

LangVersion. latest follows the SDK, preview opts into unreleased features (required for any C# 15 feature on a .NET 11 preview SDK), and a specific number such as 14.0 pins it. Pinning is the honest choice for a library that promises a minimum toolchain. Note that the language version is largely independent of the target framework — but features needing new runtime types (ref fields, static abstract interface members) only work on a framework that has them.

Warning waves. Each C# release introduces new warnings, enabled by a wave number, so that upgrading the compiler never silently fails a build that treats warnings as errors. <WarningLevel> selects the wave (level 9 for the C# 14 wave, and so on); the SDK sets it to match the target framework, which is why upgrading TargetFramework can surface new warnings.

TreatWarningsAsErrors. Worth turning on, with <WarningsNotAsErrors> for the handful you genuinely want to keep as warnings during a migration.

Deterministic is on by default and is what makes two builds of identical source produce identical binaries; ContinuousIntegrationBuild additionally normalises source paths, which is what makes source-link debugging work from a published package.

DocumentationFile/GenerateDocumentationFile turns /// comments into the XML file IDEs and DocFX consume — see Coding Conventions and Documentation.

How the Compiler Is Actually Invoked

csc is the C# compiler, part of the Roslyn compiler platform. You practically never run it directly: MSBuild does, from the Csc task, with the options it computed from the project file, the SDK targets and the NuGet restore output:

# What MSBuild actually runs (abbreviated) -- see it in full with:
dotnet build -v detailed | grep -A40 "Csc"

csc.dll /noconfig /nowarn:1701,1702 /nostdlib+ /deterministic+ \
    /langversion:14.0 /nullable:enable /warnaserror+ \
    /define:TRACE;DEBUG;NET;NET10_0;NET10_0_OR_GREATER \
    /reference:/usr/share/dotnet/packs/…/System.Runtime.dll \
    /debug+ /debugtype:portable /optimize- \
    /out:obj/Debug/net10.0/App.dll \
    Program.cs obj/Debug/net10.0/App.GlobalUsings.g.cs …

Two things are worth taking from that listing. First, the conditional symbols really are just /define: arguments — there is nothing magical about DEBUG. Second, the generated files (GlobalUsings.g.cs, and everything any source generator produced) are passed as ordinary inputs; <EmitCompilerGeneratedFiles>true</…> writes them to disk under obj/ so you can read exactly what a generator produced.

Roslyn is also a library, which is what makes analyzers, code fixes, source generators and the IDE’s own refactorings possible against the same compiler the build uses.

Practical Guidance

  • Keep #if regions small and few; prefer a runtime check, an interface, or separate files per target.

  • Never overwrite $(DefineConstants) — always append to it.

  • Use [Conditional] rather than #if around calls, so the code stays compiled and refactorable.

  • Scope #pragma warning disable to the smallest possible region, always with restore and a reason.

  • Set Nullable, TreatWarningsAsErrors and GenerateDocumentationFile in a Directory.Build.props so every project in the repository agrees.

  • Pin LangVersion in libraries; use preview only deliberately, and never in a shipped release.

  • Reserve #region for generated code, and let the type’s size tell you when it needs splitting instead.

See Also