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 |
|---|---|
|
The Debug configuration ( |
|
Both configurations by default, which is what keeps |
|
Targeting .NET 10; the |
|
Targeting .NET Standard. |
|
A platform-specific target framework such as |
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)
.cs file that runs directly with `dotnet runapp.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.Webfor a minimal API in one file). -
#:package— a NuGetPackageReference, inname@versionform. -
#: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.
See Build and Tooling.
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
#ifregions 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#ifaround calls, so the code stays compiled and refactorable. -
Scope
#pragma warning disableto the smallest possible region, always withrestoreand a reason. -
Set
Nullable,TreatWarningsAsErrorsandGenerateDocumentationFilein aDirectory.Build.propsso every project in the repository agrees. -
Pin
LangVersionin libraries; usepreviewonly deliberately, and never in a shipped release. -
Reserve
#regionfor generated code, and let the type’s size tell you when it needs splitting instead.
See Also
-
Build and Tooling — the
dotnetCLI, MSBuild and file-based apps. -
Nullable Types and Null Safety — the context
#nullableswitches. -
Attributes and Reflection —
[Conditional]among the other compiler-recognised attributes. -
Namespaces, Assemblies and Projects — multi-targeting, which is where conditional compilation earns its keep.
-
C Reference — the macro preprocessor C# deliberately does not have.