Question

How to Check Debug vs Release Builds in Rust with cfg and cfg!

rustdebuggingrust-cargopreprocessorconditional-compilation

Question

In Rust, how can you detect whether the current build is a debug build or a release build using #[cfg(...)] or the cfg!(...) macro?

In C, this is commonly done with preprocessor checks such as:

#if defined(NDEBUG)
    // release build
#endif

#if defined(DEBUG)
    // debug build
#endif

With Cargo, the rough equivalents are:

cargo build           # debug build
cargo build --release # release build

What is the Rust equivalent of this kind of conditional compilation?

I found that debug_assertions can be checked, but I am unsure whether that is the correct or most general way to distinguish debug and release behavior in Rust.

Short Answer

By the end of this page, you will understand how Rust handles conditional compilation for debug and release builds, when to use #[cfg(debug_assertions)] versus cfg!(debug_assertions), and how these differ from C-style preprocessor checks. You will also see practical examples, common mistakes, and typical usage patterns from real Rust codebases.

Concept

Rust does not have a C-style text preprocessor for #if defined(...) checks. Instead, it uses conditional compilation through the #[cfg(...)] attribute and the cfg!(...) macro.

The key idea is this:

  • #[cfg(...)] includes or excludes code at compile time.
  • cfg!(...) evaluates to true or false in an expression, but both branches must still be valid Rust code.

For distinguishing normal debug builds from optimized release builds, the most common built-in configuration is:

#[cfg(debug_assertions)]

This configuration is enabled when debug assertions are on, which is typically true for cargo build and typically false for cargo build --release.

So in practice:

  • cargo build -> debug_assertions is usually enabled
  • cargo build --release -> debug_assertions is usually disabled

Why this matters:

  • You may want extra logging in debug builds.
  • You may want expensive checks only during development.
  • You may want to compile out development-only code entirely.

However, it is important to understand that debug_assertions is not literally a debug/release flag by name. It reflects whether debug assertions are enabled for the current compilation settings. In standard Cargo workflows, this usually matches debug vs release expectations, which is why it is the idiomatic Rust choice.

Mental Model

Think of #[cfg(...)] as a build-time gate.

If the condition matches, the compiler lets that code through the gate. If it does not match, the compiler acts as if that code was never there.

Think of cfg!(...) as a build-time fact turned into a boolean value.

It answers questions like:

  • "Were debug assertions enabled when this code was compiled?"
  • "Are we compiling for Windows?"

A simple analogy:

  • #[cfg(...)] is like deciding which rooms get built into a house.
  • cfg!(...) is like asking, after the house is built, whether a certain room exists.

This difference matters because code behind #[cfg(...)] can be completely omitted, while code inside an if cfg!(...) { ... } else { ... } must still parse and type-check.

Take Quiz

Syntax and Examples

Rust commonly uses debug_assertions for debug-vs-release conditional behavior.

Using #[cfg(...)]

Use this when you want code to exist only in certain builds.

#[cfg(debug_assertions)]
fn build_mode() {
    println!("Debug build");
}

#[cfg(not(debug_assertions))]
fn build_mode() {
    println!("Release build");
}

fn main() {
    build_mode();
}

Why this works

  • In a debug build, only the first build_mode function is compiled.
  • In a release build, only the second one is compiled.

Using cfg!(...)

Use this when you need a boolean inside an expression.

fn main() {
    if cfg!(debug_assertions) {
        println!("Debug build");
    } else {
        println!("Release build");
    }
}

Important difference

This is evaluated as a compile-time constant, but both branches still need to contain valid Rust code.

A practical example

fn expensive_check(data: &[i32]) {
    #[cfg(debug_assertions)]
    {
        println!("Running extra validation in debug mode...");
        assert!(!data.is_empty(), "data should not be empty");
    }

    println!("Processing {} items", data.len());
}

In this example, the validation block is compiled only when debug_assertions is enabled.

Step by Step Execution

Consider this example:

fn main() {
    if cfg!(debug_assertions) {
        println!("Debug build");
    } else {
        println!("Release build");
    }
}

Here is what happens step by step:

  1. The compiler sees cfg!(debug_assertions).
  2. It checks whether the debug_assertions configuration is enabled for this build.
  3. It replaces that macro result with a constant boolean value.
    • In a typical debug build, this becomes true.
    • In a typical release build, this becomes false.
  4. The code behaves like this in a debug build:
fn main() {
    if true {
        println!("Debug build");
    } else {
        println!("Release build");
    }
}
  1. Or like this in a release build:
fn main() {
    if false {
        println!("Debug build");
    } else {
        println!("Release build");
    }
}

Now compare that with #[cfg(...)]:

#[cfg(debug_assertions)]
fn log_mode() {
    println!("Debug build");
}

#[cfg(not(debug_assertions))]
fn log_mode() {
    println!("Release build");
}

Step by step:

  1. The compiler checks the #[cfg(...)] attribute on each function.
  2. It keeps only the function whose condition matches.
  3. The other function is completely removed from compilation.
  4. Your final program contains exactly one log_mode definition.

Real World Use Cases

Here are common reasons to check debug vs release behavior in Rust:

Extra logging during development

if cfg!(debug_assertions) {
    eprintln!("Loading configuration from test path");
}

Useful when debugging startup behavior without cluttering release output.

Expensive validation checks

#[cfg(debug_assertions)]
fn validate_state(values: &[i32]) {
    assert!(values.windows(2).all(|w| w[0] <= w[1]));
}

This lets you catch logic bugs during development without paying the cost in production.

Debug-only helper functions

#[cfg(debug_assertions)]
fn dump_cache_state() {
    println!("Dumping cache state for inspection...");
}

These helpers can be fully compiled out in release builds.

Testing internal assumptions

Libraries and applications often use debug_assert! or #[cfg(debug_assertions)] to verify assumptions that should never fail if the program is correct.

Take Quiz

Real Codebase Usage

In real Rust projects, developers usually do not check for release mode directly. Instead, they express intent more clearly.

Common patterns

1. debug_assert! for development-time checks

fn divide(a: i32, b: i32) -> i32 {
    debug_assert!(b != 0, "b should not be zero here");
    a / b
}

This is preferred when the goal is to catch programming mistakes during development.

2. #[cfg(debug_assertions)] for debug-only code

#[cfg(debug_assertions)]
println!("Detailed parser state: {:?}", parser);

This is good for diagnostic output or expensive checks.

3. Guarding helper functions or modules

#[cfg(debug_assertions)]
mod debug_tools {
    pub fn report() {
        println!("Debug tools enabled");
    }
}

This keeps development-only utilities out of production binaries.

4. Early validation in development builds

fn process(items: &[i32]) {
    #[cfg(debug_assertions)]
    {
        assert!(!items.is_empty());
    }

    // normal processing
}

This helps catch invalid state early.

Practical advice

If your real intention is one of these:

  • development-only assertions
  • extra logging
  • debugging support
  • expensive safety checks

then debug_assertions is usually the right tool.

If you need custom build modes beyond this, real projects often use:

  • Cargo features
  • environment variables in build scripts
  • profile configuration in Cargo.toml

But for standard debug vs release behavior, debug_assertions is the idiomatic answer.

Common Mistakes

Mistake 1: Thinking Rust has a C-style preprocessor

Broken mental model:

// This is not how Rust works
#if DEBUG
println!("debug");
#endif

Rust does not do source-text preprocessing like C. Use #[cfg(...)] or cfg!(...) instead.

Mistake 2: Using cfg!(...) when code should be excluded entirely

if cfg!(debug_assertions) {
    use_debug_only_api();
} else {
    normal_api();
}

Problem:

  • Both branches must still compile.
  • If use_debug_only_api() is unavailable in release builds, this may fail.

Use #[cfg(...)] when code should not exist at all:

#[cfg(debug_assertions)]
use_debug_only_api();

#[cfg(not(debug_assertions))]
normal_api();

Mistake 3: Assuming debug_assertions is always identical to "not release"

In normal Cargo usage, that is usually true. But technically, debug_assertions means exactly what it says: debug assertions are enabled. Custom profile settings can affect this.

So write code based on intent:

  • If you want debug-only checks, use debug_assertions.
  • If you need a custom build switch, use a feature or another configuration mechanism.

Mistake 4: Overusing build-mode checks

Sometimes beginners write logic like this:

if cfg!(debug_assertions) {
    save_to_database();
}

That can accidentally make your application behave differently in important ways between development and production. Prefer limiting build-mode checks to:

  • logging
  • assertions
  • diagnostics
  • expensive internal validation

Mistake 5: Forgetting not(...)

Broken example:

#[cfg(debug_assertions)]
fn mode() { println!("Debug"); }

#[cfg(debug_assertions)]
fn mode() { println!("Release"); }

Both definitions match in debug builds, causing a duplicate definition error.

Correct version:

#[cfg(debug_assertions)]
fn mode() { println!("Debug"); }

#[cfg(not(debug_assertions))]
fn mode() { println!("Release"); }

Comparisons

ToolWhat it doesBest useImportant note
#[cfg(debug_assertions)]Includes code only when the condition matchesCompile out debug-only codeNon-matching code is not compiled
cfg!(debug_assertions)Returns true or false as a booleanConditional expressions inside functionsBoth branches must still be valid
debug_assert!Assertion active when debug assertions are enabledInternal correctness checksUsually removed or disabled in release-style builds
Cargo feature flagsCustom compile-time switchesOptional functionalityNot the same as debug/release mode

#[cfg] vs cfg!

#[cfg(debug_assertions)]
fn only_in_debug() {}
  • The function may not exist at all in release builds.
if cfg!(debug_assertions) {
    println!("debug");
}
  • The expression is a boolean.
  • The surrounding code must still compile normally.

debug_assertions vs Cargo features

ConceptPurpose
debug_assertionsDetects whether debug assertions are enabled
feature = "..."Enables optional functionality chosen by the developer

Use a feature when you want a named capability like tracing, serde, or experimental-api. Use debug_assertions when you want behavior tied to development-style builds.

Take Quiz

Cheat Sheet

Quick reference

Debug-only compilation

#[cfg(debug_assertions)]
fn debug_only() {
    println!("Only compiled in debug-style builds");
}

Release-only compilation

#[cfg(not(debug_assertions))]
fn release_only() {
    println!("Only compiled when debug assertions are off");
}

Boolean check inside expressions

if cfg!(debug_assertions) {
    println!("Debug build");
}

Debug-only assertion

debug_assert!(value >= 0);

Rules to remember

  • #[cfg(...)] removes code from compilation when the condition does not match.
  • cfg!(...) returns a boolean but does not remove surrounding code from type-checking.
  • debug_assertions is the normal Rust way to distinguish typical debug and release behavior.
  • In standard Cargo usage:
    • cargo build usually enables debug_assertions
    • cargo build --release usually disables them

Common pattern

#[cfg(debug_assertions)]
println!("extra debug info");

Edge case

Custom Cargo profiles can change whether debug assertions are enabled, so debug_assertions is more precise than saying simply "debug build."

FAQ

How do I check for a debug build in Rust?

Use #[cfg(debug_assertions)] or cfg!(debug_assertions). In normal Cargo workflows, this corresponds to typical debug builds.

How do I check for a release build in Rust?

Use #[cfg(not(debug_assertions))] or !cfg!(debug_assertions).

What is the difference between #[cfg] and cfg! in Rust?

#[cfg] conditionally includes or excludes code from compilation. cfg! returns a boolean value inside expressions.

Is debug_assertions the same as cargo build --release?

Not exactly. It indicates whether debug assertions are enabled. In default Cargo profiles, it usually lines up with debug vs release builds.

Should I use build checks for application logic?

Usually no. Restrict them to diagnostics, assertions, logging, or development-only validation.

Can I create my own build flags in Rust?

Yes. Common options include Cargo features, build scripts, and profile configuration.

Why does code inside if cfg!(...) still need to compile?

Because cfg! produces a boolean expression; it does not remove the other branch from parsing or type-checking.

Take Quiz

Mini Project

Description

Build a small Rust program that reports the current build mode and runs extra validation only when debug assertions are enabled. This demonstrates both forms of conditional compilation: #[cfg(...)] for code inclusion and cfg!(...) for boolean checks inside a function.

Goal

Create a program that prints whether it is running in debug or release style, and performs extra checks only in debug-enabled builds.

Requirements

  • Print a message showing whether debug_assertions is enabled.
  • Add a debug-only function using #[cfg(debug_assertions)].
  • Add a release-only function using #[cfg(not(debug_assertions))].
  • Use cfg!(debug_assertions) inside main.
  • Run the program with both cargo run and cargo run --release to compare behavior.
Take Quiz

Keep learning

Related questions

View more