Modules and Visibility

Master Rust's module system - define modules to control scope, use paths and the use keyword, split code into files, and apply visibility best practices for maintainable code.

Rust programs start in a single file, but real projects quickly grow beyond that. Modules are the mechanism that lets you split a crate into manageable, named blocks of code. They group related functions, structs, enums, and other items together, create a namespace hierarchy to prevent name collisions, and—most importantly—control which parts of your code are visible to the rest of the crate and to external consumers. Together with paths, the use keyword, and explicit visibility rules, modules form the backbone of how you organize and encapsulate Rust code.

Defining Modules to Control Scope

Every crate already has an implicit root module—the file main.rs or lib.rs. You create additional modules inside that root using the mod keyword. A module can be defined inline (right inside the parent) or in a separate file, but the semantics are identical.

Here’s a small inline module that wraps a sound effect:

mod sound {
    fn play() {
        println!("beep");
    }
}
fn main() {
    // This will not compile:
    // sound::play();
}

Trying to call sound::play() from main fails because the function is private by default. In Rust, all items—functions, structs, enums, constants, and even modules themselves—start out as private to the module they’re defined in. Only code inside the same module (and its child modules) can reach them.

Forgotten pub:

Forgetting to add pub is the most common reason code doesn't compile when you first split it into modules. The error message will mention that an item is private; check whether the item and every module along the path need to be made public.

To let main call play, you must annotate both the module and the function with pub:

pub mod sound {
    pub fn play() {
        println!("beep");
    }
}
fn main() {
    sound::play(); // works
}

Making the module pub doesn’t automatically expose everything inside. Each inner item still needs its own pub if outside code should use it. This granularity is deliberate: you can have a public module with a mixture of public API surface and private helper functions.

Parent and child visibility

A child module can always see private items in its parent. The reverse is not true—a parent cannot reach private items in its child without the child’s consent via pub.

mod lobby {
    fn secret_key() -> u32 { 42 }
    pub mod security {
        pub fn check() -> u32 {
            // Child sees parent's private item
            super::secret_key()
        }
    }
}
fn main() {
    let key = lobby::security::check();
    println!("{key}");
    // lobby::secret_key(); // error: private function
}

This asymmetry exists because modules are meant to be boundaries: you can use private details inside a module’s subtree, but callers from above the boundary should only interact through the public contract you’ve defined.

Correct visibility setup:

If after adding the right pub keywords your program compiles, you’ve successfully defined a public module with a clear boundary between exposed and hidden code.

Paths for Referring to Items in the Module Tree

Once you’ve built a module tree, you need a way to name a specific item buried inside it. Paths are the addresses that locate any function, struct, or module in your crate’s hierarchy. They come in two forms:

  • Absolute paths start from the crate root (the crate keyword) or from an external crate name.
  • Relative paths start from the current module, using self for the current module or super to go up one level.

Take a crate with a front_of_house module that contains a hosting sub‑module:

pub mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();
    // Relative path (eat_at_restaurant is in the same module as front_of_house)
    front_of_house::hosting::add_to_waitlist();
}

Both calls work here because eat_at_restaurant is defined at the crate root, where front_of_house is visible. If we had tried to call hosting::add_to_waitlist() from inside a sibling module of hosting, we’d need to either use an absolute path or jump up a level with super.

Using super to reach a parent

The super keyword works like .. in a filesystem path—it goes up one module level:

fn serve_order() {}
mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();              // sibling function
        super::serve_order();      // parent function
    }
    fn cook_order() {}
}

Because back_of_house is a child of the crate root, super inside that module refers to the root. This lets you call functions defined “above” the current module without hard‑coding a full absolute path that might change if the module is moved.

Privacy applies to every segment:

Even if the final item is pub, every module segment in the path must also be pub. A private module anywhere along the chain will cause a compiler error saying the item is inaccessible.

Absolute paths are immune to breakage when you move the calling code to another module—they always resolve from the crate root. Relative paths are shorter but must be updated if the calling code’s location changes. Choose based on whether you prioritize resilience to moving code or conciseness.

The use Keyword and Bringing Paths into Scope

Writing crate::front_of_house::hosting::add_to_waitlist every time quickly becomes noisy. The use keyword creates a shortcut: it brings a path into the current scope so you can refer to its last component directly.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

The idiomatic convention is to bring the parent module of a function into scope, not the function itself. This way hosting::add_to_waitlist makes it obvious at the call site that add_to_waitlist belongs to hosting. For structs, enums, and other items, the convention is to bring the full item path directly:

use std::collections::HashMap;
fn main() {
    let mut map = HashMap::new();
    map.insert("key", 10);
}

You can also import several items from the same scope using nested paths:

use std::{cmp::Ordering, io};
// Equivalent to:
// use std::cmp::Ordering;
// use std::io;

When two imported items have the same name, Rust raises a compilation error. You can resolve it by giving one an alias with as:

use std::fmt::Result;
use std::io::Result as IoResult;

Re‑exporting with pub use

Sometimes you want callers of your crate to access an item through a different path than the internal one you organized. pub use re‑exports a name so that external code can reach it as if it were defined at the new location.

pub mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
pub use crate::front_of_house::hosting;

Now users of this crate can write my_crate::hosting::add_to_waitlist(), even though hosting lives deeper in the module tree internally. This technique lets you hide messy internal structure while presenting a clean public API.

Working with use:

When your imports compile and your code reads naturally, you’ve found the right balance between explicit paths and convenience shortcuts.

Separating Modules into Different Files

Inline modules are excellent for learning and small examples, but production code belongs in separate files. Rust’s compiler looks for module code based on the mod declaration. When you write mod my_module; (with a semicolon, not curly braces), the compiler searches for either my_module.rs or my_module/mod.rs relative to the current file’s location.

The steps to extract an inline module into a file are straightforward but order‑sensitive:

1

Step 1: Remove the inline module block

Start with a module defined inline, like this in src/main.rs:

mod sound {
    pub fn play() {
        println!("beep");
    }
}

Cut the entire block—everything from mod sound { to }—leaving only the mod sound; declaration.

2

Step 2: Create the file and paste the contents

Create a new file src/sound.rs. Paste the module’s contents without the outer mod sound { ... } wrapper. The file should look like:

// src/sound.rs
pub fn play() {
    println!("beep");
}

No mod sound; is needed inside this file—Rust already knows it’s the sound module from the declaration in main.rs.

3

Step 3: Handle sub‑modules with directories

If your module itself contains sub‑modules, you need either:

  • A sound.rs file that declares its sub‑modules with mod sub_module;, and a sound/ directory holding sub_module.rs.
  • Or the older sound/mod.rs file with the sub‑module declarations, alongside sound/sub_module.rs.

New‑style (recommended):

src/
├── main.rs
├── sound.rs          // declares `pub mod effect;`
└── sound/
    └── effect.rs     // contains code for the `effect` sub‑module

In sound.rs:

pub mod effect;

In sound/effect.rs:

pub fn thunder() {
    println!("rumble");
}

Rust will follow the chain: main.rssound.rssound/effect.rs. Mixing the sound.rs style with a sound/mod.rs file for the same module is an error.

4

Step 4: Verify visibility and paths

After splitting into files, the visibility rules still apply exactly as before. If play was pub, it remains pub. Any path you were using to reference items stays the same; only the physical location of the code changed.

The mod declaration is still required:

Every new file module must be declared somewhere. Omitting mod sound; in the parent file means the compiler never sees sound.rs, and you’ll get “unresolved name” errors.

This file‑based splitting scales cleanly. A module with several sub‑modules becomes a directory; a leaf module without children stays a single .rs file.

Minimize Visibility

Rust’s default of private is not an annoyance—it’s a design tool. By keeping everything hidden unless you explicitly expose it, you prevent other code from depending on implementation details you might later change. This reduces the mental load and the risk of breaking downstream users.

Start every module by writing all items as private. Only add pub when another module (or an external crate) genuinely needs to call that item. The same discipline applies to struct fields: even if the struct is pub, its fields are private unless individually marked pub.

pub struct Breakfast {
    pub toast: String,
    seasonal_fruit: String,
}
impl Breakfast {
    pub fn summer(toast: &str) -> Breakfast {
        Breakfast {
            toast: String::from(toast),
            seasonal_fruit: String::from("peaches"),
        }
    }
}

Outside the module, callers can create a Breakfast only through summer and can change the toast freely, but they cannot touch seasonal_fruit. This pattern—private fields with a public constructor—enforces invariants while still offering a usable type.

Enums behave differently: making an enum pub automatically makes all its variants public. The rationale is that an enum’s variants are its entire interface; hiding some would rarely be useful.

Rust also supports more granular visibility modifiers for cases where you want to expose an item within a crate or a specific subtree but not publicly:

pub(crate) fn internal_helper() {}   // visible everywhere in this crate
pub(super) fn parent_visible() {}    // visible only in the parent module

Use these to relax privacy just enough for internal reuse without widening the public API.

Small public surface area:

When you look back at a module and its public items are few and obvious, you’ve likely made the right visibility choices. Future maintenance will be easier because every pub is intentional.

Avoid Wildcard Imports

The glob operator * imports every public name from a module:

use std::io::*;

At first glance this seems convenient, but it pollutes the local namespace with an unknown number of names. The reader of the code can no longer tell where a name comes from without checking the glob’s target, and accidental name collisions become likely.

Consider a file that uses both std::io::Result and std::fmt::Result:

use std::io::*;
use std::fmt::*;   // error: `Result` is ambiguous
fn main() {
    // Which Result is this?
    let _: Result<(), std::io::Error>;
}

The compiler rejects this because Result exists in both modules. You’d be forced to qualify at least one, which negates the supposed convenience of the glob.

Glob imports hide the origin of names:

Even when globs don’t cause a compile error, they make the code harder to audit. A reader scanning the top of the file cannot see which specific items are being used, and tooling that detects unused imports may also be less effective.

The few legitimate use cases for use module::* include:

  • The prelude pattern, where a crate re‑exports a curated set of commonly needed items so users can use my_crate::prelude::*.
  • In test modules, where brevity matters more than explicitness.
  • When you are deliberately re‑exporting an entire public module with pub use module::*.

Outside these situations, prefer explicit imports. They make the dependency of each file visible at a glance.

Re-export Dependencies Whose Types Appear in Your API

If your crate’s public API exposes a type from an external dependency, users of your crate will often need to name that type in their own code. Without re‑exporting, they would have to add your dependency to their Cargo.toml explicitly, just to get the type in scope. That’s a brittle arrangement: if you upgrade the dependency, their code could break because they pinned a different version.

Re‑exporting solves this by making the dependency’s types part of your crate’s public namespace.

// In your library crate, Cargo.toml has `rand` as a dependency.
pub use rand::Rng;
pub fn shuffle<T: Rng>(rng: &mut T, slice: &mut [u32]) {
    // ...
}

Now users can write:

use my_crate::Rng; // re-exported; no need to add `rand` to their own Cargo.toml

This works for traits, structs, and even whole modules. The cost is that you permanently commit to keeping that re‑exported item available in your public API—if you later remove or change it, that’s a breaking change for your users. Re‑export only the types you consider stable and essential to using your crate’s interface.

Re‑export granularity matters:

If your crate exposes several types from the same dependency, consider re‑exporting the whole module (pub use rand;) or only the specific items. The more you re‑export, the more tightly you couple your public API to that dependency’s layout.

Well‑encapsulated public API:

A crate that re‑exports only the dependency items users actually need, while keeping internal helpers private, feels like a self‑contained tool rather than a thin wrapper.


What you’ve learned in this section—defining modules, controlling visibility, navigating paths, and applying use—is the practical toolkit for structuring any Rust crate. The two principles to carry forward are: keep things private unless they must be public, and make paths explicit enough that a newcomer can trace them without guesswork. These habits pay off as projects scale and as you begin to share code with others.