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.toml to 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 build with no edits finishes almost instantly.
  • Invokes rustc once 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 rustc invocation specifically: the source is parsed, macros like println! 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, rustc generates 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/ or target/release/.
  • Only cargo run takes 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 new as soon as a program needs more than one file or any external crate; hand-invoking rustc is fine only for quick, disposable experiments.
  • Use cargo check while 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 time cargo build takes.
  • Always build with --release before measuring performance or shipping a binary; debug builds are intentionally unoptimized.
  • Commit Cargo.lock for 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, not cargo run arg1 arg2 — without the -- separator, cargo tries to interpret the arguments itself.
  • Run cargo clean occasionally on long-lived projects to remove the accumulated target/ directory if disk space matters; it will simply be rebuilt on the next cargo build.

Practice Exercises

  • Create a new package called calculator with cargo new, write a main function that computes and prints the result of 17 * 23, and run it with cargo run. Expected output: 391.
  • Take any single .rs file containing a fn main() and compile it two ways: once with plain rustc, and once by moving it into a fresh cargo new project’s src/main.rs and running cargo build. Compare where each resulting binary ends up.
  • In the calculator project, run cargo build --release and locate the binary in target/release/. Then run cargo clean and confirm the target/ directory disappears.

Summary

  • rustc is the Rust compiler itself; it can compile a single file directly into an executable with no project setup.
  • cargo is Rust’s build system and package manager; it reads Cargo.toml, manages dependencies, and calls rustc for you.
  • cargo build compiles only; cargo run compiles and then immediately executes the result.
  • cargo check type- 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.