The silent success that confuses everyone
You just finished writing your first Rust program. You type cargo build and hit enter. The terminal spits out a line saying Compiling, then Finished dev [unoptimized + debuginfo] target(s) in 0.42s. It drops you back to the prompt. You stare at the screen. Nothing happened. No "Hello, world!" No output. No animation. Did it fail? You check the exit code. It is zero. Success. But where is your program?
You try cargo run instead. The terminal shows the same compilation text, then immediately prints your output. The magic happens. What changed? You didn't touch the code. You changed the command. The difference isn't about correctness. It's about what you ask Cargo to do with the result.
Build creates the artifact. Run wields it.
cargo build is the compiler's job. It takes your source code, checks the syntax, verifies the types, resolves dependencies, and translates everything into machine code. It saves that machine code as an executable file on your disk. Then it stops. It does not execute the file. It does not show you the program's output. It just gives you the binary.
cargo run is the compiler plus the operating system. It does everything cargo build does. If the binary is already up to date, it skips the compilation. Then it hands that executable to your shell and tells it to start running. The output from your program streams back to your terminal. When the program exits, cargo run exits.
Think of cargo build like a chef cooking a meal and plating it. The food is ready. It sits on the counter. cargo run is the chef cooking the meal and immediately handing it to you to eat. If the meal is already on the counter, the chef just hands it over.
Minimal example
Here is a simple program that demonstrates the separation. The code contains a runtime panic. This is important for understanding what each command catches.
// src/main.rs
/// Demonstrates compilation versus execution.
fn main() {
// This line prints to stdout when the binary executes.
// The compiler ignores the logic here during build.
// It only checks that the syntax and types are valid.
println!("Program started.");
// This division by zero causes a panic at runtime.
// cargo build will succeed and create the binary.
// The crash only happens when the binary runs.
let result = 10 / 0;
println!("Result: {}", result);
}
Run the commands in your terminal.
# cargo build compiles the source into a binary.
# It places the result in target/debug/.
# It exits immediately after compilation succeeds.
# You will see "Finished" but no program output.
cargo build
# The binary exists on disk.
# You can inspect it, move it, or run it manually.
# The file size tells you the build produced something.
ls -lh target/debug/my_project
# cargo run performs the build if necessary.
# It then executes the binary as a child process.
# You see the print, then the panic stack trace.
cargo run
cargo build reports success. The binary is created. cargo run reveals the crash. A successful build is not a successful program. It is just a successful translation.
What happens under the hood
When you invoke either command, Cargo starts with the same steps. It reads Cargo.toml to find your dependencies. It checks Cargo.lock to ensure versions match. It looks at the modification timestamps of your source files and the existing artifacts in the target directory. If nothing changed, Cargo skips work entirely. This is incremental compilation. Both commands benefit from it.
If compilation is needed, Cargo invokes rustc. The Rust compiler parses your code, builds the type graph, checks borrow rules, generates LLVM intermediate representation, optimizes, and emits object files. Finally, the linker combines everything into a single executable. The result lands in target/debug/ by default. The directory name matches the build profile.
cargo run adds one more step. It spawns a child process running the binary. It connects the child's standard input, output, and error streams to your terminal. It waits for the child to finish. It propagates the exit code. If the child panics, cargo run shows the panic message and exits with a non-zero code.
Realistic workflow: arguments and environment
Real programs take arguments. cargo run makes passing arguments ergonomic. It uses the double dash -- to separate Cargo's flags from your program's flags. This is a standard Unix convention. Everything after -- goes to your binary. Everything before -- goes to Cargo.
// src/main.rs
/// Processes command line arguments.
fn main() {
// Collect arguments into a vector.
// The first element is always the binary path.
let args: Vec<String> = std::env::args().collect();
// Iterate and print arguments.
// This loop runs at runtime, not compile time.
for (i, arg) in args.iter().enumerate() {
println!("Arg {}: {}", i, arg);
}
}
# cargo run uses -- to separate Cargo flags from program args.
# Cargo consumes flags before --. Your program gets the rest.
# This keeps the command readable and avoids flag collisions.
cargo run -- --verbose --mode=debug
# Without cargo run, you must specify the binary path.
# This is error-prone and harder to read.
# It also breaks if the binary name changes.
./target/debug/my_project --verbose --mode=debug
You can also set environment variables for the run. This is useful for debugging.
# RUST_BACKTRACE=1 tells Rust to print a full stack trace on panic.
# cargo run passes this variable to the child process.
# This helps you find the exact line that crashed.
RUST_BACKTRACE=1 cargo run -- --verbose
Convention aside: The double dash -- is a universal signal in command-line tools. It tells the tool "I'm done giving you options; everything after this belongs to the program you're launching." Cargo follows this convention. Use it to avoid confusion when your program accepts flags that look like Cargo flags. If you forget --, Cargo might try to interpret your program's flags as its own and fail.
Pitfalls and errors
A common trap is assuming cargo build validates your logic. It doesn't. cargo build only checks for compile-time errors. If you have a logic error, a panic, or a division by zero, cargo build will report success. The binary is created. The crash only happens when you run it. Never treat a successful build as proof that your program works. Run it to find runtime bugs.
Another pitfall involves multiple binaries. If your Cargo.toml defines more than one binary target, cargo run doesn't know which one to execute. You get an error.
The compiler rejects this with error: found multiple possible package targets. You must specify the binary name using --bin.
# cargo run fails when multiple binaries exist.
# Cargo cannot guess which one you want.
cargo run
# error: found multiple possible package targets
# Use --bin to disambiguate.
# This tells Cargo exactly which binary to build and run.
cargo run --bin my_tool -- --verbose
Newcomers also struggle with the target directory. The binary is never in your project root. It lives in target/debug/ or target/release/. This directory is generated by Cargo. It contains build artifacts, dependency caches, and metadata. Do not commit it to version control. Add target/ to your .gitignore. The directory can grow large. It is safe to delete and rebuild.
Debug versus release
Both cargo build and cargo run default to the debug profile. This profile enables debug symbols and disables aggressive optimizations. The binary is slower but easier to debug. When you are ready for production performance, you need the release profile.
# cargo build --release compiles with optimizations enabled.
# The binary is faster and smaller.
# It lands in target/release/ instead of target/debug/.
cargo build --release
# cargo run --release does the same.
# It builds in release mode if needed, then runs.
# Use this to benchmark your code accurately.
cargo run --release
Convention aside: Always benchmark with --release. Debug builds include instrumentation and skip optimizations. The performance numbers you see in debug mode are not representative of real-world behavior. If you measure speed, measure release.
When to use which
Use cargo build when you need the binary file itself. This includes deploying the artifact to a server, measuring binary size, or integrating the build step into a CI pipeline that separates compilation from testing. Use cargo build --release when you are preparing a distribution artifact for end users.
Use cargo run for the development loop. When you are editing code and want to see the output immediately, cargo run handles the compile-and-execute cycle in one command. It also handles argument passing cleanly via --. Use cargo run when you want to test environment variable behavior or catch panics quickly.
Use cargo check when you want fast feedback on types without generating a binary. This is faster than cargo build because it skips code generation. Reach for this when you are refactoring and just want to know if the types line up.
Use the binary directly from target/ when you need to run it with complex shell redirections or pipelines that cargo run makes awkward. Though cargo run supports basic piping, advanced shell scripts often work better with the explicit path.
Pick the command that matches your goal. If you need the file, build. If you need the result, run.