How to distinguish debug from release in Rust? Let's break down cfg with real examples
\n\nHave you ever written code in Rust and thought: "I wish I could add detailed logging in debug mode and remove all the extras in release"? Or maybe you wanted to include assertions only during development but disable them in the final build? If so — you've come to the right place. Today we'll explore how to use the magic of cfg to control code behavior depending on the build type. This isn't just dry theory — we'll go from a simple flag to a production-ready solution.
This topic isn't pulled out of thin air: on Stack Overflow, the question "How to check release / debug builds using cfg in Rust?" has gathered thousands of views. That means many people need this. And we'll break it down thoroughly.
\n\nWhy is this important? Two worlds — two approaches
\n\nIn Rust, there are two main build profiles: debug (with the --debug flag or simply cargo build) and release (cargo build --release). They differ not only in optimizations but also in the behavior of some built-in checks.
For example:
\n- \n
- debug: slow, but with array bounds checks, debug symbols, and assertions. \n
- release: fast, but without extra checks — all for performance. \n
What if you need the debug version to log every action, while the release version only logs critical errors? Or if you want a test endpoint to be accessible only locally? That's exactly what cfg is for.
Main tools: cfg!() and #[cfg()]
\n\nIn Rust, there are two ways to check the build type:
\n- \n
- The
cfg!()macro — executed at compile time, returns abool. Used inside functions. \n - The
#[cfg()]attribute — completely includes or excludes a block of code from compilation. \n
Both use the same keys. The most popular one for our task is debug_assertions. It is active only in debug builds. There is also its inverse — not(debug_assertions) for release.
Example 1: Simple check inside a function
\n\nLet's start with the cfg!() macro. Suppose we have a function that calculates something complex. In debug, we want to validate the input data:
fn calculate_something(x: i32) -> i32 {\n // Check only in debug builds\n if cfg!(debug_assertions) {\n println!("[DEBUG] calculate_something called with x = {}", x);\n assert!(x > 0, "x must be positive!");\n }\n\n // Main logic\n x * 2\n}\n\nfn main() {\n let result = calculate_something(10);\n println!("Result: {}", result);\n}\n\n\nWhat will happen?
\n- \n
- With
cargo build(debug) — you'll see the log, and if x ≤ 0, the program will panic with an assertion. \n - With
cargo build --release— the line withcfg!(debug_assertions)will evaluate tofalse, and the compiler will eliminate this block as dead code. No overhead! \n
Example 2: Complete code exclusion with #[cfg()]
\n\nSometimes you need not just to skip execution, but to completely remove a function or module from the binary. For this, we use the attribute:
\n\n// This module will be compiled ONLY in debug builds\n#[cfg(debug_assertions)]\nmod debug_helpers {\n pub fn print_state(state: &str) {\n println!("[DEBUG] Current state: {}", state);\n }\n\n pub fn validate_data(data: &[u8]) -> bool {\n // Expensive check, not needed in release\n data.len() < 1000\n }\n}\n\n// This module is only for release\n#[cfg(not(debug_assertions))]\nmod release_helpers {\n pub fn print_state(_state: &str) {\n // Do nothing in release — save CPU\n }\n\n pub fn validate_data(_data: &[u8]) -