Organizing Code with Modules

As a Rust program grows past a single file, you need a way to group related code together, hide implementation details, and avoid naming collisions. Rust’s module system does exactly this: it lets you organize functions, structs, enums, traits, and constants into a tree of named namespaces called modules, and lets you control exactly which parts of that tree are visible from the outside. Privacy is not just a convention here — the compiler enforces the boundaries you draw, so a well-designed module layout is also how you build safe, encapsulated APIs in Rust. This lesson covers how the module tree works, the syntax for declaring and nesting modules, how visibility (pub) and paths work, and how modules map onto files as a project grows.

Overview: How Modules Work

Think of a Rust crate (a compiled unit — a binary or a library) as a filesystem. The crate root (src/main.rs for a binary, src/lib.rs for a library) is the top-level directory. Every mod declaration you write creates a new “subdirectory” — a nested namespace that can hold its own functions, structs, enums, traits, constants, and even further nested modules. The result is a tree, and every item in your program has an address in that tree called a path, written with :: as the separator, such as shapes::circle::Circle.

The critical rule that makes this tree useful is privacy: every item — a function, a struct, a struct’s fields, a module itself — is private by default. A private item is visible only inside the module that defines it and that module’s descendants. To make an item reachable from outside its module, you mark it pub. This means the module tree isn’t just an organizational nicety; it is literally how you decide what counts as your crate’s public API versus its private internals. Code outside a module cannot reach into it and touch something you didn’t explicitly expose, no exceptions, checked entirely at compile time with zero runtime cost.

There are two equivalent ways to write a module. You can write it inline, with the module’s contents in a brace block right where you declare it — mod shapes { ... }. Or, once a module grows large, you can write mod shapes; (note the semicolon, no braces) and the compiler will look for the module’s contents in a separate file, either src/shapes.rs or src/shapes/mod.rs. Both forms produce the exact same module tree; splitting into files is purely an organizational choice for humans, not something the compiler treats differently. Because this lesson’s examples must compile as single self-contained files, every example below uses the inline form — but everything about paths, nesting, and privacy applies identically once you split modules across files, which the Syntax section shows.

Finally, paths can be written two ways: absolute, starting from the crate root with the keyword crate (e.g. crate::shapes::circle::Circle), or relative, starting from the current module using self (this module) or super (the parent module). Typing a full path every time you use something is tedious, so a use declaration brings a path into scope once, letting you refer to the item by its short name afterward.

Syntax

The general forms for declaring and using modules:

// Declaring a module inline
mod module_name {
    // items: fn, struct, enum, trait, const, mod ...
}

// Declaring a module that lives in another file
mod module_name; // looks for module_name.rs or module_name/mod.rs

// Visibility modifiers
pub fn visible_everywhere() {}
pub(crate) fn visible_in_this_crate_only() {}
pub(super) fn visible_to_parent_module() {}
fn private_by_default() {}

// Bringing paths into scope
use crate::module_name::item;
use crate::module_name::{item_a, item_b};
use crate::module_name::item as alias;
pub use crate::module_name::item; // re-export
Modifier Visible from
(nothing) Current module and its descendants only
pub Anywhere the module itself is reachable from
pub(crate) Anywhere within the current crate, not outside it
pub(super) The parent module only
pub(in path) A specific ancestor module and its descendants

When a project splits modules into files, the directory layout mirrors the module tree exactly:

my_project/
├── Cargo.toml
└── src/
    ├── main.rs          // crate root: declares `mod shapes;`
    ├── shapes.rs         // the `shapes` module: declares `pub mod circle;`
    └── shapes/
        └── circle.rs      // the `shapes::circle` module

Examples

Example 1: A basic module

The simplest module just groups a function under a name. You call it through its path.

mod greetings {
    pub fn hello() {
        println!("Hello from the greetings module!");
    }
}

fn main() {
    greetings::hello();
}

Output:

Hello from the greetings module!

The function hello lives at the path greetings::hello. Because it’s marked pub, code outside the greetings module — here, main, which lives in the crate root — can call it. Drop the pub and this line fails to compile, because main is outside greetings.

Example 2: Nested modules and use

Modules can nest arbitrarily deep, and use lets you avoid typing the full path every time.

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

    impl Rectangle {
        pub fn new(width: f64, height: f64) -> Rectangle {
            Rectangle { width, height }
        }

        pub fn area(&self) -> f64 {
            self.width * self.height
        }
    }

    pub mod circle {
        pub struct Circle {
            pub radius: f64,
        }

        impl Circle {
            pub fn area(&self) -> f64 {
                std::f64::consts::PI * self.radius * self.radius
            }
        }
    }
}

use shapes::Rectangle;
use shapes::circle::Circle;

fn main() {
    let rect = Rectangle::new(3.0, 4.0);
    let circ = Circle { radius: 2.0 };

    println!("Rectangle area: {}", rect.area());
    println!("Circle area: {:.2}", circ.area());
}

Output:

Rectangle area: 12
Circle area: 12.57

circle is a module nested inside shapes, and it must itself be marked pub mod circle — every module on the path from the caller down to the item must be public, not just the final item. The two use lines bring Rectangle and Circle into scope by their short names, so main can write Rectangle::new(...) instead of the longer shapes::Rectangle::new(...).

Example 3: Encapsulation with a realistic example

Modules are how you hide internal state. Here, Account‘s balance field stays private — outside code can only change it through methods that enforce the rules.

mod bank {
    pub struct Account {
        owner: String,
        balance: i64,
    }

    impl Account {
        pub fn new(owner: &str) -> Account {
            Account {
                owner: owner.to_string(),
                balance: 0,
            }
        }

        pub fn deposit(&mut self, amount: i64) {
            self.balance += amount;
        }

        pub fn withdraw(&mut self, amount: i64) -> Result<(), String> {
            if amount > self.balance {
                return Err(format!("{} has insufficient funds", self.owner));
            }
            self.balance -= amount;
            Ok(())
        }

        pub fn balance(&self) -> i64 {
            self.balance
        }
    }
}

use bank::Account;

fn main() {
    let mut acct = Account::new("Priya");
    acct.deposit(500);

    match acct.withdraw(700) {
        Ok(()) => println!("Withdrawal succeeded"),
        Err(e) => println!("Withdrawal failed: {}", e),
    }

    acct.deposit(300);
    match acct.withdraw(700) {
        Ok(()) => println!("Withdrawal succeeded, balance is now {}", acct.balance()),
        Err(e) => println!("Withdrawal failed: {}", e),
    }
}

Output:

Withdrawal failed: Priya has insufficient funds
Withdrawal succeeded, balance is now 100

Notice owner and balance have no pub keyword, even though Account itself is pub. A struct being public does not make its fields public — each field’s visibility is independent. Outside code can only interact with the balance through deposit, withdraw, and balance, so it’s impossible to set a negative balance or bypass the insufficient-funds check by poking the field directly.

How It Works Step by Step

When rustc compiles your crate, module resolution happens roughly like this:

  • 1. Discover the tree. Starting at the crate root, the compiler reads every mod name { ... } or mod name; declaration. For the semicolon form, it loads name.rs (or name/mod.rs) and recurses into it, discovering any further mod declarations there.
  • 2. Attach a visibility to every item. Each function, struct, field, and module gets a privacy marker: private (the default) or one of the pub variants.
  • 3. Resolve every path. For each path like shapes::circle::Circle, the compiler walks the tree edge by edge from the path’s starting point (the crate root for absolute paths, or the current module for relative ones). At each step, it checks: is this edge visible from where the path is being used? An item is reachable only if every module along the way, and the final item itself, is visible from the call site.
  • 4. Apply use as a shortcut, not a copy. A use declaration doesn’t duplicate code or change what’s compiled — it just registers a short alias for a path in the current scope, resolved once at compile time.
  • 5. Reject violations. If any path in your program reaches for something that isn’t visible from the call site, compilation stops with an error (commonly E0603 for a private item or E0616 for a private field) — there is no way to work around this at runtime, because the check never happens at runtime in the first place.

Common Mistakes

Mistake 1: Forgetting pub, then being surprised the item is private

Everything is private by default, including struct fields even when the struct itself is public. Trying to construct or read fields from outside the module fails:

mod shapes {
    struct Square {
        side: f64,
    }

    impl Square {
        fn area(&self) -> f64 {
            self.side * self.side
        }
    }
}

fn main() {
    let sq = shapes::Square { side: 4.0 }; // error: struct `Square` is private
    println!("{}", sq.area());             // error: method `area` is private
}

Neither Square, its field side, nor the method area carry pub, so nothing about them is reachable from main. The fix is to mark exactly the pieces you intend to expose:

mod shapes {
    pub struct Square {
        pub side: f64,
    }

    impl Square {
        pub fn area(&self) -> f64 {
            self.side * self.side
        }
    }
}

fn main() {
    let sq = shapes::Square { side: 4.0 };
    println!("{}", sq.area());
}

Output:

16

Mistake 2: Using a short name without the correct path

A nested module’s items are not automatically in scope just because they’re marked pub — you still need to write (or use) the full path from where you are:

mod outer {
    pub mod inner {
        pub fn greet() {
            println!("Hi from inner");
        }
    }
}

fn main() {
    inner::greet(); // error[E0433]: failed to resolve: use of undeclared crate or module `inner`
}

From main‘s point of view, inner doesn’t exist on its own — it only exists as outer::inner. Either spell out the full path, or bring it into scope with use:

mod outer {
    pub mod inner {
        pub fn greet() {
            println!("Hi from inner");
        }
    }
}

fn main() {
    outer::inner::greet();
}

Output:

Hi from inner

Best Practices

  • Start every item private and add pub only when something outside the module genuinely needs it — it’s much easier to widen an API later than to narrow one that’s already public.
  • Prefer pub(crate) over full pub for items that other parts of your own crate need but that shouldn’t be part of the crate’s external API.
  • Use use to shorten paths you type often, but avoid glob imports (use module::*;) in regular code — they hide where a name comes from; they’re fine for test modules or well-known preludes.
  • Use pub use to re-export a deeply nested item at a shallower, more convenient path, giving users of your crate a clean public API that doesn’t mirror your internal file layout.
  • Once a module’s inline block grows past a screenful of code, split it into its own file with mod name; — the module tree and privacy rules stay identical.
  • Keep struct fields private and expose behavior through methods, as in the bank account example, so invariants (like “balance never goes negative”) can’t be bypassed.
  • Put unit tests in a #[cfg(test)] mod tests submodule inside the file they test — tests can see private items because they’re nested inside the same module.

Practice Exercises

  • Exercise 1: Write a module named math containing public functions add(a: i32, b: i32) -> i32 and multiply(a: i32, b: i32) -> i32. Call both from main and print the results. Expected output for add(2, 3) and multiply(2, 3): 5 and 6.
  • Exercise 2: Create nested modules library::fiction and library::nonfiction, each with a public function describe() that prints a short sentence. Bring both into scope with two separate use statements and call them from main.
  • Exercise 3: Inside a module temperature, write a private helper function celsius_to_fahrenheit and a public function report(c: f64) that calls the helper and prints the converted value. Then, as a thought experiment (don’t submit code that fails to compile), predict what error you’d get if main tried to call temperature::celsius_to_fahrenheit directly.

Summary

  • A crate’s items form a tree of modules rooted at the crate root (main.rs or lib.rs); every item has an address in that tree called a path.
  • Everything is private by default — visible only inside its own module and that module’s descendants — until marked pub (or a narrower variant like pub(crate)).
  • A struct being pub does not make its fields public; each field’s visibility is set independently.
  • Modules can be written inline (mod name { ... }) or in a separate file (mod name;, looking for name.rs or name/mod.rs) — both produce an identical module tree.
  • Paths are absolute (from crate) or relative (self, super); every module along a path must be visible from the call site, not just the final item.
  • use brings a path into scope under a short name; it’s a compile-time alias, not a copy of code.
  • Privacy is enforced entirely at compile time with no runtime cost, making modules Rust’s primary tool for building safe, encapsulated APIs.