Skip to content
← All Rust articles

Rust explainer

Crate vs package vs workspace vs module

About 5 min to read. Allow extra time to work through examples.

Understand Rust's four organizational units, why a library and binary have separate privacy boundaries, and how Cargo builds and tests them.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Modules organize code within a crate. Cargo groups build targets into packages, and packages can share a workspace.

Term Role
Module A namespace in a crate's module tree.
Crate A compilation unit with a root module and its descendants.
Package A Cargo project with a package manifest and build targets.
Workspace One or more packages managed together by Cargo.

A crate has its own module tree

A crate starts at a root source file. Cargo defaults to src/lib.rs for a library and src/main.rs for the primary binary, but manifests can configure other paths. Modules belong to that crate even when their source is spread across files.

pub(crate) makes an item accessible within its defining crate. Another crate cannot access it simply because both live in the same directory or repository. Rules such as trait-implementation coherence and the downstream effects of non_exhaustive also distinguish the defining crate from others.

A crate is a compilation unit, not a count of build commands: Cargo may compile a target again for tests, features, profiles or different platforms.

One package can produce several crates

A package's Cargo.toml contains a package definition. A package can have at most one library target, multiple binaries, and targets such as examples, integration tests and benchmarks. Cargo packages are also the units published and versioned in a registry. Public API compatibility informs package versions.

A package with both src/lib.rs and src/main.rs has separate library and binary crates. The binary uses the library's public API. Its access is not extended to pub(crate) library items.

Here is a complete small package. Create the files under a new directory.

Cargo.toml

[package]
name = "message-tool"
version = "0.1.0"
edition = "2024"

src/lib.rs

mod message;

pub fn greeting() -> &'static str {
    message::text()
}

src/message.rs

pub(crate) fn text() -> &'static str {
    "hello"
}

src/main.rs

fn main() {
    println!("{}", message_tool::greeting());
}

cargo run prints hello. The library can call message::text, but the binary calls the public greeting entry point. This keeps the implementation private without making the binary responsible for library internals.

By default, the library crate name comes from the package name with hyphens changed to underscores. A lib section can override that name, so the package name is not always the identifier you use in Rust code. Dependencies can also be renamed in a consumer's manifest.

Integration tests can exercise libraries or binaries

Add this file to the package above:

tests/cli.rs

#[test]
fn prints_greeting() {
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_message-tool"))
        .output()
        .expect("run message-tool");
    assert!(output.status.success());
    assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "hello");
}

Run cargo test. Cargo builds the binary and supplies its path through CARGO_BIN_EXE_message-tool when compiling the integration test.

A binary-only package can use this approach too. It needs no library to test its executable's output or exit status. A library is useful when tests should call application functions directly. Integration tests are separate crates and use the library's public API; unit tests within a crate can test private items visible to their test module.

A file is not automatically a module

In the example, mod message; declares a module whose source is loaded from src/message.rs. use instead brings an existing path into scope; it does not declare a module or discover source files.

Adding an arbitrary helper file under src does not include it automatically. It must be connected to a crate through a module declaration or another mechanism such as include. Cargo's target discovery is a separate process: files such as src/bin/extra.rs can become binary targets by convention. Path attributes and target configuration can change source locations.

In editions 2018 and later, configured external dependencies are normally available through the extern prelude. Access them by path or use declaration, not by declaring a local module with their name.

Workspaces coordinate packages

Workspace members share a lockfile and, by default, a target directory at the workspace root. Build-output configuration can change that directory. A workspace does not merge members into a single crate or bypass privacy.

A root manifest can contain both package and workspace sections. A virtual workspace root has a workspace section without a package definition, so not every Cargo.toml represents a package. Repositories and workspaces need not have a one-to-one relationship.

Put workspace profiles and dependency patches in the root manifest; member declarations are ignored, with Cargo warnings where applicable. Package selection and resolver settings affect feature unification. A dev dependency of one member does not automatically enable its features for every build of every other member. For example, newer resolvers distinguish development dependencies when the selected build does not need them.

extern crate still has uses

Most modern Cargo projects do not need extern crate for ordinary dependencies. It remains valid syntax, including uses such as extern crate alloc in no_std code and some macro-import patterns. Its presence alone does not tell you whether an example is outdated.

This article uses Cargo projects to show boundaries that a single-file exercise cannot demonstrate. The site's Rust submissions are compiled by the local daemon with rustc; they do not run these Cargo commands.

step 13 of 22

Modules, Visibility, Testing and Docs

Up next Cargo.toml essentials