C Conditional Compilation

Conditional compilation is a way to tell the C preprocessor to include or exclude parts of your source code before the compiler ever sees them. Using directives like #if, #ifdef, and #ifndef, you can write one source file that compiles differently depending on which macros are defined — enabling debug logging, targeting different platforms, switching algorithms, or preventing a header from being included twice. It is one of the most powerful and most misused features of the C language.

Overview: How Conditional Compilation Works

C compilation happens in phases, and the preprocessor runs first, before parsing, type checking, or code generation. The preprocessor scans your source text line by line looking for lines that begin with #. When it finds a conditional directive such as #if, it evaluates a constant expression using only integer arithmetic (no variables, no function calls, no floating point). Based on whether that expression is true (non-zero) or false (zero), it either keeps the following lines of source text or deletes them entirely before the real compiler ever runs.

This is a crucial distinction from a runtime if statement. A runtime if (DEBUG) { ... } still compiles both branches into the program — the condition is checked while the program runs, and the compiler may or may not optimize away a dead branch. A preprocessor #if DEBUG ... #endif makes the decision at compile time: if the condition is false, the code between #if and #endif is stripped from the token stream and never becomes part of the compiled program at all. It cannot cause a runtime cost, and it does not even need to be syntactically valid C for the branch that gets removed (though it must still consist of matched preprocessor tokens).

This makes conditional compilation ideal for things that must be decided before the program exists as a binary: which operating system you are targeting, whether this is a debug or release build, which optional features were licensed or configured in, or whether a header file has already been processed once in this translation unit.

Syntax

#if constant-expression
    // code included if expression is nonzero (true)
#elif another-constant-expression
    // included if the previous conditions were false and this one is true
#else
    // included if none of the above were true
#endif

#ifdef NAME
    // included if NAME is #defined (equivalent to #if defined(NAME))
#endif

#ifndef NAME
    // included if NAME is NOT #defined (equivalent to #if !defined(NAME))
#endif
  • #if — begins a block whose inclusion depends on a constant integer expression.
  • #elif — else if; you may chain as many as you like.
  • #else — the fallback branch, optional, at most one per #if chain.
  • #endif — required to close every #if/#ifdef/#ifndef block.
  • defined(NAME) — a special operator usable inside #if/#elif that evaluates to 1 if NAME is currently defined (even as empty), otherwise 0.
  • #ifdef NAME / #ifndef NAME — shorthand for testing whether a macro is defined, without needing defined().
  • #undef NAME — removes a macro definition, often used before redefining a flag.
  • #error message — forces a compilation failure with the given message, useful for catching invalid configurations early.

Inside a #if expression you can use integer literals, the usual C operators (&&, ||, !, comparison operators, bitwise operators), and macros that expand to integer constants. An undefined identifier used directly in a #if expression (not inside defined()) is treated as 0 — a common source of bugs, covered below.

Examples

Example 1: A debug-only trace macro

#include <stdio.h>

#define DEBUG 1

int compute_sum(int a, int b) {
    int result = a + b;
#if DEBUG
    printf("DEBUG: compute_sum(%d, %d) = %d\n", a, b, result);
#endif
    return result;
}

int main(void) {
    int total = compute_sum(3, 4);
    printf("Total: %d\n", total);
    return 0;
}

Output:

DEBUG: compute_sum(3, 4) = 7
Total: 7

Because DEBUG is defined as 1, the #if DEBUG block is kept and the printf trace line becomes part of the compiled program. If you changed the definition to #define DEBUG 0, the preprocessor would delete that entire printf line before compilation, and it would never appear in the binary — not even as dead code. In real projects, DEBUG is usually supplied on the command line with a flag like -DDEBUG=1 instead of being hardcoded, so the same source file can produce both debug and release builds without editing it.

Example 2: Selecting a configuration with #if/#elif/#else

#include <stdio.h>

#define OPTIMIZATION_LEVEL 2

#if OPTIMIZATION_LEVEL == 0
    #define BUFFER_SIZE 64
    #define MODE_NAME "debug"
#elif OPTIMIZATION_LEVEL == 1
    #define BUFFER_SIZE 256
    #define MODE_NAME "balanced"
#elif OPTIMIZATION_LEVEL >= 2
    #define BUFFER_SIZE 1024
    #define MODE_NAME "performance"
#else
    #error "OPTIMIZATION_LEVEL must be defined"
#endif

int main(void) {
    char buffer[BUFFER_SIZE];
    printf("Mode: %s\n", MODE_NAME);
    printf("Buffer size: %d bytes\n", (int)sizeof(buffer));
    return 0;
}

Output:

Mode: performance
Buffer size: 1024 bytes

Here the preprocessor walks the #if/#elif chain top to bottom, testing each condition in order and stopping at the first one that is true. Since OPTIMIZATION_LEVEL is 2, the third branch (>= 2) wins, defining BUFFER_SIZE as 1024 and MODE_NAME as "performance". Only one of the three #define pairs ever reaches the compiler; the other two are discarded text. Notice the trailing #error branch — it exists purely as a safety net and never fires here because one of the earlier conditions already matched.

Example 3: Combining conditions with logical operators

#include <stdio.h>

#define FEATURE_NETWORK 1
#define FEATURE_ENCRYPTION 1
#define FEATURE_LOGGING 0

int main(void) {
#if FEATURE_NETWORK && FEATURE_ENCRYPTION
    printf("Secure networking enabled.\n");
#elif FEATURE_NETWORK && !FEATURE_ENCRYPTION
    printf("Warning: networking enabled without encryption.\n");
#else
    printf("Networking disabled.\n");
#endif

#if !FEATURE_LOGGING
    printf("Logging is off.\n");
#endif

#if defined(FEATURE_NETWORK) && defined(FEATURE_LOGGING)
    printf("Both FEATURE_NETWORK and FEATURE_LOGGING are defined (values: %d, %d).\n", FEATURE_NETWORK, FEATURE_LOGGING);
#endif

    return 0;
}

Output:

Secure networking enabled.
Logging is off.
Both FEATURE_NETWORK and FEATURE_LOGGING are defined (values: 1, 0).

This example shows that #if expressions accept the full range of C’s logical and comparison operators. It also highlights the difference between testing a macro’s value (FEATURE_LOGGING, which is 0) and testing whether it is defined at all (defined(FEATURE_LOGGING), which is true even though the value is 0). Mixing these two ideas up is one of the most common mistakes with conditional compilation, discussed next.

Under the Hood: Step by Step

When you compile a file containing conditional directives, this is roughly what happens:

  1. The preprocessor tokenizes the source file line by line, watching for lines starting with #.
  2. Ordinary macros (#define) are expanded throughout the surviving text, including inside #if expressions themselves (except for the operand of defined, which is never macro-expanded).
  3. When a #if, #ifdef, or #ifndef is reached, its controlling expression is evaluated using integer arithmetic. Any identifier that is not a defined macro and is not the target of defined() is replaced with 0 per the C standard.
  4. If the condition is true, the preprocessor keeps scanning normally until the matching #elif, #else, or #endif, and skips the rest of the chain’s branches entirely (their text is discarded, not merely hidden).
  5. If the condition is false, the preprocessor scans forward looking only for its own #elif/#else/#endif and nested #if blocks (to keep nesting balanced), discarding everything else without even checking whether it is syntactically valid C.
  6. Only after all conditional blocks are resolved does the resulting flat token stream get handed to the actual C compiler for parsing, type-checking, and code generation.

Because step 6 happens after all of this, dead branches can contain code that would not even compile on this platform (for example, Windows-only API calls inside an #ifdef _WIN32 block) — the compiler simply never sees them when the condition is false.

Common Mistakes

Mistake 1: Testing an undefined macro with #if instead of #ifdef

#if FEATURE_X
    do_feature_x();
#endif

If FEATURE_X was never #defined anywhere, this does not raise a compile error — the undefined identifier silently evaluates to 0, so the block is quietly skipped. This can hide typos (FEATURE_X vs. FEATURE_x) for a long time. Prefer being explicit about which check you mean:

#if defined(FEATURE_X) && FEATURE_X
    do_feature_x();
#endif

This only enables the code if FEATURE_X is both defined and nonzero, catching the case where it was defined as 0 to mean disabled.

Mistake 2: Forgetting or mismatching #endif

Every #if, #ifdef, or #ifndef needs exactly one matching #endif. Omitting one, or deleting one during editing, causes every directive after it to nest incorrectly, often producing confusing errors far away from the real problem (or, worse, silently swallowing large sections of a file). Indent nested preprocessor directives consistently and keep #if/#endif pairs visually close, or add a trailing comment naming what each #endif closes, e.g. #endif /* FEATURE_X */.

Mistake 3: Missing header guards

#ifndef MYMATH_H
#define MYMATH_H

int add(int a, int b);
int subtract(int a, int b);

#endif /* MYMATH_H */

Every header file you write should be wrapped in an #ifndef/#define/#endif guard like this. Without it, if the header is ever #included twice in the same translation unit (directly or indirectly through other headers), every declaration inside is duplicated, and the compiler reports “redefinition” errors. The first inclusion defines the guard macro; every later inclusion in the same file sees the macro already defined and skips the whole header body.

Best Practices

  • Always wrap header files in include guards (#ifndef/#define/#endif) or use the widely supported #pragma once as a supplement, not a replacement, if your compilers all support it.
  • Prefer #ifdef/#ifndef when you only care whether a macro exists, and #if defined(X) && X when the macro’s value also matters.
  • Comment every closing #endif with the condition it closes, especially in deeply nested code.
  • Keep conditional blocks small and localized; large chunks of platform-specific code are easier to maintain in separate files selected by the build system than buried in dozens of #ifdef blocks.
  • Use #error to fail fast on invalid or missing configuration macros rather than letting the build silently produce a broken binary.
  • Define build-time flags like DEBUG or NDEBUG via compiler flags (-DDEBUG) rather than hardcoding them in source, so the same file can produce multiple build variants.
  • Avoid conditional compilation for logic that could simply be a runtime if statement; reserve it for things that truly must be decided before the program is built, such as platform or feature selection.

Practice Exercises

  • Exercise 1: Write a program with a macro LOG_LEVEL (0 = none, 1 = errors only, 2 = errors and warnings, 3 = everything). Use #if/#elif/#else to print different sets of messages depending on the value.
  • Exercise 2: Create a header file with a proper include guard that declares a square(int) function, and a source file that defines and uses it. Verify that including the header twice in the same file does not cause a redefinition error.
  • Exercise 3: Write a small program that uses #ifndef to provide a default value for a macro MAX_USERS only if it wasn’t already defined (hint: #ifndef MAX_USERS followed by #define MAX_USERS 100, then #endif), and print the resulting value.

Summary

  • Conditional compilation directives (#if, #ifdef, #ifndef, #elif, #else, #endif) run entirely in the preprocessor, before real compilation begins.
  • #if evaluates a constant integer expression; undefined identifiers used directly (not via defined()) are treated as 0.
  • defined(NAME), or the shorthand #ifdef/#ifndef, tests whether a macro exists, independent of its value.
  • Discarded branches are removed from the token stream entirely and never reach the compiler, so they can contain platform-specific code that wouldn’t even compile elsewhere.
  • Every conditional block must be closed with #endif; missing or mismatched ones cause confusing, far-away errors.
  • Header guards using #ifndef/#define/#endif are the standard way to prevent multiple-inclusion errors.
  • #error lets you fail the build early with a clear message when required configuration is missing or invalid.