The Module System in Detail

A thorough guide to the Rust module system's inner workings, covering items, converting a program into a library, multiple binaries, attributes, and the standard prelude

In Rust, every piece of code you write lives inside a module. That module forms part of a crate, which is the unit the compiler operates on. The previous sections introduced packages, crates, and the basics of modules, visibility, and use. This section pulls those concepts together and examines the fine-grained mechanics that sit beneath them: what exactly a module can contain, how a typical binary program becomes a reusable library, how to manage several executables in one package, how attributes let you talk to the compiler, and what items Rust silently makes available to every file.

You will leave this section able to reason about the structure of any Rust project you open — not just the “what” and the “where”, but the underlying rules that make those decisions work.

Items — the Raw Materials of a Rust Module

A Rust module is a collection of items. The term “item” is not casual; it is the compiler’s word for any top-level declaration inside a module. Understanding items matters because every rule about privacy, naming, and paths is applied to them.

An item can be a function, a struct, an enum, a trait, an impl block, a constant, a type alias, a use declaration, another module, or a handful of other constructs the language defines. All of them participate in the module’s namespace — you refer to them through paths like crate::module::Thing. And all of them, unless marked pub, are invisible to any code outside the module that contains them.

What makes items special compared to, say, local variables or expressions, is that they exist at the module level and can outlive the single statement that created them. A function is an item; a temporary binding inside that function is not.

What counts as an item, and why it matters

The compiler draws a hard line between items and everything else. When you write a file that starts with fn main(), you are defining an item. When you later add struct Point, that is another item. They both sit as siblings inside the implicit module created for that file. This matters because only items can be imported with use. You cannot, for example, bring a local variable from another module into scope; you can only bring items.

Here is the mental model that helps most beginners: imagine a module as a locked cabinet. Each drawer is an item — a function drawer, a struct drawer, a use drawer. By default, the cabinet is closed; you must open it (with pub) to let outsiders reach in and grab a specific drawer.

Kinds of items you will encounter daily

  • Functionsfn, the workhorses of logic.
  • Structs and enums — data shapes.
  • Traits — shared behaviour definitions.
  • Implementations (impl blocks) — behaviour attached to types.
  • Constants and statics — named values that live for the program’s lifetime.
  • Type aliases — shortcuts for complex types.
  • Modules — sub-cabinets that can contain their own items.
  • use declarations — shortcuts that bring an item’s path into the local namespace.

The important thing is not to memorise the list, but to remember that every one of these is private unless explicitly published. Many early compilation errors (“cannot find X in this scope”) are caused by forgetting that even though an item exists in a file, that file is a module, and the item inside is private to it.

pub on an item does not cascade to its contents:

If you mark a struct public with pub struct Point, outsiders can name the type, but they still cannot read or write its fields — unless you also mark those fields pub. The same logic applies to modules: making a module public lets external code access that module’s path, but every item inside must be explicitly published before it becomes reachable.

The rest of this section assumes you are comfortable with items as the fundamental building blocks. Every example we build from here is just a specific arrangement of items across modules and crates.

Turning a Program into a Library

A binary crate built around src/main.rs is how most Rust experiments begin. At some point you want other people (or other parts of your own project) to reuse that code. That means turning the binary’s logic into a library crate. Rust makes the transition mechanical, but it requires careful attention to entry points and visibility.

Why this matters

If your program’s business logic is locked inside a main.rs file, no other crate can depend on it. A library crate, exposed through src/lib.rs, becomes a reusable dependency that you can import in tests, examples, and other binaries — including the original executable you started with.

The standard pattern for real-world Rust projects is to keep most code in lib.rs and leave main.rs as a thin wrapper that calls into the library. This gives you a clean API boundary and makes automated testing straightforward.

1

Step 1: Create a library entry point

If your project does not yet have a src/lib.rs, create one. The simplest version can re-export the functionality you plan to move:

// src/lib.rs
// This file will become the root of your library crate.

At this point, your package now contains both a binary crate (from src/main.rs) and a library crate. Both share the same package name.

2

Step 2: Move logic into the library

Take the functions, structs, and other items that represent the reusable logic out of main.rs and place them into modules rooted at src/lib.rs. Suppose your original main.rs looked like this:

// src/main.rs (before)
fn main() {
    let result = add(3, 5);
    println!("Result: {}", result);
}
fn add(a: i32, b: i32) -> i32 {
    a + b
}

Move add and any related types into a module inside the library. Create src/lib.rs:

// src/lib.rs
pub mod math;
// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
3

Step 3: Make items public

Everything in a library crate is private by default. To use add from main.rs, you must mark the module and the function pub. In the code above, both mod math inside lib.rs and fn add inside math.rs are declared public. If either were private, the compiler would reject the path from the binary.

4

Step 4: Use the library from the binary

Rewrite main.rs to call into the library by its package name. Since the library crate shares the package’s name, you can refer to it with crate:: from the binary’s perspective — but only if you declare the library as an external crate with extern crate (rarely needed in Rust 2018+). In modern editions, the library is automatically available under the package name.

// src/main.rs (after)
fn main() {
    let result = my_project::math::add(3, 5);
    println!("Result: {}", result);
}

Replace my_project with the actual package name defined in Cargo.toml. If you rename the package later, adjust this path accordingly.

After these steps, cargo run compiles the library, then the binary that depends on it, and runs the executable. The business logic now lives in a reusable crate.

You now have a library:

If cargo build completes without errors, your project structure is correct. The library appears in target/debug/ as an .rlib file alongside your binary. Other crates can now depend on your logic by adding this package to their Cargo.toml.

Don't leave private items in the library root:

A common mistake is to write a library function in lib.rs without marking it pub and then wonder why the binary cannot find it. The function exists inside the library crate, but because it is private, it is inaccessible from outside — including from the binary that lives alongside it. Always check that the path from the binary to the item traverses only pub boundaries.

The src/bin Directory for Multiple Binaries

A package with a single executable alongside a library covers many use cases. But sometimes you need several standalone programs that share the same underlying logic — a CLI tool, a background daemon, a maintenance utility, all built from one set of source files. Rust supports this directly through the src/bin directory.

Any .rs file placed inside src/bin becomes the crate root of its own binary crate, compiled automatically by Cargo. Each file defines its own main function and gets its own executable in the build output.

How it works in practice

Start with a package that already has a library. Create a directory src/bin and add a file for each additional binary you want:

my_project/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs        (the default binary, if present)
│   └── bin/
│       ├── daemon.rs
│       └── migrate.rs

Each file inside bin/ is compiled to an executable named after the file (minus the .rs extension). The library crate is automatically available as a dependency for every binary — you refer to it with the package name, exactly as main.rs does.

// src/bin/daemon.rs
fn main() {
    // Use the shared library logic
    my_project::start_service();
}
// src/bin/migrate.rs
fn main() {
    my_project::run_migrations();
}

If the package also has a src/main.rs, that becomes a binary named after the package itself. You can choose to keep it or delete it; the binaries in src/bin do not depend on its existence.

Naming and build output

After running cargo build, Cargo places each binary inside target/debug/ (or target/release/). The names map directly to the file names:

  • src/main.rsmy_project (the package name)
  • src/bin/daemon.rsdaemon
  • src/bin/migrate.rsmigrate

You can run a specific binary with cargo run --bin <name>, for example cargo run --bin daemon.

Each binary is its own crate:

While all binaries live in the same package, the compiler treats each as an independent crate. They share the library, but they cannot directly access each other’s code. If you need to share logic between two binaries, move that shared logic into the library crate.

Cargo features and dependencies affect all crates in the package:

If you add an optional dependency or a Cargo feature in Cargo.toml, it is available to the library and to every binary. A binary that accidentally pulls in heavy dependencies it does not use can increase compile times and binary size. Consider whether a separate package would be more appropriate for large, independent binaries.

This mechanism makes it easy to ship multiple related tools from a single repository while keeping common code in one place. It is widely used in the Rust ecosystem — for example, Cargo itself is a package containing the cargo binary and a library that third-party tools can depend on.

Attributes — Annotations That Shape Your Code

Rust code is not only made of items. It also contains attributes, which are metadata directives that instruct the compiler about how to process those items. Attributes look like #[...] or #![...] and sit either directly on top of an item or at the beginning of a module. They do not affect runtime behaviour; they influence compilation, code generation, linting, and conditional inclusion.

Outer vs. inner attributes

The position of the ! character distinguishes the two forms.

  • Outer attribute#[attribute] — applies to the item that immediately follows it.
  • Inner attribute#![attribute] — applies to the enclosing item (usually the module or crate in which it sits).

For example, you can place #[derive(Debug)] above a struct to automatically generate a Debug implementation for it. You can place #![allow(dead_code)] at the top of main.rs to silence the “unused code” warning for the whole module.

// Inner attribute: applies to the entire crate root
#![deny(missing_docs)]
// Outer attribute: applies only to this struct
#[derive(Debug, Clone)]
pub struct Config {
    pub host: String,
    pub port: u16,
}

Common attributes you will encounter

Some attributes are built into the language; others are provided by the standard library or by external crates through procedural macros. The ones you will see repeatedly include:

  • #[derive(SomeTrait)] — automatically implement common traits like Debug, Clone, PartialEq.
  • #[cfg(...)] and #[cfg_attr(...)] — conditionally compile code based on features, target OS, or other flags.
  • #[allow(lint_name)], #[warn(...)], #[deny(...)] — control compiler lint levels for specific items or scopes.
  • #[test] — mark a function as a test that runs with cargo test.
  • #![no_std] — opt out of the standard library for embedded or bare-metal contexts.
  • #[doc = "..."] — provide documentation that appears in cargo doc output.

Attributes are not decorative; they are the primary mechanism for communicating with the Rust toolchain beyond the code’s direct semantics.

Conditional compilation with #[cfg]

A practical example: you want a function that behaves differently on Windows and on Unix.

#[cfg(target_os = "windows")]
fn get_config_dir() -> PathBuf {
    // Windows-specific path logic
}
#[cfg(not(target_os = "windows"))]
fn get_config_dir() -> PathBuf {
    // Unix-specific path logic
}

The compiler will include only one of these functions in the final binary, depending on the target platform. The same technique works with Cargo features:

#[cfg(feature = "json")]
mod json_support;

This module is compiled only when the json feature is enabled in Cargo.toml.

cfg attributes are compile-time only:

Conditional compilation does not hide code at runtime. If you write a test that uses a #[cfg(test)]-gated function, that function simply does not exist outside of tests. Calling it from production code produces a compile error, not a silent dead branch.

Attributes form a quiet but pervasive layer of the module system. They change how modules are compiled, which code is visible under which conditions, and what rules the compiler enforces on your items. They deserve reading beyond this introduction, particularly #[cfg], #[cfg_attr], and the lint system, which appear in almost every substantial Rust codebase.

The Standard Prelude — What Rust Brings into Scope for You

Every Rust module — whether it is main.rs, lib.rs, or a deeply nested file — starts with a set of items already in scope. This invisible collection is called the standard prelude. It means you can write Vec::new() or println! without an explicit use std::vec::Vec; or use std::println;.

The prelude is not a special language feature; it is simply a conventional module, std::prelude, that the compiler automatically imports into every crate’s root. Specifically, the compiler inserts use std::prelude::v1::*; into each module on your behalf, as if you had written it yourself at the top of every file. This is why you get common types and traits for free.

What the prelude contains

The std::prelude::v1 module re-exports a curated set of frequently needed items. The exact list is documented, but you do not need to memorise it — you will encounter its members naturally. It includes:

  • TypesOption, Result, String, Vec, Box, ToOwned, and more.
  • TraitsClone, Copy, Drop, Iterator, Fn, FnMut, FnOnce, Into, From, and many others. These traits are why you can call .clone() on a String without importing Clone explicitly.
  • Macrosprintln!, format!, vec!, assert!, and the entire family of built-in macros.

Without the prelude, every Rust program would begin with dozens of use statements just to access Vec and Option. The prelude is a pragmatic choice: it prioritises the ergonomics of the common case over a strictly minimal namespace.

How the prelude interacts with your code

Because the prelude is imported automatically, you rarely need to think about it. However, you can override it or opt out of it entirely in specific contexts.

If you define your own type named Result in a module, your type will shadow the prelude’s Result in that scope. The compiler will use your definition, and the standard Result will be hidden unless you qualify it as std::result::Result.

More drastically, #![no_implicit_prelude] prevents the automatic import from happening at all. This is sometimes used in macro crates or embedded development, where precise control over the namespace matters:

// crate root with #![no_implicit_prelude]
#![no_implicit_prelude]
// You must now explicitly import everything, including std types
use std::vec::Vec;
use std::prelude::v1::*; // re-enable it if you wish

For most development, you never touch the prelude. It sits quietly in the background, making Rust feel more approachable by ensuring that the building blocks of the language are always within reach.

The prelude works, not magic:

If you are ever unsure why a particular type or trait is available without an explicit use, the answer is almost always the standard prelude. Checking the prelude documentation is the authoritative way to confirm.


Where the Module System Takes You Next

The mechanics covered here — items, library conversion, multiple binaries, attributes, and the prelude — form the connective tissue between individual Rust files and the cohesive projects that the language expects. They are not features you reach for every hour, but they are the rules that govern every file you write.

A few insights that tie these together:

  • Every compilation error about a missing name is, at root, a story about an item that is not reachable along a public path. The fix always involves either marking something pub or adding a use statement that respects the module boundaries.
  • The library-binary split is not a Rust oddity; it is an architectural discipline. Keeping reusable logic in lib.rs and thin entry points in main.rs or src/bin/*.rs makes testing and composition easier in the long run.
  • Attributes and the prelude are the silent partners of the module system: one gives you compile-time leverage over what gets built, the other gives you frictionless access to the most common language surface.