How to use lib.rs vs main.rs

Use main.rs for executable programs and lib.rs for reusable library code in Rust projects.

The split that saves you from copy-paste

You write a command-line tool that parses configuration files. It works. A week later, you need that exact same parsing logic inside a web backend. You copy the file. You paste it. You now maintain two copies of the same bug. Rust gives you a built-in solution that stops this duplication at the project structure level. You split the code into two files: src/main.rs and src/lib.rs.

This split is not a stylistic preference. It is a fundamental division of responsibility enforced by Cargo and the Rust compiler. main.rs builds an executable. lib.rs builds a reusable library. When you keep them separate, you get automatic testing, clean dependency boundaries, and code that other programs can actually import.

Keep the entry point thin. Let the library do the heavy lifting.

How Cargo sees your project

Cargo treats these two files as completely different crate types. A crate is a compilation unit. main.rs produces a binary crate. It expects a fn main() and outputs an executable file. lib.rs produces a library crate. It outputs a static library or an object file that other code links against. The compiler never merges them into one file. It compiles them separately and then links the binary to the library.

Think of main.rs as the stage manager. It handles command-line arguments, sets up the environment, catches panics, and kicks off the show. lib.rs is the script and the props. It contains the actual logic, the data structures, and the business rules. The stage manager does not write the play. It just calls the functions that do.

This separation matters because of visibility. The pub keyword is a gatekeeper. Anything without pub in lib.rs stays inside the library. The binary can only see what you explicitly expose. The compiler enforces this boundary at compile time. If you try to reach across it without permission, you get an error. This strictness prevents accidental API leaks and keeps your public interface intentional.

Treat the pub boundary as a contract. If it is not public, it does not exist to the outside world.

A minimal working split

Here is the smallest project that demonstrates the split. The package name in Cargo.toml is calc_tool. Cargo uses this name for the library crate identifier.

// src/lib.rs
/// Adds two integers and returns the sum.
pub fn add(a: i32, b: i32) -> i32 {
    // We mark this pub so the binary crate can call it
    a + b
}

/// Multiplies two integers and returns the product.
pub fn multiply(a: i32, b: i32) -> i32 {
    // Public functions become part of the library API
    a * b
}
// src/main.rs
/// Runs the binary and prints the result of a library function.
fn main() {
    // The crate name matches the package name in Cargo.toml
    use calc_tool::add;
    
    // We call the library function from the executable entry point
    let result = add(2, 3);
    println!("Result: {}", result);
}

Notice the import path in main.rs. It uses calc_tool::add, not crate::add. When main.rs imports from lib.rs, it treats the library as an external dependency, even though they live in the same folder. Cargo wires this up automatically. You do not need to add the library to Cargo.toml dependencies. The compiler knows they belong together.

Convention aside: run cargo new --lib to generate a project with only lib.rs. Run cargo new --bin to generate only main.rs. Most real projects start with cargo new and add lib.rs manually when the logic outgrows the binary file.

Trust the borrow checker. It usually has a point.

What happens at compile time

When you run cargo build, Cargo does not just compile one file. It resolves the dependency graph first. It sees src/lib.rs and compiles it into a library artifact. It sees src/main.rs and compiles it into a binary artifact. The binary links against the library. This separation happens at compile time, not runtime.

The compiler checks visibility across the crate boundary. If you remove pub from add in lib.rs, the binary fails to compile. You get E0603 (item is private). The compiler tells you exactly which function is hidden and where you tried to use it. This error saves you from shipping a binary that crashes at runtime because a symbol is missing.

The compiler also checks type compatibility. If lib.rs returns a custom struct that is not marked pub, the binary cannot name it. You get E0432 (unresolved import) or E0277 (trait bound not satisfied) depending on how you try to use it. The library controls what crosses the boundary. The binary adapts to that boundary.

This one-way dependency is intentional. The library cannot import from main.rs. The dependency arrow only points from binary to library. If you try to use main::something inside lib.rs, the compiler rejects it. The library must remain self-contained. This rule keeps your project acyclic and prevents circular initialization bugs.

Respect the dependency arrow. The library never looks back at the binary.

Testing without the binary

The real advantage of lib.rs appears when you write tests. You cannot easily unit test a fn main(). The function takes no arguments, prints to stdout, and exits. Testing it requires spawning a subprocess or mocking the entire environment. Library functions are different. They take inputs, return outputs, and live in a testable module.

Put your tests inside lib.rs. Cargo runs them automatically with cargo test. It compiles the library with test harnesses enabled, runs the tests, and reports results. It does not build the binary. This means your tests run faster and do not depend on CLI argument parsing or file paths.

// src/lib.rs
/// Adds two integers and returns the sum.
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Tests only compile when running cargo test
#[cfg(test)]
mod tests {
    // We import the function from the parent module
    use super::*;

    #[test]
    fn test_add_positive() {
        // We verify the public API behaves correctly
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_add_negative() {
        // We cover edge cases without launching the binary
        assert_eq!(add(-1, 1), 0);
    }
}

The #[cfg(test)] attribute tells the compiler to ignore this module during normal builds. It only appears when you run cargo test. This keeps your release binary small. It also lets you test private helper functions if you move them into the same file or mark them pub(crate).

Convention aside: use pub(crate) when you want a function to be visible to tests in the same crate but hidden from external users. It is the standard way to expose test helpers without polluting your public API.

Write tests against the library, not the binary. You will thank yourself when the CLI interface changes.

Common traps and compiler errors

Developers new to the split run into a few predictable mistakes. The first is forgetting pub. You write a function in lib.rs, call it from main.rs, and get E0603. The fix is adding pub to the function signature. The compiler will not guess that you intended to export it.

The second trap is naming collisions. If your package is named my_tool, the library crate is my_tool. If you add a third-party dependency also named my_tool, Cargo gets confused. The compiler throws an unresolved import error. The solution is to rename the binary in Cargo.toml using a [[bin]] section, or rename the external dependency with package = "..." in your dependencies. Keep crate names unique within your workspace.

The third trap is trying to share state across the boundary. You might want main.rs to initialize a database connection and pass it to lib.rs. Do not do it the other way around. The library should not reach out to initialize global state. It should accept connections or configurations as function arguments. This keeps the library pure and testable. If you force the library to manage its own I/O, you lock yourself into a single runtime environment.

The compiler will catch most of these mistakes early. E0432 appears when you misspell a crate name. E0277 appears when you try to use a type that does not implement a required trait. E0308 appears when return types mismatch across the boundary. Read the error codes. They point directly to the boundary violation.

Structure your project around reuse, not just execution.

When to pick which structure

Use src/main.rs when you need an executable entry point that handles command-line arguments, file I/O, or process initialization. Use src/lib.rs when you want to isolate reusable logic, expose a public API, or run unit tests without launching a binary. Use both together when your project ships a CLI tool but also wants to share its core algorithms with other Rust programs. Reach for a separate library crate when the shared code is large enough to warrant its own version history and dependency list. Reach for plain main.rs only when the program is a thin wrapper around a single external service or a one-off script that will never be imported elsewhere.

Keep the boundary clean. Let the compiler enforce it.

Where to go next