How to Split a Crate into Multiple Files

Split a Rust crate by creating a lib.rs root and declaring separate module files to organize code logically.

The scroll of shame

You wrote a tool that does something useful. It started as a quick script in main.rs. Now it has 900 lines. You're scrolling past the same helper function five times just to find the bug in the parser. You want to move that parser to its own file so your brain can breathe.

Rust doesn't let you just create a file and expect magic. The compiler won't scan your src folder and guess how things connect. You have to tell Rust exactly how the pieces fit together. This is the module system. It feels like extra typing at first, but it gives you total control over what gets compiled, what stays private, and how users interact with your code.

The module tree is a map you draw

Rust organizes code using a module tree. The crate root is the trunk. Everything else branches off. When you declare a module, the compiler looks for a file, reads it, and injects the code into the tree.

The crate root depends on what you're building. If you have src/lib.rs, that file is the root of the library. If you have src/main.rs, that file is the root of the binary. You can have both. In that case, lib.rs defines the library, and main.rs uses it like any other crate.

The key rule: private by default. Every item in a module is private to that module unless you mark it pub. This prevents accidental leaks. You have to consciously decide what the outside world can see.

Minimal split

Start with the simplest case. You have lib.rs and you want to move a function to utils.rs.

// src/lib.rs
/// The library root. Declares the module structure.
mod utils;

/// Public function that uses the helper.
pub fn run() {
    // Access the item through the module path.
    utils::helper();
}

// src/utils.rs
/// Helper logic separated for clarity.
/// Marked pub so lib.rs can access it.
pub fn helper() {
    println!("Helper called");
}

The mod utils; line in lib.rs tells the compiler to look for src/utils.rs. If the file exists, the compiler reads it. The code inside becomes part of the utils module. Because helper is marked pub, lib.rs can call it. If you remove pub, the compiler rejects the code.

How the compiler stitches files together

When the compiler sees mod name;, it performs a search. In modern Rust (2018 edition and later), it looks for src/name.rs first. If that file exists, it uses it. If not, it looks for src/name/mod.rs. This second option exists for legacy code and for modules that contain sub-modules.

The content of the file is injected into the module tree. Paths inside the file are resolved relative to the crate root, not the file location. This means you can reference items from other modules using crate:: paths.

// src/models.rs
/// Data structures for the application.
pub struct Config {
    pub debug: bool,
}

// src/utils.rs
/// Utilities that depend on models.
use crate::models::Config;

/// Check if debug mode is enabled.
pub fn is_debug(config: &Config) -> bool {
    config.debug
}

Using crate::models::Config makes the source explicit. It avoids ambiguity. You don't have to guess where Config came from. The path starts at the root and drills down. This is the recommended style for cross-module references.

Visibility: private by default

Splitting files forces you to think about visibility. pub makes an item public to the world. That's often too much. You might want a helper to be visible to other modules in your crate, but not to users of your library.

Rust provides granular visibility modifiers.

// src/lib.rs
mod internal;

/// Public API for users.
pub fn do_work() {
    // internal::secret is visible here because it's pub(crate).
    internal::secret();
}

// src/internal.rs
/// Internal helper. Visible only within this crate.
/// Users of the library cannot access this.
pub(crate) fn secret() {
    println!("Internal logic");
}

pub(crate) restricts visibility to the current crate. This is essential for testing. You can mark test helpers pub(crate) so your tests can see them, while keeping them hidden from external users.

Other modifiers include pub(super) for visibility up to the parent module, and pub(self) which is equivalent to private. Use pub(crate) when you need cross-module access inside the crate. Use pub only for the public API.

Realistic structure: library and binary

A common pattern is to split a binary crate into a library part and a binary part. The library holds the logic. The binary holds the entry point and CLI parsing. This makes the logic testable without running the binary.

// src/lib.rs
/// Core logic for the application.
mod parser;
mod models;

/// Re-export main types for the binary and tests.
pub use models::AppConfig;
pub use parser::parse_args;

// src/parser.rs
/// Argument parsing logic.
use crate::models::AppConfig;

pub fn parse_args(args: &[String]) -> AppConfig {
    AppConfig { verbose: args.contains(&"--verbose".to_string()) }
}

// src/models.rs
/// Data models.
pub struct AppConfig {
    pub verbose: bool,
}

// src/main.rs
/// Binary entry point.
use my_crate::parse_args;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let config = parse_args(&args);
    
    if config.verbose {
        println!("Verbose mode on");
    }
}

In Cargo.toml, you need to tell Cargo about the library.

[package]
name = "my_crate"
version = "0.1.0"
edition = "2021"

[lib]
name = "my_crate"
path = "src/lib.rs"

The binary can use my_crate::... because lib.rs re-exports the items. The library encapsulates the logic. The binary is thin. This structure scales well. You can add more modules to the library without touching main.rs.

Convention: flatten the API

Convention aside: Rust crates usually hide their internal module structure. You declare mod parser; to organize your code, but you pub use parser::parse_args; to give users a clean API. Users shouldn't care about your file structure. They just want the function.

Avoid pub mod parser; unless the module name itself is part of the public interface, like std::collections. Most crates prefer flat exports.

// src/lib.rs
mod parser;
mod models;

// Flatten the API. Users do my_crate::parse_args, not my_crate::parser::parse_args.
pub use parser::parse_args;
pub use models::AppConfig;

This keeps the public surface clean. It also gives you flexibility to refactor internal modules without breaking users. If you move parse_args to a different file, the public API stays the same.

Pitfalls

Private module errors

If you forget to mark a module or item public, the compiler rejects access from outside.

// src/lib.rs
mod utils;

// Error: E0603: module `helper` is private
pub use utils::helper;

The error E0603 tells you exactly what is private. Check the chain. If utils is private, you can't re-export from it. If helper is private, you can't re-export it. Mark the necessary items pub or pub(crate).

Unresolved imports

If you use a name that isn't in scope, you get E0432.

// src/utils.rs
// Error: E0432: unresolved import `crate::models::Config`
use crate::models::Config;

This usually means the path is wrong, or the item isn't public enough. Check the module tree. Verify visibility. Ensure the file exists and is declared with mod.

File naming traps

mod foo; expects src/foo.rs. Case matters. On Linux, Foo.rs is different from foo.rs. On Windows, it might not be. Relying on case-insensitive behavior breaks portability. Stick to lowercase with underscores for module files.

Also, avoid mod.rs for top-level modules. Rust 2018 prefers named files. mod foo; looks for foo.rs. Use foo/mod.rs only when foo has sub-modules.

Circular dependencies

Rust handles cross-module references via the crate root, so true circular dependencies are rare. However, you can create confusion by trying to use items in a way that breaks the build order. Always use crate:: paths for cross-module references. Avoid use super::... deep in the tree when crate::... is clearer.

Decision matrix

Use a single main.rs or lib.rs when the code fits comfortably on a few screens and splitting adds no value.

Use mod name; paired with src/name.rs when a logical unit of code grows beyond one file or needs to be shared across multiple modules.

Use pub use to re-export items when you want to present a flat, user-friendly API that hides your internal file organization.

Use pub(crate) when you need an item to be visible to other modules or tests within the crate, but not to external users.

Use crate:: paths when referencing items across modules to avoid ambiguity and make the source of the item explicit.

Use pub mod only when the module name itself is part of the public interface, such as grouping related types under a namespace.

Use src/name/mod.rs only when the module contains sub-modules; otherwise, stick to src/name.rs for leaf modules.

Organize for maintainability, export for usability. Keep the internal structure flexible and the public API stable.

Where to go next