Skip to content
← All articles

Crate vs package vs workspace vs module

Four words that get used interchangeably and mean four different things. Getting them straight answers "why can't I use this from my binary", "what does pub(crate) actually cover", and "why did adding a file do nothing".

These four words get used as if they were synonyms, including by people who should know better, and almost every structural confusion in a Rust project comes from a fuzzy version of one of them. They are worth ten minutes.

word what it is how many
module a namespace node inside one crate as many as you like
crate one compilation unit — one rustc invocation one per rustc run
package one Cargo.toml at most one library, any number of binaries
workspace a set of packages sharing one Cargo.lock and one target/ one per repo, usually

Crate: the compilation unit

A crate is what rustc compiles in one go. It has exactly one root module — src/lib.rs for a library, src/main.rs for a binary — and every module in the crate descends from it. When you write pub(crate), this is the crate you mean. When the compiler says “cannot be re-exported outside the crate”, this is the boundary.

Crucially: the crate is also the privacy boundary and the SemVer boundary. Everything from 10.3 and 10.4 is scoped to it. #[non_exhaustive] does nothing inside it. The orphan rule is stated in terms of it.

Package: the unit Cargo manages

A package is a directory with a Cargo.toml. It is the unit of publishing, of versioning and of dependency declaration. And here is the fact that surprises everybody:

A package containing both src/lib.rs and src/main.rs compiles TWO crates.

They are separate compilation units. The binary does not automatically see the library’s internals — it has to use the library by its package name, exactly as an external user would:

// src/main.rs, in a package named my-tool
use my_tool::run;          // note the underscore

fn main() {
    run();
}
💡Your package is called image-proc. src/lib.rs has a pub(crate) fn decode. Why can src/main.rs not call it, and what are your two options? click to reveal

Because src/main.rs is a different crate. pub(crate) means “visible within the crate that declares it”, and the binary is not that crate — from its point of view the library is an external dependency that happens to live in the same directory.

Your options are:

  1. Make decode genuinely pub, accepting that it is now public API that external users can call and that you owe SemVer guarantees on.
  2. Move whatever the binary needed into the library as a thin pub entry point, and keep decode internal. This is the usual answer and it is why the standard advice is logic in src/lib.rs, src/main.rs a thin shell.

Option 2 has a second payoff, covered in 10.21: integration tests in tests/ are separate crates too, so a binary-only package cannot be integration-tested at all. Putting the logic in the library is what makes it testable.

Also note image-proc became image_proc in the use. Cargo normalises dashes to underscores because a dash is not a valid Rust identifier. The package is named one way and the crate the other, which is a small, permanent papercut.

Module: a node inside one crate

Modules are what the rest of this track has been about. They do not cross crate boundaries; mod never reaches into a dependency. If you find yourself wanting mod some_dependency;, what you want is use some_dependency::… — the dependency is already linked, its name is already in the extern prelude, and no mod is involved.

This is the source of “why did adding a file do nothing”. A .rs file in src/ that no mod statement names is not part of any crate. rustc never opens it. Your editor will happily show you the code, your changes will compile-check in rust-analyzer, and nothing will happen.

Workspace: shared lockfile, shared target directory

A workspace groups packages so they resolve dependencies together and share one target/. That is the whole idea. The members are still separate packages producing separate crates; what they share is the resolution and the build cache.

The practical consequences are covered in 10.18, but two of them are worth previewing because they surprise people:

  • Feature unification (10.16) happens across the workspace, so a dev-dependency of one member can enable features in a crate another member depends on.
  • [profile.*] and [patch] are root-only. Putting [profile.release] in a member’s manifest is silently ignored with a warning, and people lose an afternoon to it.

extern crate is a fossil

In edition 2015 you had to write extern crate serde; to bring a dependency into scope. Since 2018 dependencies are in the extern prelude automatically and the keyword is dead — with two survivals:

  • #[macro_use] extern crate foo; for pre-2018 macro export.
  • extern crate alloc; in no_std crates, where alloc is not in the prelude and has to be pulled in explicitly.

If you see extern crate in a tutorial, the tutorial predates 2018, which tells you something about the rest of it too.

The short version

  • Module: a namespace node inside a crate. Declared with mod, never crosses crates.
  • Crate: one rustc invocation. The privacy, SemVer and orphan-rule boundary.
  • Package: one Cargo.toml. Up to one library plus any number of binaries — so a package can be several crates.
  • Workspace: several packages, one lockfile, one target/, one dependency resolution.