Packages and Crates
Understanding Rust's fundamental units of code organization, compilation, and distribution
As a Rust project grows beyond a single file, you need a way to group related code, control what is visible to other parts of the program, and eventually share your work with others. Rust provides three distinct but interconnected concepts for this: packages, crates, and modules. This section focuses on the first two — what they are, how they relate, and how Cargo uses them to build your code. The crate root and build profiles are part of that story, defining where the compiler starts and how it tunes the final binary.
What Are Packages and Crates?
The word “crate” gets used informally in the Rust community to mean a reusable library you find on crates.io, but at the language level the terms are precise. Understanding the difference between a package and a crate prevents a whole category of build and organizational confusion later.
A crate is the unit of compilation. The Rust compiler, rustc, works on one crate at a time. A crate is a tree of modules that produces either a library or an executable. Library crates produce a .rlib (or platform-specific equivalent) that other crates can link against; binary crates produce an executable file.
A package is a unit of distribution managed by Cargo. A package is what you create when you run cargo new. It consists of a Cargo.toml manifest and a src directory containing source files. The package describes how to build one or more crates — it is the “wrapping” around compilation.
The Crate vs. Package Confusion:
It is extremely common to hear someone say "the serde crate" when they actually mean "the serde package." In everyday conversation the distinction is harmless, but when you are designing a project structure or debugging a build issue, knowing exactly what is a crate and what is a package matters. A package can contain multiple crates; a crate is always just one library or one executable.
Think of the relationship this way: a package is the project folder with its Cargo.toml — the thing you check into version control. Inside that folder, you may have one library crate (at most) and zero or more binary crates. The package’s name is defined in Cargo.toml, and by default the crate names match the package name, but they are conceptually different.
How Cargo Creates Packages
When you run cargo new my_project, Cargo generates:
my_project/
├── Cargo.toml
└── src/
└── main.rs
This is a package containing a single binary crate. The main.rs file contains a main function, which is the entry point for the executable. If you instead run cargo new --lib my_library, you get:
my_library/
├── Cargo.toml
└── src/
└── lib.rs
This package contains a single library crate. The lib.rs file is the crate root, and the compiled output will be a library that other code can depend on.
A package can contain both. If you have both src/main.rs and src/lib.rs, the package contains one library crate and one binary crate. This is a typical pattern for a library that also ships a command-line tool.
Binary Crates Beyond main.rs
A package can have multiple binary crates. Besides the default src/main.rs, you can place additional .rs files inside src/bin/. Each file becomes a separate binary crate.
my_project/
├── Cargo.toml
└── src/
├── main.rs
└── bin/
├── tool_a.rs
└── tool_b.rs
Cargo will compile main.rs as a binary crate named after the package, and tool_a.rs and tool_b.rs as separate binary crates named tool_a and tool_b. You run them with cargo run --bin tool_a.
Multiple Binaries and Cargo Run:
If a package contains more than one binary crate, the plain cargo run command becomes ambiguous. You must specify which binary to execute with cargo run --bin <name>. For the default binary using src/main.rs, the name is the package name as defined in Cargo.toml.
What Is and Is Not a Crate
A library crate does not need a main function. It exposes public items — functions, structs, traits — that other crates can use. A binary crate must have a main function and produces an executable. The words "library" and "binary" describe what the crate produces, but both are crates.
The compiler sees crates as trees of modules, with the crate root at the top. When you write use std::collections::HashMap, you are reaching into the std crate, navigating its module tree. The package concept does not appear in the source code — you never write package in Rust. The package exists purely in the Cargo configuration.
Checking Your Understanding:
If you have a project with a Cargo.toml, a src/lib.rs, and a src/main.rs, you have one package and two crates: a library crate and a binary crate. The binary crate can use the library crate by referring to it with the package name (e.g., use my_project::some_function;). If you can explain why the binary crate automatically has access to the library crate, you understand the relationship.
The Crate Root
Every crate has a single file that serves as its entry point for the compiler. This file is called the crate root. The compiler starts reading from this file and follows mod declarations to discover the rest of the crate’s module tree.
Cargo determines the crate root from the package structure:
src/main.rsis the crate root for the default binary crate.src/lib.rsis the crate root for the library crate.- Any file inside
src/bin/is the crate root for an additional binary crate.
The crate root is not just the first file compiled — it defines the top-level module scope for that crate. Everything inside the crate is a descendant of this root module. When you write crate::some_module::some_function, the crate keyword refers to this root.
Why the Crate Root Matters
Because the compiler begins at the crate root, all modules you want included in the crate must be reachable from this file through mod statements. If you create a file src/utilities.rs but never write mod utilities; in the crate root (or in some module that is itself declared in the crate root), the compiler will not see that code. It will not be compiled as part of the crate.
Consider a package with a library crate:
mod network;
mod database;
pub fn start_server() {
network::init();
database::connect();
}
The mod network; declaration tells the compiler to look for a module named network. It will search inline, then for src/network.rs, and finally src/network/mod.rs. The same logic applies to database. Both modules become part of the crate’s tree, rooted at src/lib.rs.
Missing mod Declarations:
A file placed in src/ is not automatically part of the crate. If you forget to declare mod filename; in the crate root or an ancestor module, the compiler will ignore that file entirely. This is one of the most frequent mistakes when moving from single-file programs to multi-file projects. The compiler will not warn you that a file is unused; your code simply won't be found.
How Cargo Passes the Crate Root to rustc
When you run cargo build, Cargo inspects your src directory, identifies the crate roots according to the conventions above, and invokes rustc with the appropriate --crate-type and the root file. You rarely need to think about this unless you are working on build scripts or custom configurations. For everyday development, the file you place at src/main.rs or src/lib.rs is the crate root, and that is where your module tree begins.
The Relationship Between Library and Binary Crate Roots
When a package has both a library crate and a binary crate, the binary crate can use the library crate as an external dependency. Inside src/main.rs, you can write:
use my_package::some_function;
fn main() {
some_function();
}
Cargo automatically treats the library crate as a dependency of the binary crate, using the package name as the crate name. This means you do not need to add the library to Cargo.toml under [dependencies]; the linkage is implicit within the same package. This is the standard pattern for separating the core logic (library) from the executable interface (binary).
Modules vs. Files:
The crate root defines the root module. Every mod declaration introduces a submodule. The file system layout mirrors the module tree: mod garden; corresponds to src/garden.rs or src/garden/mod.rs. This one-to-one mapping between module declarations and files is the foundation of Rust's multi-file organization. Understanding that the crate root is the starting point of that mapping will save you hours of confusion.
Build Profiles
Cargo supports different build profiles that control the compiler’s optimization level, debug information, and other settings. The two built-in profiles are dev and release. They determine how cargo build and cargo build --release behave.
When you compile without --release, Cargo uses the dev profile. This profile prioritizes fast compilation over runtime performance. It produces a debug build with minimal optimization, includes debug symbols, and enables incremental compilation. The result is a binary that starts quickly after a code change but runs slower than an optimized build.
When you compile with --release, Cargo uses the release profile. This profile applies aggressive optimizations, strips debug symbols by default, and takes significantly longer to compile. The resulting binary is much faster at runtime.
Default Profile Settings
Each profile has default settings defined in Cargo’s configuration. You can see them in the Cargo documentation, but the most important defaults are:
| Setting | dev | release |
|---|---|---|
opt-level | 0 (no optimization) | 3 (maximum optimization) |
debug | true | false |
debug-assertions | true | false |
incremental | true | false |
opt-level controls how hard LLVM tries to optimize your code. A value of 0 means “do not optimize; compile as quickly as possible.” A value of 3 means “apply all optimizations, even if they take more time.”
debug controls whether debug information is included in the binary. In dev mode, debug info lets you step through code with a debugger. In release mode, stripping debug info reduces binary size.
debug-assertions enables extra runtime checks (like integer overflow panics in release builds when disabled by default) that are useful during development.
Customizing Build Profiles in Cargo.toml
You can override these defaults by adding a [profile.*] section to your Cargo.toml. For example, to increase the optimization level for the dev profile (because you want faster test runs while keeping debug info), you can write:
[profile.dev]
opt-level = 1
To make the release profile produce slightly smaller binaries with debug information for profiling, you can adjust it:
[profile.release]
opt-level = 3
debug = true
lto = true
lto (Link Time Optimization) enables cross-crate optimization, further reducing binary size and improving runtime performance at the cost of longer link times.
Changing Profiles Affects Build Times:
Even a small change like opt-level = 1 in dev can noticeably increase compilation time. Rust’s compile times are already a common concern; raising the optimization level for development builds should be a deliberate choice, not a default.
When to Use Each Profile
During active development, stick with the default dev profile. The fast compile-edit-debug cycle matters more than runtime speed. When you are ready to benchmark, test performance, or deploy, use --release. The performance difference is often an order of magnitude.
A common pattern is to run tests with both profiles:
cargo test # Uses dev profile
cargo test --release # Uses release profile
Release-mode tests can catch optimization-related bugs where code behaves differently under aggressive optimizations (though this is rare in safe Rust).
Debug Assertions in Release Mode:
By default, debug-assertions are off in the release profile. This means integer overflow checks, among other safety nets, are removed. If your code relies on these checks for correctness, ensure that you are not only testing in dev mode — or override the setting with debug-assertions = true in your [profile.release] if your domain requires it.
Custom Profiles
Cargo also supports user-defined profiles. You can create custom profiles by naming them in Cargo.toml under [profile.<name>] and then invoking them with cargo build --profile <name>. This allows you to define, for example, a benchmark profile with maximum optimization and no debug output, without modifying the standard release profile.
[profile.benchmark]
inherits = "release"
debug = false
lto = "fat"
codegen-units = 1
The inherits key copies all settings from the named profile (here release) and then overrides the specified fields. This keeps the configuration concise.
Profile Customization is Purely Declarative:
You do not need to modify build scripts or environment variables to tune profiles. A few lines in Cargo.toml are enough to control optimization, debug information, and linking behavior. If you can open your Cargo.toml and see a [profile.release] section that matches your deployment needs, your build pipeline is correctly configured.
The concepts of packages, crates, and crate roots form the structural skeleton of every Rust project. They define how your code is organized, compiled, and eventually distributed. The build profile system then determines the quality of the final output: development-friendly or production-hardened.