The rustc and cargo build/run Commands
Every Rust program eventually becomes machine code through the same tool: rustc, the Rust compiler. But almost nobody invokes rustc directly for real projects — instead they use cargo, Rust’s official build tool and package manager, which wraps rustc and adds dependency management, project scaffolding, and a consistent set of commands like cargo build and cargo run. Understanding both — what rustc does on its own, and what cargo adds on top — is essential to reading Rust tooling output, debugging build errors, and working efficiently in any Rust codebase.
Overview: How rustc and cargo Fit Together
rustc is the actual Rust compiler — the program that turns Rust source code into a runnable binary (or a library). Every Rust toolchain installs it, and you can call it directly: point it at a single .rs file and it type-checks, borrow-checks, optimizes, and links that file into an executable, all in one step. For a tiny script or a first experiment, that is all you need.
Real projects, however, are almost never a single file. They depend on external libraries (called crates), they need a standard folder layout so tools and other developers know where to look, and they need a reproducible way to record exactly which versions of which dependencies were used. cargo is the tool that solves all of that. It is Rust’s official build system and package manager: it reads a manifest file named Cargo.toml, downloads and compiles any dependencies listed there, and then invokes rustc itself — with the correct flags, search paths, and in the correct order — to produce your final binary. You almost never call rustc by hand once a project has more than one file or a single dependency.
A useful comparison: rustc is to Rust roughly what gcc or clang is to C — a compiler that turns source into machine code. cargo is closer to a combination of make, npm, and a package registry client — it orchestrates the compiler, manages dependencies, and standardizes commands like build, run, and test across every Rust project you will ever open.
Whichever tool triggers it, compilation happens in the same stages: rustc parses your source into an abstract syntax tree, expands macros like println!, resolves names and checks types, then lowers the code to an intermediate representation called MIR where the borrow checker runs. Only after every reference and every ownership move has been proven safe does the compiler generate LLVM intermediate code, optimize it, and hand it to the system linker to produce a final executable. This is why a borrow-checker violation is a compile-time error rather than something you discover at runtime — it is caught during this MIR analysis step, before a single instruction of machine code is even generated.
Both tools also understand two build profiles. The default, unoptimized debug profile compiles fast and embeds debug symbols, which is why the compiled binary from a plain cargo build lands in target/debug/. The release profile (cargo build --release) spends much longer running LLVM’s optimizer and strips debug info, producing a binary in target/release/ that can run many times faster — a tradeoff you make deliberately when benchmarking or shipping.
Syntax
The two tools have different general forms. Direct compilation with rustc looks like this:
rustc <file>.rs [options]
cargo‘s commands all follow the same pattern — a subcommand plus optional flags:
cargo new <name> [--lib]
cargo build [--release]
cargo run [--release] [-- <program-args>]
cargo check
| Part | Meaning |
|---|---|
rustc <file>.rs |
Compiles exactly one crate rooted at that file into a binary named after the file (or after -o). |
-o <name> |
rustc option to choose the output binary’s name. |
--edition 2021 |
rustc option selecting the language edition to compile against. |
cargo new <name> |
Scaffolds a new package: creates a folder, a Cargo.toml manifest, and src/main.rs (or src/lib.rs with --lib). |
cargo build |
Compiles the package and its dependencies without running anything. Debug profile by default. |
--release |
Flag accepted by build and run to use the optimized release profile instead of debug. |
cargo run |
Builds the package (if needed) and then immediately executes the resulting binary. |
-- <program-args> |
Everything after a lone -- is passed as command-line arguments to your program, not to cargo itself. |
cargo check |
Runs the compiler’s analysis (type checking, borrow checking) without generating machine code — much faster for a quick feedback loop. |
Examples
Example 1: Compiling a single file with rustc
For a quick experiment, you don’t need a cargo project at all. Save the following as main.rs:
fn main() {
println!("Hello, world!");
}
Compile it, then run the resulting binary directly:
$ rustc main.rs
$ ./main
Output:
Hello, world!
The rustc main.rs command reads that one file, compiles it as a standalone crate, and writes a native executable named main (or main.exe on Windows) into the current directory. Running that binary prints the greeting. There is no Cargo.toml, no target/ folder, and no dependency resolution involved — this is rustc working exactly as it would for any other single-file compiled language.
Example 2: Creating and running a cargo project
For anything beyond a single file, scaffold a real package with cargo new:
$ cargo new greetings
$ cd greetings
This creates a Cargo.toml manifest:
[package]
name = "greetings"
version = "0.1.0"
edition = "2021"
[dependencies]
…and a starter src/main.rs, which we replace with:
fn main() {
let name = String::from("Rustacean");
println!("Hello, {}! Welcome to Rust.", name);
}
Now build and execute it with a single command:
$ cargo run
Output:
Compiling greetings v0.1.0 (/path/to/greetings)
Finished dev [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/greetings`
Hello, Rustacean! Welcome to Rust.
cargo run first checks whether anything changed since the last build; since this is a fresh project, it compiles greetings (invoking rustc behind the scenes with the right flags for the debug profile), places the binary at target/debug/greetings, and then immediately executes it, streaming the program’s own output — the final Hello, Rustacean! line — to your terminal.
Example 3: Debug vs. release builds
Replace src/main.rs in the same project with a small CPU-bound loop:
fn main() {
let mut sum: u64 = 0;
for i in 1..=1_000_000 {
sum += i;
}
println!("Sum: {}", sum);
}
Build it with optimizations enabled and run the resulting binary directly:
$ cargo build --release
$ ./target/release/greetings
Output:
Compiling greetings v0.1.0 (/path/to/greetings)
Finished release [optimized] target(s) in 0.55s
Sum: 500000500000
The --release flag tells cargo to invoke rustc with LLVM’s optimization pipeline turned on and debug assertions turned off. Compilation itself takes longer, but the binary lands in a separate target/release/ directory (never overwriting your debug build) and runs the million-iteration loop dramatically faster than the unoptimized debug build would.
How It Works Step by Step
When you run cargo build or cargo run, cargo performs, in order:
- Reads
Cargo.tomlto find the package name, edition, and declared dependencies. - Resolves the full dependency graph and records the exact versions used in
Cargo.lock, downloading any missing crates from crates.io. - Computes a fingerprint for each crate (source hash, flags, dependency versions) and skips recompiling anything unchanged since the last build — this is why a second
cargo buildwith no edits finishes almost instantly. - Invokes
rustconce per crate that needs (re)building, in dependency order, passing the correct search paths so each crate can find the compiled output of the crates it depends on. - For your own crate’s
rustcinvocation specifically: the source is parsed, macros likeprintln!are expanded, names and types are resolved, and the code is lowered to MIR, where the borrow checker verifies every reference and ownership move. - Once borrow checking passes,
rustcgenerates LLVM IR, runs the optimizer (aggressively in release mode, minimally in debug mode), and produces machine code. - The system linker combines that machine code with the standard library and any other compiled crates into a single executable in
target/debug/ortarget/release/. - Only
cargo runtakes the extra step of executing that freshly built binary, forwarding anything after a--as command-line arguments to your program.
Calling rustc directly on a single file skips the first three steps entirely — there is no manifest to read, no dependency graph, and no cache — but the remaining compiler steps (parsing through linking) are identical.
Common Mistakes
Mistake 1: Expecting cargo build to run the program
cargo build only compiles your code and places the binary in target/debug/ (or target/release/ with --release) — it never executes it. Beginners often run it and then wonder why nothing was printed:
$ cargo build
Compiling greetings v0.1.0 (/path/to/greetings)
Finished dev [unoptimized + debuginfo] target(s) in 0.38s
$ # ...nothing printed, because the program never ran
To both build and execute in one step, use cargo run instead, or run the compiled binary yourself with ./target/debug/greetings.
Mistake 2: Assuming a moved value is still usable
Because ownership and borrow-checking happen inside the same compile step that rustc and cargo build trigger, a program that moves a String and then tries to use the original binding will fail to build — this is one of the most common first encounters with the borrow checker:
fn main() {
let name = String::from("Ferris");
let greeting = build_greeting(name);
println!("{}", greeting);
println!("Original name was: {}", name);
}
fn build_greeting(n: String) -> String {
format!("Hello, {}!", n)
}
Both rustc and cargo build reject this with a compile error, because build_greeting(name) takes ownership of name by value, moving it out of main:
error[E0382]: borrow of moved value: `name`
--> src/main.rs:5:38
|
2 | let name = String::from("Ferris");
| ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
3 | let greeting = build_greeting(name);
| ---- value moved here
4 | println!("{}", greeting);
5 | println!("Original name was: {}", name);
| ^^^^ value borrowed here after move
The fix is to borrow the string instead of moving it, by taking a &str parameter:
fn main() {
let name = String::from("Ferris");
let greeting = build_greeting(&name);
println!("{}", greeting);
println!("Original name was: {}", name);
}
fn build_greeting(n: &str) -> String {
format!("Hello, {}!", n)
}
Output:
Hello, Ferris!
Original name was: Ferris
Now build_greeting only borrows name for the duration of the call, so main still owns it afterward and can use it again.
Mistake 3: Benchmarking a debug build
Running cargo run or cargo build without --release produces the debug profile, which disables most optimizations to keep compile times short. Timing a CPU-heavy program built this way and concluding “Rust is slow” is a very common mistake — always add --release before measuring performance, as shown in Example 3.
Best Practices
- Reach for
cargo newas soon as a program needs more than one file or any external crate; hand-invokingrustcis fine only for quick, disposable experiments. - Use
cargo checkwhile actively writing code — it runs the type and borrow checker without the slower codegen and linking steps, giving you feedback in a fraction of the timecargo buildtakes. - Always build with
--releasebefore measuring performance or shipping a binary; debug builds are intentionally unoptimized. - Commit
Cargo.lockfor binaries/applications so every build uses identical dependency versions; library crates typically omit it so downstream users can resolve their own versions. - Pass arguments to your program with
cargo run -- arg1 arg2, notcargo run arg1 arg2— without the--separator, cargo tries to interpret the arguments itself. - Run
cargo cleanoccasionally on long-lived projects to remove the accumulatedtarget/directory if disk space matters; it will simply be rebuilt on the nextcargo build.
Practice Exercises
- Create a new package called
calculatorwithcargo new, write amainfunction that computes and prints the result of17 * 23, and run it withcargo run. Expected output:391. - Take any single
.rsfile containing afn main()and compile it two ways: once with plainrustc, and once by moving it into a freshcargo newproject’ssrc/main.rsand runningcargo build. Compare where each resulting binary ends up. - In the
calculatorproject, runcargo build --releaseand locate the binary intarget/release/. Then runcargo cleanand confirm thetarget/directory disappears.
Summary
rustcis the Rust compiler itself; it can compile a single file directly into an executable with no project setup.cargois Rust’s build system and package manager; it readsCargo.toml, manages dependencies, and callsrustcfor you.cargo buildcompiles only;cargo runcompiles and then immediately executes the result.cargo checktype- and borrow-checks without full codegen, making it the fastest way to get compiler feedback while editing.- Debug builds (
target/debug/) are unoptimized and fast to compile; release builds (cargo build --release,target/release/) are optimized and fast to run. - Ownership and borrow-checker errors are caught during this same compile step, before any machine code is generated — never at runtime.
