When your test tools shouldn't ship
You are building a CLI tool that parses configuration files. The logic works. You run it, it outputs the right data. Now you want to write tests. You need a crate to generate random configuration strings to fuzz your parser. You add rand to your project. You run cargo build --release. Your binary jumps from 2MB to 18MB. Compilation time triples. You just pulled a testing utility into your production artifact.
That is the problem dev dependencies solve. They let you use crates for testing, benchmarking, and documentation without including them in the final binary. Your release build stays lean. Your users don't download code they never need.
The construction site analogy
Rust separates what your code needs to run from what your code needs to prove it works. Dev dependencies are crates that exist only during development. They vanish when you ship the final binary.
Think of a construction site. You need heavy machinery to build the house. Cranes, excavators, scaffolding. Once the house is done, you do not ship the crane inside the living room. The crane is a dev dependency. The bricks, lumber, and wiring are regular dependencies. They end up in the house. The crane stays on the lot.
Minimal example
Here is how you declare a dev dependency. The [dev-dependencies] section in Cargo.toml lists crates available only for tests and docs.
[package]
name = "config-parser"
version = "0.1.0"
[dependencies]
# serde is needed to parse JSON in production.
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
# rand is only needed to generate test data.
# It will not be compiled into the release binary.
rand = "0.8"
In your code, you gate test logic behind #[cfg(test)]. This attribute tells the compiler to include the module only when running tests.
// src/lib.rs
/// Parses a configuration string into a result.
pub fn parse_config(input: &str) -> Result<String, &str> {
// WHY: Simple validation logic for demonstration.
if input.contains('@') {
Ok(input.trim().to_string())
} else {
Err("Invalid format")
}
}
// WHY: #[cfg(test)] ensures this module is compiled only for `cargo test`.
// Code inside here can use dev dependencies like `rand`.
#[cfg(test)]
mod tests {
use super::*;
// WHY: rand is a dev dependency. It is available here but not in main code.
use rand::Rng;
#[test]
fn test_random_inputs() {
let mut rng = rand::thread_rng();
// WHY: Generate a random string to test edge cases.
let random_val: u32 = rng.gen();
let input = format!("user{}@example.com", random_val);
assert!(parse_config(&input).is_ok());
}
}
Run cargo test. Cargo downloads rand, compiles it, links it to the test binary, and runs the tests. Run cargo build --release. Cargo ignores [dev-dependencies]. The resulting binary has no trace of rand. No code, no symbols, no size overhead.
Keep the crane off the roof. If it does not run the app, it is a dev dependency.
How Cargo handles the split
Cargo builds a dependency graph for each build target. When you run cargo build, Cargo constructs a graph containing only [dependencies] and [build-dependencies]. When you run cargo test, Cargo constructs a different graph. This graph includes [dependencies], [build-dependencies], and [dev-dependencies].
The dev dependencies link only to the test binary, not the library or main binary. This isolation is structural. The compiler literally cannot see dev dependencies when compiling production code. If you try to import a dev dependency in your main code, you get E0432 (use of undeclared crate or module) during a release build. The crate does not exist in that context.
This separation also speeds up release builds. Fewer crates mean less compilation work. If your dev dependencies include heavy testing frameworks, excluding them from release builds can save minutes of compile time.
Doc tests are tests. Dev dependencies are available in doc tests too. When you run cargo test --doc, Cargo compiles the code blocks in your documentation comments. Those code blocks can use dev dependencies. This lets you write rich, runnable examples in your docs without polluting the production dependency graph.
Doc tests and dev dependencies
Documentation examples are a first-class citizen in Rust. You can write code blocks in doc comments that compile and run as tests. Dev dependencies are fully available in these blocks.
/// Parses a config string.
///
/// # Examples
///
/// ```
/// use config_parser::parse_config;
/// // WHY: rand is a dev dependency, so it works in doc tests.
/// use rand::Rng;
///
/// let mut rng = rand::thread_rng();
/// let val: u32 = rng.gen();
/// let input = format!("test{}@example.com", val);
/// assert!(parse_config(&input).is_ok());
/// ```
pub fn parse_config(input: &str) -> Result<String, &str> {
// Implementation...
Ok(input.to_string())
}
Run cargo test --doc. The example compiles and runs. If you remove rand from [dev-dependencies], the doc test fails with E0432. The doc test runner treats dev dependencies as available crates.
Convention aside: The community expects doc examples to be runnable. If an example requires a dev dependency, list that dependency in [dev-dependencies]. Do not hide requirements. If users copy your example, it should work.
Doc tests are public API. Make them runnable.
Benchmarks
Benchmarks also use dev dependencies. The standard benchmarking crate criterion goes in [dev-dependencies]. You define a benchmark binary in Cargo.toml using the [[bench]] section.
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "parse_bench"
harness = false
The harness = false flag tells Cargo not to use the built-in test harness. Criterion provides its own harness. The benchmark binary links against your library and the dev dependencies.
// benches/parse_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use config_parser::parse_config;
fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("parse_valid", |b| {
b.iter(|| {
// WHY: black_box prevents the compiler from optimizing away the result.
black_box(parse_config("user@example.com"));
});
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
Run cargo bench. Cargo compiles the benchmark binary with dev dependencies. The benchmark runs against the release-optimized library code. This gives you accurate performance measurements.
Benchmarks are dev dependencies. They measure production code but do not ship with it.
Pitfalls and traps
Using dev dependencies in main code
If you forget #[cfg(test)] and try to use a dev dependency in your library code, the compiler rejects you. You will see E0432 when building for release. The error message points to the use statement. The fix is to move the code into a test module or remove the dependency from production code.
Build scripts cannot see dev dependencies
Build scripts run during the build process. They cannot access dev dependencies. build.rs runs before dev dependencies are considered. If your build script tries to import a dev dependency, it fails.
Use [build-dependencies] for crates needed by build.rs. Build dependencies are available only to the build script. They are not linked to the final binary. This is a separate category from dev dependencies.
Build scripts run early. They do not see dev dependencies. Use build-dependencies instead.
Macro confusion
New Rustaceans sometimes put macro crates in dev dependencies. This is wrong. If a macro generates code that ends up in your binary, the macro must be available during the main build. serde_derive is a dependency, not a dev dependency. The generated code is part of the binary. The macro must be present when compiling production code.
Dev dependencies are strictly for code that does not end up in the final artifact. If the crate touches production code, it belongs in [dependencies].
Version conflicts
Dev dependencies can conflict with regular dependencies if versions are incompatible. Cargo tries to resolve this, but sometimes it fails. If you have serde = "1.0" in dependencies and serde = "2.0" in dev dependencies, Cargo cannot unify the versions. You get a resolution error.
Keep versions aligned. If a crate is in both sections, use the same version. Cargo allows this. You can list the same crate in both [dependencies] and [dev-dependencies] with the same version. Cargo deduplicates the crate. The dev dependency entry just ensures the crate is available for tests if it were optional, though this is rare. Usually, you list a crate in one section or the other.
Decision matrix
Use [dependencies] when the crate is required for the application to run in production.
Use [dev-dependencies] when the crate is needed only for tests, benchmarks, or documentation examples.
Use [build-dependencies] when the crate is needed by build.rs to generate code or configure the build.
Reach for cargo add --dev when adding a test utility to avoid manual TOML editing.
Pick #[cfg(test)] modules when you need to isolate test code from library code.
Trust the separation. If it does not run the app, it is a dev dependency.