Skip to main content
Rust · lesson 24 of 30
Rust intermediate Lesson 24 of 30

Modules in Rust

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

By SkillByExample · Updated

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

Libraries like 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 Rayon (use rayon::prelude::*) and many other crates. (Tokio had a prelude before 1.0 but removed it, so use tokio::prelude::* no longer compiles.)

Practice

  1. Call the private function. Add println!("gcd = {}", math::gcd(12, 8)); to the single-file example’s main. Read the error, then make it compile.
Solution
mod math {
    pub fn gcd(mut a: u64, mut b: u64) -> u64 {
        while b != 0 { let t = b; b = a % b; a = t; }
        a
    }

    pub fn lcm(a: u64, b: u64) -> u64 {
        a / gcd(a, b) * b
    }
}

fn main() {
    println!("lcm = {}", math::lcm(12, 8));
    println!("gcd = {}", math::gcd(12, 8));
}
$ rustc private.rs
error[E0603]: function `gcd` is private
  --> private.rs:14:32
   |
14 |     println!("gcd = {}", math::gcd(12, 8));
   |                                ^^^ private function
   |
note: the function `gcd` is defined here
  --> private.rs:2:5
   |
 2 |     fn gcd(mut a: u64, mut b: u64) -> u64 {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0603`.
$ rustc private_fixed.rs && ./private_fixed
lcm = 24
gcd = 4

lcm could always call gcd because both live in math, and privacy is checked against the module the call is written in. main is outside math, so it needs pub. Making something pub adds it to the module’s API, so only do it when callers outside really need it.

  1. Reach up with super. Without making gcd public, add a child module math::fractions with a pub fn reduce(num: u64, den: u64) -> (u64, u64) that uses gcd to reduce a fraction. Print 18/24 in lowest terms.
Solution
mod math {
    fn gcd(mut a: u64, mut b: u64) -> u64 {
        while b != 0 { let t = b; b = a % b; a = t; }
        a
    }

    pub mod fractions {
        // A child module can see its parent's private items through super::
        pub fn reduce(num: u64, den: u64) -> (u64, u64) {
            let g = super::gcd(num, den);
            (num / g, den / g)
        }
    }
}

fn main() {
    let (n, d) = math::fractions::reduce(18, 24);
    println!("18/24 = {}/{}", n, d);
}
$ rustc super_gcd.rs && ./super_gcd
18/24 = 3/4

Privacy only blocks access from outside a module. A child module counts as inside its parent, so it can use the parent’s private items. super::gcd compiles here, while math::gcd from main did not in task 1. This is how you share helpers within a subsystem without exposing them.

  1. Split it into files and add transforms. Recreate the geometry layout under a Cargo project and build it before creating transforms.rs. Then write transforms.rs with a scale function that returns a bigger Circle, and use it from main along with Rectangle.
Solution
// src/geometry/transforms.rs
use super::shapes::Circle; // sibling module, reached through the parent

pub fn scale(c: &Circle, factor: f64) -> Circle {
    Circle::new(c.radius * factor)
}
// src/main.rs
mod geometry;

use geometry::shapes::{Circle, Rectangle};
use geometry::transforms::scale;

fn main() {
    let c = Circle::new(5.0);
    let r = Rectangle::new(3.0, 4.0);
    println!("circle area: {:.2}, circumference: {:.2}", c.area(), c.circumference());
    println!("rectangle area: {:.2}", r.area());

    let big = scale(&c, 2.0);
    println!("scaled radius {} -> area {:.2}", big.radius, big.area());
}
$ cargo run -q
error[E0583]: file not found for module `transforms`
 --> src/geometry.rs:2:1
  |
2 | pub mod transforms;
  | ^^^^^^^^^^^^^^^^^^^
  |
  = help: to create the module `transforms`, create file "src/geometry/transforms.rs" or "src/geometry/transforms/mod.rs"
  = note: if there is a `mod transforms` elsewhere in the crate already, import it with `use crate::...` instead

For more information about this error, try `rustc --explain E0583`.
error: could not compile `geo` (bin "geo") due to 1 previous error
$ cargo run -q
circle area: 78.54, circumference: 31.42
rectangle area: 12.00
scaled radius 10 -> area 314.16

A mod declaration is what puts a file into the crate. Without it the compiler never reads the file, and with it the file must exist at one of the two paths the help lists. transforms reaches its sibling shapes through super::, and Circle::new works there because both it and the radius field are pub.

  1. Hide a field. Put Config inside mod settings with name public, version as pub(crate) and secret_key private, plus a pub fn new. From main, try to print all three fields. Then expose only a masked version of the key.
Solution
mod settings {
    pub struct Config {
        pub name: String,
        pub(crate) version: u32,
        secret_key: String,
    }

    impl Config {
        pub fn new(name: &str) -> Config {
            Config { name: String::from(name), version: 1, secret_key: String::from("s3cr3t") }
        }

        // Expose a safe view of the private field instead of the field itself
        pub fn masked_key(&self) -> String {
            format!("{}***", &self.secret_key[..1])
        }
    }
}

fn main() {
    let c = settings::Config::new("app");
    println!("{} v{}", c.name, c.version);
    println!("key = {}", c.masked_key());
}
$ rustc fields.rs
error[E0616]: field `secret_key` of struct `Config` is private
  --> fields.rs:18:28
   |
18 |     println!("key = {}", c.secret_key);
   |                            ^^^^^^^^^^ private field

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0616`.
$ rustc fields_fixed.rs && ./fields_fixed
app v1
key = s***

Only secret_key failed. version is pub(crate), and main is in the same crate. A struct with a private field also can’t be built with a literal outside its module, which is why new is required. This lets the module enforce that every Config it hands out is valid.

  1. Build a two-crate workspace. Make a workspace with a core library that exports pub fn greeting(name: &str) -> String, and a cli binary that depends on it by path and prints core::greeting("workspace"). Build everything at once, then run just cli.
Solution
# Cargo.toml (workspace root)
[workspace]
members = ["core", "cli"]
resolver = "2"
# cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2024"

[dependencies]
core = { path = "../core" }
// core/src/lib.rs
pub fn greeting(name: &str) -> String {
    format!("hello, {}", name)
}
// cli/src/main.rs
fn main() {
    println!("{}", core::greeting("workspace"));
}
$ cargo build --workspace
   Compiling core v0.1.0 (/src/core)
   Compiling cli v0.1.0 (/src/cli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.19s
$ cargo run -q -p cli
hello, workspace

Cargo compiled core first because cli depends on it, and both share one target/ directory and one Cargo.lock at the root. In real projects, give the library a more specific name such as myapp-core. A dependency named core shadows Rust’s built-in core crate inside cli, which works here but becomes confusing once you need anything from the real one.

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.