Skip to main content
Rust intermediate Lesson 24 of 30

Modules in Rust

Organize code with mod, pub, use, crate structure, and Cargo workspaces.

Modules in a Single File

Modules are Rust’s namespace mechanism. They let you group related functions, structs, and types together, control what is visible to the outside world, and avoid name collisions between unrelated parts of a codebase. Items inside a module are private by default — you opt into visibility with pub rather than opting out of it.

// src/main.rs

mod math {
    // Private by default — only callable within this module
    fn gcd(mut a: u64, mut b: u64) -> u64 {
        while b != 0 { let t = b; b = a % b; a = t; }
        a
    }

    // pub — accessible from outside the module
    pub fn lcm(a: u64, b: u64) -> u64 {
        a / gcd(a, b) * b  // can call private gcd here since we're in the same module
    }

    // Modules can be nested to any depth
    pub mod stats {
        pub fn mean(data: &[f64]) -> f64 {
            data.iter().sum::<f64>() / data.len() as f64
        }

        pub fn variance(data: &[f64]) -> f64 {
            let m = mean(data);
            data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / data.len() as f64
        }
    }
}

use math::stats; // bring stats into scope to avoid typing math::stats every time

fn main() {
    println!("{}", math::lcm(12, 8));      // 24
    println!("{:.2}", stats::mean(&[1.0, 2.0, 3.0, 4.0, 5.0])); // 3.00
    println!("{:.2}", stats::variance(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])); // 4.00
}

Separating Modules into Files

As a project grows, keeping all code in one file becomes unwieldy. Rust lets you move each module into its own file. The mod geometry; declaration in main.rs tells the compiler to look for the module’s content in src/geometry.rs, and sub-modules follow the same pattern recursively.

src/
├── main.rs
├── geometry.rs
└── geometry/
    ├── shapes.rs
    └── transforms.rs

src/main.rs

mod geometry;  // compiler loads src/geometry.rs

use geometry::shapes::Circle;

fn main() {
    let c = Circle::new(5.0);
    println!("area: {:.2}", c.area());
}

src/geometry.rs

// Declares sub-modules; compiler loads src/geometry/shapes.rs etc.
pub mod shapes;
pub mod transforms;

src/geometry/shapes.rs

use std::f64::consts::PI;

pub struct Circle {
    pub radius: f64,
}

impl Circle {
    pub fn new(radius: f64) -> Self { Self { radius } }
    pub fn area(&self) -> f64 { PI * self.radius * self.radius }
    pub fn circumference(&self) -> f64 { 2.0 * PI * self.radius }
}

pub struct Rectangle {
    pub width: f64,
    pub height: f64,
}

impl Rectangle {
    pub fn new(width: f64, height: f64) -> Self { Self { width, height } }
    pub fn area(&self) -> f64 { self.width * self.height }
}

Visibility Modifiers

Rust’s visibility system is fine-grained. Rather than just public or private, you can restrict visibility to a specific module boundary. This lets you share implementation details between sibling modules in a crate without exposing them to users of the crate’s public API.

pub struct Config {
    pub name: String,          // visible to everyone — part of the public API
    pub(crate) version: u32,   // visible anywhere within this crate only
    pub(super) timeout: u64,   // visible in the parent module only
    secret_key: String,        // private — visible only within this module
}

mod inner {
    pub fn public_fn() {}
    fn private_fn() {}         // callable only from within `inner`

    pub(super) fn super_fn() {} // callable from the parent module
}

The use Keyword

use brings items into scope so you can refer to them by their short name. Without it, you would need to write the full path every time. use also enables re-exporting — marking an item pub use makes it part of your module’s public API even though it is defined elsewhere, letting you design a clean public surface that is independent of your internal file structure.

use std::collections::HashMap;
use std::fmt::{self, Display, Formatter};  // self brings fmt itself, Display and Formatter separately
use std::io::{self, Read, Write};

// Glob import — convenient but risks name collisions; use sparingly
use std::collections::*;

// Alias — useful when two items have the same short name
use std::collections::HashMap as Map;

// Re-export — Circle becomes part of this module's public API
pub use crate::geometry::shapes::Circle;

Paths — Absolute vs Relative

Rust paths work like filesystem paths. crate:: is the root of the current crate (like /). super:: goes up one module level (like ../). Relative paths are shorter but can break if you move code; absolute paths are more explicit. Both are valid — prefer whichever is clearer in context.

mod outer {
    pub fn foo() {}

    pub mod inner {
        pub fn bar() {
            crate::outer::foo();  // absolute path from crate root
            super::foo();         // relative path — go up one level to outer, then foo
        }
    }
}

fn main() {
    crate::outer::foo();         // absolute
    crate::outer::inner::bar();

    outer::foo();                 // relative (from crate root in main.rs)
    outer::inner::bar();

    use outer::inner::bar;        // bring into scope for shorter call syntax
    bar();
}

Library Crate (lib.rs)

A crate with src/lib.rs is a library. Its public API is whatever is pub at the top level of lib.rs. Re-exporting the most important types at the top level with pub use makes your library ergonomic to use — consumers can write use my_lib::Parser instead of use my_lib::parser::inner::Parser.

// src/lib.rs
pub mod parser;
pub mod renderer;

// Flatten the public API — consumers don't need to know the internal module structure
pub use parser::Parser;
pub use renderer::{Renderer, RenderOptions};

/// Library version from Cargo.toml — available at compile time
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

Users of the crate:

use my_lib::{Parser, Renderer}; // top-level re-exports — clean and simple
use my_lib::renderer::RenderOptions; // or import from the module directly

Cargo.toml and Dependencies

Cargo.toml is the manifest for your crate. It declares metadata, dependencies, features, and the location of binary entry points. Understanding its structure lets you build libraries, multiple binaries, and feature-gated code all within one package.

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

# Multiple binary targets in one package
[[bin]]
name = "server"
path = "src/bin/server.rs"

[[bin]]
name = "cli"
path = "src/bin/cli.rs"

[lib]
name = "my_project"
path = "src/lib.rs"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

[dev-dependencies]
# Only compiled when running tests or examples
tempfile = "3"

[features]
default = ["tls"]
tls = ["dep:rustls"]

Cargo Workspaces

Workspaces let multiple related crates share a single Cargo.lock file and build cache. This is the standard structure for larger projects — a core library crate, a server binary, and a CLI binary all share dependencies and are built and tested together. Changes to the core library automatically trigger rebuilds of the crates that depend on it.

workspace/
├── Cargo.toml        # workspace manifest — lists all members
├── core/             # shared library crate
│   ├── Cargo.toml
│   └── src/lib.rs
├── server/           # binary crate — depends on core
│   ├── Cargo.toml
│   └── src/main.rs
└── cli/              # binary crate — depends on core
    ├── Cargo.toml
    └── src/main.rs

workspace/Cargo.toml

[workspace]
members = ["core", "server", "cli"]
resolver = "2"

# Declare shared dependency versions once — member crates inherit them
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

workspace/server/Cargo.toml

[package]
name = "server"
version = "0.1.0"
edition = "2021"

[dependencies]
core = { path = "../core" }
serde.workspace = true   # inherit version from workspace — no duplication
tokio.workspace = true

Build and test everything at once:

cargo build --workspace
cargo test --workspace
cargo run -p server

The prelude Pattern

Large libraries like Tokio and Rayon expose a prelude module containing the most commonly needed items. Users can do a single wildcard import to get everything they need without listing dozens of individual imports. If you are writing a library with many frequently used types, the prelude pattern is worth adopting.

// src/lib.rs
pub mod prelude {
    // Re-export the items most users will need — a curated convenience bundle
    pub use crate::{Parser, Renderer, RenderOptions, Config};
    pub use crate::error::{Error, Result};
}

Users can then write:

use my_lib::prelude::*;  // one line brings in everything commonly needed

This pattern is used by Tokio (use tokio::prelude::*), Rayon, and many others.

Frequently Asked Questions

What is the difference between a module and a crate?
A crate is the smallest unit of compilation — a binary or library. A module is a namespace inside a crate for organizing code. A crate can contain many modules.
How does Rust find module files?
For mod foo; in main.rs or lib.rs, Rust looks for src/foo.rs or src/foo/mod.rs. With the 2018+ style, src/foo.rs is preferred. For mod bar; inside src/foo.rs, Rust looks for src/foo/bar.rs.
What does pub(crate) mean?
pub(crate) makes an item visible anywhere within the current crate but not to external consumers. It is more restrictive than pub and more permissive than the default private visibility.