Splitting Modules into Files

Every Rust program starts as a tree of modules, and at first that whole tree usually lives in one file. As a project grows, cramming everything into main.rs becomes painful to navigate, review, and merge. Rust lets you move a module’s code into its own file (or a whole directory of files) without changing what the module tree looks like, how privacy works, or how other code refers to it. This lesson explains exactly how the compiler finds a module’s file, walks through splitting single and nested modules, and covers the mistakes that most often break a newly-split project.

Overview / How It Works

Every Rust crate has a module tree, rooted at the crate’s entry file — src/main.rs for a binary, or src/lib.rs for a library. A mod declaration adds a node to that tree, but Rust needs to know where the contents of that node come from. There are two ways to supply them, and understanding that they are two spellings of the same thing is the key mental model for this lesson.

Write mod greetings { ... } and the module’s contents are the code between the braces, right there in the same file. Write mod greetings; instead — a declaration with no braces, ending in a semicolon — and you are telling the compiler: "this module exists, but its contents are not here; go load them from a file." The compiler then looks for that file using a fixed rule based on where the declaration appears.

For a module declared in the crate root (main.rs or lib.rs), Rust looks for src/<name>.rs. If that module itself contains further file-backed submodules, those live in a directory named after the parent module: src/<name>/<submodule>.rs. The file that represents the parent module’s own body can be named src/<name>.rs sitting as a sibling of the <name>/ directory (the modern, 2018-edition-and-later style), or, in the older 2015-edition style still supported today, src/<name>/mod.rs inside the directory itself. Both work; the modern sibling-file style is preferred because it avoids having many identically-named mod.rs files open in an editor at once.

Crucially, once the compiler has assembled the tree from all these files, it treats the tree exactly the same way regardless of which file each piece of text came from. Privacy rules, path resolution with crate:: and super::, and how use statements work are all determined by a node’s position in the tree, never by which file it happens to be written in. Splitting a module into its own file is purely an organizational move — it changes nothing about how the code behaves.

One consequence surprises newcomers coming from languages that auto-discover source files: Rust does not scan your src/ directory looking for .rs files to compile. A file is only part of your crate if some ancestor module contains an explicit mod declaration naming it. Create src/helpers.rs and forget to write mod helpers; in main.rs, and the compiler behaves as if that file does not exist at all.

Syntax

The general shape of a file-backed module declaration is short: a mod keyword, a name, and a semicolon instead of a body.

mod module_name;

// module_name's contents now live in one of:
//   src/module_name.rs        (modern, 2018+ style)
//   src/module_name/mod.rs    (legacy 2015 style)
Syntax Meaning
mod name; Declares a module and loads its contents from a separate file; private to its parent unless marked pub.
pub mod name; Same as above, but the module itself is visible outside its parent.
mod name { ... } Declares a module with its contents written inline, in the same file.
use path::to::item; Brings an item into scope under a short name so full paths aren’t needed everywhere.
pub use path::to::item; Re-exports an item, making it reachable through the current module’s path too.
crate:: An absolute path starting from the crate root, usable from any file in the crate.
super:: A relative path referring to the parent module, one level up from the current one.

Examples

Example 1: A single module in its own file

The simplest case is one module moved to one sibling file. Here is the project layout and the contents each file would hold:

src/
├── main.rs
└── greetings.rs

// src/main.rs
mod greetings;

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

// src/greetings.rs
pub fn hello() {
    println!("Hello from the greetings module!");
}

To prove the logic is correct without needing two files, here is the exact same code with the module written inline instead of split out — it compiles and runs identically, because mod greetings; plus a separate file and mod greetings { ... } in one file build the identical module tree:

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

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

Output:

Hello from the greetings module!

hello is marked pub because main, outside the greetings module, needs to call it. Drop the pub and this stops compiling, whether the code is inline or split — see Common Mistakes below.

Example 2: Nested modules across a directory

When a module has its own submodules, it gets a directory. Here shapes contains two submodules, circle and square, each in its own file inside src/shapes/, with src/shapes.rs itself holding only the pub mod declarations that point at them:

src/
├── main.rs
├── shapes.rs
└── shapes/
    ├── circle.rs
    └── square.rs

// src/main.rs
mod shapes;

fn main() {
    let circle_area = shapes::circle::area(2.0);
    let square_area = shapes::square::area(3.0);
    println!("Circle area: {:.2}", circle_area);
    println!("Square area: {:.2}", square_area);
}

// src/shapes.rs
pub mod circle;
pub mod square;

// src/shapes/circle.rs
pub fn area(radius: f64) -> f64 {
    std::f64::consts::PI * radius * radius
}

// src/shapes/square.rs
pub fn area(side: f64) -> f64 {
    side * side
}

Again, the inline equivalent compiles and runs to prove the logic, using nested mod blocks in place of nested files:

mod shapes {
    pub mod circle {
        pub fn area(radius: f64) -> f64 {
            std::f64::consts::PI * radius * radius
        }
    }

    pub mod square {
        pub fn area(side: f64) -> f64 {
            side * side
        }
    }
}

fn main() {
    let circle_area = shapes::circle::area(2.0);
    let square_area = shapes::square::area(3.0);
    println!("Circle area: {:.2}", circle_area);
    println!("Square area: {:.2}", square_area);
}

Output:

Circle area: 12.57
Square area: 9.00

Notice src/shapes.rs contains only pub mod circle; and pub mod square; — it is itself just another file-backed module, and its two lines are file-backed module declarations pointing one level deeper. The nesting can continue as deeply as the project needs.

Example 3: Privacy carries over unchanged

This example shows that a private helper function inside a module stays private after the module moves to its own file — privacy depends on tree position, not on which file holds the text. format_price has no pub and can only be called from inside the inventory module itself:

mod inventory {
    pub struct Item {
        pub name: String,
        price_cents: u32,
    }

    impl Item {
        pub fn new(name: &str, price_cents: u32) -> Item {
            Item {
                name: name.to_string(),
                price_cents,
            }
        }

        pub fn price_display(&self) -> String {
            format_price(self.price_cents)
        }
    }

    fn format_price(cents: u32) -> String {
        format!("${}.{:02}", cents / 100, cents % 100)
    }
}

use inventory::Item;

fn main() {
    let item = Item::new("Rust Mug", 1499);
    println!("{}: {}", item.name, item.price_display());
}

Output:

Rust Mug: $14.99

new takes &str because it only needs to read the name to build an owned String with to_string() — it doesn’t need ownership of the caller’s string. If this were split into a file, it would look like this, and would behave identically:

src/
├── main.rs
└── inventory.rs

// src/main.rs
mod inventory;
use inventory::Item;

fn main() {
    let item = Item::new("Rust Mug", 1499);
    println!("{}: {}", item.name, item.price_display());
}

// src/inventory.rs
pub struct Item {
    pub name: String,
    price_cents: u32,
}

impl Item {
    pub fn new(name: &str, price_cents: u32) -> Item {
        Item {
            name: name.to_string(),
            price_cents,
        }
    }

    pub fn price_display(&self) -> String {
        format_price(self.price_cents)
    }
}

// format_price has no pub -- it stays private to the inventory
// module even though it now lives in its own file.
fn format_price(cents: u32) -> String {
    format!("${}.{:02}", cents / 100, cents % 100)
}

How It Works Step by Step

When rustc (via Cargo) builds a crate, it processes the module tree roughly like this:

  • Start at the crate root file and read its top-level items in order.
  • Whenever a mod name; declaration (no braces) is found, compute the expected file location based on where the current file sits in the tree, then look for name.rs next to it, or name/mod.rs inside a directory named after it. If neither exists, compilation fails with an unresolved-module error; if both exist, it fails with an ambiguous-file error.
  • Parse that file’s top-level items exactly as if they had been pasted inline inside mod name { ... } at the point of the declaration.
  • Repeat recursively: any mod declarations found inside that file are resolved relative to a directory named after this module, not the crate root.
  • Once the full tree is assembled from every file, name resolution and privacy checks run over the whole tree uniformly. A path like crate::shapes::circle::area or a relative super::helper is resolved against the tree structure, never against the filesystem layout directly — the filesystem layout just happens to mirror the tree by convention, which is what makes it readable to humans.

Common Mistakes

Mistake 1: Forgetting pub after splitting a module out

Items are private by default. Moving code to its own file changes nothing about that — if neither the module nor the function is marked pub, outside code still cannot reach it, and the compiler rejects the call with a privacy error (module circle is private, in this case, since it fails before even reaching area):

mod shapes {
    mod circle {
        fn area(radius: f64) -> f64 {
            std::f64::consts::PI * radius * radius
        }
    }
}

fn main() {
    let a = shapes::circle::area(2.0);
    println!("{}", a);
}

The fix is to mark both the submodule and the function pub, exposing exactly the path main needs to walk:

mod shapes {
    pub mod circle {
        pub fn area(radius: f64) -> f64 {
            std::f64::consts::PI * radius * radius
        }
    }
}

fn main() {
    let a = shapes::circle::area(2.0);
    println!("{:.2}", a);
}

Output:

12.57

Mistake 2: Creating the file but never declaring the module

Rust does not scan directories for source files. Creating src/helpers.rs does nothing on its own — some ancestor file must contain mod helpers; or the compiler never even looks at it:

src/
├── main.rs
└── helpers.rs

// src/main.rs (missing "mod helpers;")
fn main() {
    let doubled = helpers::double(21);
    println!("{}", doubled);
}

// src/helpers.rs
pub fn double(x: i32) -> i32 {
    x * 2
}

This fails with an unresolved-module error, because as far as the compiler is concerned helpers was never mentioned anywhere. Adding the missing declaration (shown here as the inline equivalent, which compiles) fixes it:

mod helpers {
    pub fn double(x: i32) -> i32 {
        x * 2
    }
}

fn main() {
    let doubled = helpers::double(21);
    println!("{}", doubled);
}

Output:

42

Mistake 3: Mixing the two file-naming conventions

A module’s body must come from exactly one place. If both src/shapes.rs and src/shapes/mod.rs exist at the same time, the compiler cannot tell which one is authoritative and refuses to build:

src/
├── main.rs
├── shapes.rs          (modern-style module file)
└── shapes/
    ├── mod.rs          (legacy-style module file -- conflicts with shapes.rs!)
    ├── circle.rs
    └── square.rs

The compiler reports that the file for module shapes was found at both locations and stops. The fix is to pick one convention and delete the other file — keep src/shapes.rs (the modern style used throughout this lesson) and remove src/shapes/mod.rs, or vice versa, but never both at once.

Best Practices

  • Prefer the modern name.rs plus name/ directory convention over the legacy name/mod.rs style for new code — it avoids having many identically-named mod.rs tabs open at once.
  • Declare a module with mod exactly once, in the file that is its true parent in the tree; every other file that needs it reaches it through use or a fully-qualified crate:: path, never another mod declaration.
  • Keep files focused: when a module file grows past a few hundred lines or starts covering unrelated responsibilities, that is usually a sign to split it into a submodule directory.
  • Use pub(crate) instead of pub for items that only need to be visible elsewhere inside your own crate, keeping your public API surface intentional.
  • Let your directory layout mirror the module tree so the two mental models never diverge and confuse readers navigating the project.
  • Re-export deeply nested but frequently used items at a shallow, convenient path with pub use so callers aren’t forced to memorize long paths.

Practice Exercises

  • Create a small binary crate with a math module split into its own file, src/math.rs, containing pub fn add(a: i32, b: i32) -> i32 and pub fn multiply(a: i32, b: i32) -> i32. Call both from main and print the results.
  • Split math further into two submodules, math::basic and math::advanced, each in its own file inside a math/ directory, moving add and multiply accordingly and updating the call sites in main.rs.
  • Take the Mistake 1 example above, remove pub from just area (leaving pub mod circle in place), and predict which specific identifier the compiler will complain is private before checking your answer against the section’s explanation.

Summary

  • mod name; and mod name { ... } build exactly the same module tree — splitting into files is an organizational choice, not a separate language feature.
  • A file-backed module’s contents are found at name.rs or name/mod.rs relative to its parent’s location; modules with their own submodules get a directory named after them.
  • Rust never auto-discovers source files — a file is only compiled in if some ancestor module declares it with mod.
  • Privacy, use, crate::, and super:: behave identically whether a module’s code is inline or in a separate file.
  • Prefer the modern name.rs plus name/ layout, declare each module exactly once, and use pub or pub(crate) deliberately to keep your API surface intentional.