How to split modules across files

Split Rust modules across files by creating a new file and declaring it with the `mod` keyword in the parent file.

When main.rs gets too long

Your main.rs started with ten lines. Now it has three hundred. You have a function for parsing config, another for logging, and a third for calculating trajectories. They're all crammed together. Scrolling takes effort. You can't find the function you need. The file feels like a junk drawer.

Rust solves this with modules. Modules let you split code into separate files while keeping a single logical hierarchy. The compiler stitches them back together before building the binary. You get clean files without losing the ability to call code across boundaries.

Modules are a tree, not just files

Rust's module system is a logical tree. The root is your crate. Everything else hangs off that root. Files are just a convenience for writing code. The compiler maps the module tree to the filesystem, but the tree is what matters.

When you write mod foo;, you are telling the compiler: "There is a module named foo in my tree." The compiler then looks for a file to fill that module. The file location follows strict rules. Get the file wrong, and the compiler complains. Get the module declaration right, and the structure holds.

Think of the module tree like a directory structure in your head. The files on disk are just the pages where you wrote the content. The mod keyword is the table of contents.

The minimal split

Start with a single file. You want to move a function to its own file.

// main.rs
mod greetings;

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

The mod greetings; line declares a module. The semicolon is required. This is a declaration, not a block. The compiler sees this and searches for the file.

In the same directory as main.rs, create greetings.rs.

// greetings.rs
/// Prints a greeting to stdout.
pub fn say_hello() {
    println!("Hello from a separate file!");
}

The function must be pub. Without pub, the function is private to the module. The parent module (main) cannot see it. The compiler enforces this boundary.

Run cargo run. The output is Hello from a separate file!. The split works.

How the compiler finds your files

The compiler uses a simple algorithm to locate module files. When it sees mod foo; inside a file, it looks for one of two paths relative to that file:

  1. foo.rs in the same directory.
  2. foo/mod.rs in a subdirectory named foo.

The compiler checks foo.rs first. If it finds that file, it uses it. If not, it checks for the foo/mod.rs structure. If neither exists, compilation fails.

This dual support exists for historical reasons. Older Rust code often used mod.rs for every module. Modern Rust prefers foo.rs for simple modules and foo/mod.rs only when the module has submodules.

Convention aside: Use foo.rs for modules that don't have children. Use foo/mod.rs when you need to nest submodules inside foo. The mod.rs file acts as the entry point for that directory.

Visibility and the pub keyword

Splitting files introduces visibility rules. Every item in Rust is private by default. A function, struct, or constant defined in a module is invisible to the outside world unless you mark it pub.

This applies to the module itself too. If you declare mod utils; inside another module, utils is private to that parent. Code outside the parent cannot access utils::something. You need pub mod utils; to expose the module.

The visibility chain matters. You can have a public module with private functions. You can have a private module with public functions. The functions are only accessible if the module is accessible.

// main.rs
pub mod config;

fn main() {
    // This works because config is pub.
    let path = config::get_default_path();
}
// config.rs
/// Returns the default configuration path.
pub fn get_default_path() -> &'static str {
    "/etc/myapp/config.toml"
}

/// Helper function, not exposed outside this file.
fn parse_legacy_format(input: &str) -> String {
    input.to_uppercase()
}

parse_legacy_format stays hidden. get_default_path is exposed. This granularity lets you hide implementation details while exposing a clean API.

Trust the privacy system. If you have to make everything pub to get it to work, your module boundaries are likely wrong.

Ergonomics with use statements

Calling my_module::sub_module::deep_function() every time gets tedious. Rust provides use statements to bring items into scope.

// main.rs
mod utils;

fn main() {
    // Verbose path.
    utils::format_date("2024-01-01");

    // Bring the function into scope.
    use utils::format_date;
    format_date("2024-01-01");
}

The use statement creates a local alias. You can rename items during import.

use utils::format_date as fmt;

fn main() {
    fmt("2024-01-01");
}

You can also glob import everything from a module.

use utils::*;

Glob imports are convenient in tests where you want all helpers available. In library code, they can make it unclear where names come from. Convention aside: Avoid use * in production code. Explicit imports make the dependency graph readable.

Re-exports and flattening APIs

Sometimes you want to expose an item from a submodule without forcing callers to dig through the hierarchy. Use pub use to re-export.

// main.rs
pub mod api;

fn main() {
    // Callers can use api::User directly.
    let user = api::User::new();
}
// api.rs
pub mod models;

// Re-export User so it appears at api::User.
pub use models::User;
// api/models.rs
pub struct User {
    pub name: String,
}

impl User {
    pub fn new() -> Self {
        User { name: String::from("Anonymous") }
    }
}

The User struct lives in api/models.rs. The pub use in api.rs makes it available as api::User. This flattens the API surface. Callers don't need to know about the internal models module.

Re-exports are a powerful tool for library design. They let you organize code internally while presenting a simple interface to users.

Common pitfalls and compiler errors

Module errors are frequent when you start splitting files. The compiler messages are precise. Learn to read them.

Unresolved import

If you misspell a module name or forget the file, you get E0432.

error[E0432]: unresolved import `my_module`
  --> main.rs:1:5
   |
1  | mod my_module;
   |     ^^^^^^^^^ no external crate `my_module`

Check the file name. Ensure my_module.rs exists in the same directory. Check for typos in the mod declaration.

Private item access

If you try to call a non-pub function from another module, the compiler rejects you with E0603.

error[E0603]: function `helper` is private
  --> main.rs:4:18
   |
4  |     utils::helper();
   |                  ^^^^^^ private function

Add pub to the function definition. Or reconsider if the function should be exposed.

Module not found in path

If you declare mod foo; but the file is in the wrong place, the compiler says it can't find the module.

error[E0583]: file not found for module `foo`
  --> main.rs:1:1
   |
1  | mod foo;
   | ^^^^^^^^
   |
   = help: to create the module `foo`, create file "src/foo.rs" or "src/foo/mod.rs"

The help message tells you exactly where to put the file. Follow it.

Use of undeclared type

If you forget to import a type with use, you get E0412.

error[E0412]: cannot find type `Config` in this scope
  --> main.rs:5:22
   |
5  |     let c = Config::new();
   |                      ^^^^^^ not found in this scope

Add use my_module::Config; or use the full path my_module::Config.

Treat compiler errors as instructions. They rarely lie. Fix the error, and the code moves forward.

Decision matrix

Use foo.rs when the module is simple and has no submodules. This keeps the file structure flat and easy to navigate. Use foo/mod.rs when the module contains submodules. The directory structure mirrors the module hierarchy, making it clear that foo is a container. Use pub use when you want to expose an item from a deeper submodule at a shallower level. This flattens the API and reduces boilerplate for callers. Use use statements to bring frequently used items into local scope. This improves readability without changing visibility. Use pub mod when you want other modules or crates to access the module. Use plain mod when the module is internal implementation detail.

Where to go next