Question
How to Check Debug vs Release Builds in Rust with cfg and cfg!
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 totrueorfalsein 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_assertionsis usually enabledcargo build --release->debug_assertionsis 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.
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_modefunction 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:
- The compiler sees
cfg!(debug_assertions). - It checks whether the
debug_assertionsconfiguration is enabled for this build. - 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.
- In a typical debug build, this becomes
- The code behaves like this in a debug build:
fn main() {
if true {
println!("Debug build");
} else {
println!("Release build");
}
}
- 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:
- The compiler checks the
#[cfg(...)]attribute on each function. - It keeps only the function whose condition matches.
- The other function is completely removed from compilation.
- Your final program contains exactly one
log_modedefinition.
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.
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
| Tool | What it does | Best use | Important note |
|---|---|---|---|
#[cfg(debug_assertions)] | Includes code only when the condition matches | Compile out debug-only code | Non-matching code is not compiled |
cfg!(debug_assertions) | Returns true or false as a boolean | Conditional expressions inside functions | Both branches must still be valid |
debug_assert! | Assertion active when debug assertions are enabled | Internal correctness checks | Usually removed or disabled in release-style builds |
| Cargo feature flags | Custom compile-time switches | Optional functionality | Not 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
| Concept | Purpose |
|---|---|
debug_assertions | Detects 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.
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_assertionsis the normal Rust way to distinguish typical debug and release behavior.- In standard Cargo usage:
cargo buildusually enablesdebug_assertionscargo build --releaseusually 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.
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_assertionsis enabled. - Add a debug-only function using
#[cfg(debug_assertions)]. - Add a release-only function using
#[cfg(not(debug_assertions))]. - Use
cfg!(debug_assertions)insidemain. - Run the program with both
cargo runandcargo run --releaseto compare behavior.
Keep learning
Related questions
Absolute Values in Rust: Using the abs() Method
Learn how to calculate absolute values in modern Rust with abs(), handle signed integer overflow, and choose safer alternatives.
Accepting Iterators in Rust Functions with impl Iterator
Learn how to write Rust functions that accept HashMap iterators using generic bounds or impl Iterator, avoiding trait object and lifetime errors.
Access Enum Variant Values in Rust with Pattern Matching
Learn how to extract and print values stored in Rust enum variants using match and if let pattern matching, with practical examples.