The src/bin Directory for Multiple Binaries
Learn how to organize multiple binary targets in a single Rust package using the src/bin directory, share code through a library crate, handle modules inside binaries, and configure custom paths when the default convention is not enough
A single Rust package can produce more than one executable. The mechanism Cargo provides for this is a special folder named src/bin. Instead of writing one monolithic main.rs, you can place multiple .rs files under that folder, and each one becomes its own binary target with zero additional configuration.
Cargo’s automatic discovery of binaries is what makes this work. When you run cargo build or cargo run, the build system scans src/bin/*.rs and treats every file it finds there as the crate root of a separate binary. The name of each binary matches the filename — src/bin/hello.rs produces a binary named hello, src/bin/goodbye.rs produces goodbye, and so on. You never have to list these in Cargo.toml unless you need a path that does not follow the flat-file convention.
Why Multiple Binaries Exist in One Package
Many beginners first encounter Rust as a way to write a single executable: cargo new myapp creates a src/main.rs, and that is the whole program. But real projects often need several related tools that share the same domain logic. A web service might ship a server binary and a separate CLI tool for database migrations. A game engine might compile both the game runtime and a level editor from the same codebase. A learning repository might hold dozens of small example programs that all reuse the same helper functions.
Without the src/bin directory, you would have to create a separate Cargo package for each binary — each with its own Cargo.toml, its own dependency list, and its own copy of any shared code. That duplication quickly becomes unmaintainable. The src/bin directory lets you keep all the binaries inside a single package while maintaining a shared library crate at the root of the project. The binaries depend on the library and reuse everything it exports, and Cargo compiles the shared code only once.
You Already Use This Pattern:
If you have ever created a project with cargo new and found a src/main.rs file waiting for you, you have used a single-binary package. Adding a second file to src/bin is the same pattern, just with a second entry point. Nothing about the package fundamentally changes — Cargo simply sees two binaries instead of one.
How Cargo Discovers Binaries Automatically
The rules are simple but exact. When Cargo looks at your src/bin directory, it applies the following logic:
- Every
.rsfile directly insidesrc/binbecomes a binary target. The target name is the file stem:src/bin/server.rs→ binary namedserver. - Every directory inside
src/binthat contains amain.rsfile becomes a binary target. The target name is the directory name:src/bin/worker/main.rs→ binary namedworker. - Files nested deeper than one level are ignored.
src/bin/sub/deep.rsdoes not create any binary unless you explicitly declare it with a[[bin]]table inCargo.toml.
These rules are automatic, meaning you do not need to touch Cargo.toml at all for typical layouts. Cargo picks up new binaries the moment you save a file in the right place.
Consider a project with the following structure:
my_project/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── bin/
│ │ ├── cli.rs
│ │ ├── daemon.rs
│ │ └── worker/
│ │ └── main.rs
Here cargo build produces three binary targets:
clifromsrc/bin/cli.rsdaemonfromsrc/bin/daemon.rsworkerfromsrc/bin/worker/main.rs
You can confirm this by running cargo run --bin followed by a tab in your shell — Cargo will suggest the available binary names. Or, run cargo build --bin cli to build only that one target.
Running a Specific Binary
When a package has more than one binary, cargo run no longer knows which one you mean, so you must specify it:
cargo run --bin cli
The --bin flag tells Cargo exactly which binary to build and execute. You can pass arguments to the binary by placing them after a -- separator:
cargo run --bin cli -- --verbose --output report.txt
Default Binary Without --bin:
If you only have src/main.rs and no src/bin files, cargo run defaults to that single binary. Once you add any file to src/bin, Cargo requires you to pick one explicitly. This is a deliberate design choice that prevents ambiguity when the set of targets grows.
Sharing Code Through the Library Crate
Binaries inside src/bin are separate crate roots. Each one compiles into its own executable with its own main function. They do not share code by default, and they cannot directly import each other’s modules. The standard way to share functionality is to place it in the package’s library crate, rooted at src/lib.rs, and have every binary import from there.
A minimal library file might look like this:
pub fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
pub mod config {
pub fn load() -> String {
"configuration loaded".to_string()
}
}
Each binary can then use the library by its package name (the name field in Cargo.toml):
fn main() {
let message = my_project::greet("World");
println!("{message}");
}
fn main() {
let config = my_project::config::load();
println!("Admin tool: {config}");
}
Package Name Must Match Cargo.toml:
The use my_project::... path uses the package name, not the directory name. Check the [package] section of Cargo.toml — the name field determines how binaries refer to the library. If the name contains hyphens, Rust converts them to underscores in code.
Without a src/lib.rs, the package has no library target, and the binaries would have to contain all logic themselves. That is acceptable for tiny one-off scripts, but the moment two binaries need the same data structure or function, the library becomes the natural home for that shared code.
Organizing Modules Within a Binary Target
A binary file in src/bin is a crate root, just like src/main.rs. It can declare its own modules. The question is where those module files live. If you place them directly inside src/bin, Cargo will interpret every .rs file as a separate binary, not as a module — so mod helper; inside cli.rs will not find src/bin/helper.rs the way you expect.
The solution is to create a directory named after the binary and place both main.rs and the module files inside it. This is the same layout that applies to src/main.rs and library modules, but moved under src/bin.
For example, to give the cli binary a dedicated parser module:
src/
├── bin/
│ └── cli/
│ ├── main.rs
│ └── parser.rs
Inside cli/main.rs, you can now write:
mod parser;
fn main() {
let args = parser::parse_args();
println!("{args:?}");
}
And parser.rs lives safely alongside main.rs without being treated as an independent binary target. Cargo sees the directory cli and looks for main.rs inside it — all other files in that directory are simply part of that binary’s module tree.
Avoid Placing Module Files Next to Flat Binaries:
A file layout like src/bin/server.rs and src/bin/network.rs does not allow server.rs to declare mod network;. Cargo will see both files as separate binaries. If server.rs tries to declare mod network, the compiler will look for network.rs or network/mod.rs relative to the crate root — but since Cargo already treats network.rs as its own crate root, you will get a cryptic error about a missing main function in that file. Use the directory approach instead.
Configuring Binaries That Do Not Fit the Default Convention
The automatic discovery rules work beautifully for flat files and single-directory groupings, but they stop at one level of nesting. You cannot have src/bin/category/tool.rs and expect Cargo to pick it up as a binary named tool. The directory category would need to contain a main.rs, not an arbitrarily named file.
When you need deeper hierarchies or non-standard paths — common in learning repositories that group dozens of examples by topic — you can declare each binary target explicitly in Cargo.toml using [[bin]] tables.
Suppose you have this layout:
src/
├── bin/
│ ├── lifetimes/
│ │ ├── example_1.rs
│ │ └── example_2.rs
│ └── ownership/
│ └── ownership_demo.rs
None of these files are automatically discovered because they are not directly in src/bin and are not inside a directory with a main.rs. To make them work, add the following to Cargo.toml:
[[bin]]
name = "example_1"
path = "src/bin/lifetimes/example_1.rs"
[[bin]]
name = "example_2"
path = "src/bin/lifetimes/example_2.rs"
[[bin]]
name = "ownership_demo"
path = "src/bin/ownership/ownership_demo.rs"
After this, cargo run --bin example_1 works as expected. Cargo no longer guesses — it uses the explicit paths you provided.
Explicit Targets Disable Automatic Discovery for That Name:
Once you add a [[bin]] table with a given name, Cargo will not also automatically discover a file that happens to have the same stem. If you later add src/bin/example_1.rs, the explicit definition takes precedence. Keep this in mind when refactoring.
This approach solves the subdirectory problem, but it comes with a maintenance cost: every new binary requires a new entry in Cargo.toml. For a handful of binaries, this is perfectly fine. For dozens, the file becomes long and repetitive.
Alternatives When the Number of Binaries Grows Large
Explicit [[bin]] tables scale poorly. Before committing to them, consider whether one of these patterns fits your situation better.
Flatten the Structure with Descriptive Names
Instead of grouping files by topic using folders, encode the topic in the filename and keep everything directly under src/bin. This restores automatic discovery and keeps Cargo.toml clean.
src/bin/
├── lifetimes_example_1.rs
├── lifetimes_example_2.rs
├── ownership_demo.rs
The cost is a flat, potentially long list of files. For a repository with twenty or thirty examples, this is usually the simplest and most maintainable approach.
Use a Single Binary with Subcommands
When the binaries are variations of the same tool rather than fundamentally separate programs, you can consolidate them into one binary that dispatches based on a command-line argument. The shared logic stays in the library, and the binary becomes a thin router.
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("lifetimes-1") => my_project::lifetimes::example_1(),
Some("lifetimes-2") => my_project::lifetimes::example_2(),
Some("ownership") => my_project::ownership::demo(),
_ => eprintln!("Usage: my_project <example>"),
}
}
Execution then looks like cargo run -- lifetimes-1. This keeps the Cargo.toml minimal and the project structure conventional, at the expense of always compiling all the example code together. For lightweight examples, that trade-off is negligible.
Separate Workspace Members
For truly independent groups of binaries that have different dependencies or compile-time settings, you can split them into separate packages within a Cargo workspace. Each package follows the standard src/main.rs or src/bin conventions, and all packages share a single Cargo.lock and target directory.
my_workspace/
├── Cargo.toml # [workspace] with members
├── playground/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ └── bin/...
├── lifetimes-examples/
│ ├── Cargo.toml
│ └── src/
│ └── bin/...
└── ownership-examples/
├── Cargo.toml
└── src/
└── bin/...
The workspace approach buys you strong isolation and the ability to add package-specific dependencies, but it introduces more Cargo.toml files and a larger directory tree. It is most valuable when the binaries genuinely belong to different domains and would benefit from independent versioning or feature flags.
There Is No Single Correct Answer:
All of these patterns — explicit targets, flattened files, subcommand binary, and workspace members — are used in real Rust projects. Choose the one that matches the number of binaries you have, how strongly they are related, and who else will maintain the code. A solo learning project with forty examples might be happiest with a flat src/bin. A production multi-tool service might split into workspace members.
How the Build System Sees Multiple Binaries
When Cargo compiles a package with several binary targets, each binary is its own compilation unit with its own crate root. They can all depend on the same library crate, but they do not see each other’s modules. Under the hood, this means Cargo invokes rustc separately for each binary (and once for the library, if it exists). The library’s compiled artifacts are reused across all binaries, so you pay the compilation cost of shared code only once.
You can inspect exactly which targets Cargo knows about with:
cargo metadata --no-deps --format-version 1 | jq '.packages[0].targets'
This is not something you will need every day, but when a binary fails to show up where you expect it, running this command and looking at the name and src_path fields can tell you whether Cargo actually discovered the file or whether a configuration mismatch is at play.
Common Pitfalls
Treating src/bin files as modules for each other. A file in src/bin is a separate crate root, not a module of another binary. There is no mod relationship between two binaries. If you need shared helper functions, they belong in the library crate.
Placing module files directly next to flat binaries. As explained earlier, src/bin/server.rs and src/bin/handler.rs results in two binaries, not one binary with a handler module. Use the src/bin/server/ directory pattern for binaries that need submodules.
Expecting deep subdirectories to be auto-discovered. Only files directly inside src/bin and directories with a main.rs are picked up. Anything deeper requires a [[bin]] table in Cargo.toml.
Forgetting that the library must be public. If a binary tries to use my_project::some_function, that function must be marked pub in the library. Private items in src/lib.rs are invisible to binaries, even though they live in the same package.
Not running cargo clean after restructuring. Sometimes Cargo caches a stale view of the binary targets. If a binary that should exist does not appear, cargo clean followed by cargo build is worth trying before you spend time debugging the configuration.
Summary
The src/bin directory turns a single Rust package into a factory for multiple executables while keeping shared code centralized in a library crate. Cargo’s automatic discovery handles the common cases without any configuration, and the [[bin]] table covers everything beyond the convention. The key architectural insight is that each binary is a separate crate root — it can be as simple as a single file or as complex as a directory with its own module tree, but it always stands apart from other binaries.
When you find yourself reaching for deeply nested subdirectories or dozens of explicit target entries, pause and ask whether a flatter structure, a subcommand-based binary, or a workspace split would serve you better. The tooling supports all these approaches; the right one is the one that keeps your Cargo.toml readable and your mental model of the project clear.