When one binary stops being enough
You started with cargo new my-tool. One main.rs, one executable, life was simple. Then a friend asks for a small migration script that uses the same database code. Then you realise you want a separate seed-db command for your local dev box. Suddenly you've got three programs that all share most of their code.
You could split them into three Cargo projects, each pulling in a fourth shared library crate via path dependency. That works. It's also a lot of Cargo.toml files to keep aligned, and it makes cargo run more annoying to use. There's a lighter option: keep everything in one project and let Cargo build several binaries from the same source tree.
This is one of the bits of Cargo that everyone learns eventually but rarely sees laid out cleanly. Here's how it actually works.
The two conventions
Cargo recognises two patterns for binaries inside a single package:
src/main.rsis the default binary, named after your package. This is what you get fromcargo new.- Any file at
src/bin/<name>.rsbecomes a binary called<name>automatically. NoCargo.tomledits required.
For the simplest case, that's the whole story.
my-tool/
├── Cargo.toml
└── src/
├── main.rs ← cargo run (binary: my-tool)
├── lib.rs ← shared library, automatically picked up
└── bin/
├── seed-db.rs ← cargo run --bin seed-db
└── migrate.rs ← cargo run --bin migrate
Each *.rs under src/bin/ compiles as its own binary. They can all use my_tool::... to share code from src/lib.rs. They're built and tested together when you run cargo build or cargo test.
Walking through it
# Cargo.toml — nothing special needed for the basic layout.
[package]
name = "my-tool"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }
// src/lib.rs — shared code lives here. Both binaries can import it.
pub mod db {
/// Open a connection to the database. Used by every binary in this crate.
pub async fn connect() -> sqlx::PgPool {
sqlx::PgPool::connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap()
}
}
// src/main.rs — the default binary, named "my-tool" by default.
// We pull in the shared module via the crate's library name.
use my_tool::db;
#[tokio::main]
async fn main() {
let pool = db::connect().await;
println!("Hello from the main binary, db is alive: {pool:?}");
}
// src/bin/seed-db.rs — automatically becomes the binary "seed-db".
// Same `use` path, same shared code.
use my_tool::db;
#[tokio::main]
async fn main() {
let pool = db::connect().await;
sqlx::query("INSERT INTO users (name) VALUES ('alice')")
.execute(&pool).await.unwrap();
println!("seeded");
}
// src/bin/migrate.rs — third binary, "migrate".
use my_tool::db;
#[tokio::main]
async fn main() {
let pool = db::connect().await;
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
}
Now from the command line:
# Default binary: cargo picks `my-tool` because it's the package name.
cargo run
# Specific extras live behind --bin.
cargo run --bin seed-db
cargo run --bin migrate
# Build all of them at once.
cargo build --release
When you need explicit control
The convention covers 90% of cases. For the remaining 10%, there's the explicit [[bin]] syntax. Use it when:
- A binary's source lives outside
src/bin/. Maybe you want it intools/orcmd/server.rs. - You want a binary's name to differ from its filename.
- You want to set per-binary metadata (different
required-features, for instance).
# Multiple [[bin]] entries, each with a name and path. Cargo no longer
# auto-detects entries that you've explicitly listed.
[[bin]]
name = "server"
path = "src/cmd/server.rs"
[[bin]]
name = "worker"
path = "src/cmd/worker.rs"
# Only build this binary when the "background" feature is enabled.
required-features = ["background"]
[[bin]]
name = "admin"
path = "tools/admin.rs"
A subtle gotcha: the moment you add [[bin]] entries to Cargo.toml, Cargo's auto-detection of src/bin/*.rs is not disabled. It just adds the explicit ones on top. If you want only the listed binaries, set autobins = false:
[package]
name = "my-tool"
version = "0.1.0"
edition = "2024"
autobins = false # disable src/bin/ auto-detection
You probably don't need this until you do. Most projects mix conventions and explicit entries fine.
Sharing code between binaries
The cleanest pattern is what we did above: put shared code in src/lib.rs (or modules referenced from there) and import it from each binary. Cargo automatically builds the library alongside the binaries, and they all link against it.
If you don't want a library crate at all (maybe you're building only CLIs), you can use a sibling module:
src/
├── main.rs
├── shared.rs ← only `main.rs` and bins inside `src/bin/` can use this
└── bin/
└── tool.rs ← cannot `mod shared;` from here directly!
This is the foot-gun. A file in src/bin/ is a separate crate root. It cannot say mod shared; to pull in src/shared.rs, because src/shared.rs belongs to the main binary's module tree, not to the src/bin/ binary's. The fix is almost always to promote the shared code to lib.rs and import it via use my_tool::shared;. That's the canonical Rust way.
Selecting binaries on the command line
Useful flags once you have several binaries:
# Build only one binary.
cargo build --bin seed-db
# Run with arguments. Everything after `--` goes to the program.
cargo run --bin migrate -- --dry-run
# Test only one binary's tests.
cargo test --bin seed-db
# Install all binaries from the workspace into ~/.cargo/bin.
cargo install --path .
# Install just one of them.
cargo install --path . --bin migrate
cargo install is particularly handy when the package has multiple binaries and you only want one in your $PATH.
When to switch to a workspace
Multiple binaries in one package work great until they need different sets of dependencies, or they need to be released on different schedules, or they grow large enough that compile times start to hurt. At that point, split them into a Cargo workspace: one Cargo.toml at the root listing several member crates, each with its own dependency tree.
Workspaces are the right answer when "shared lib + small CLIs" stops describing the project. The single-package approach with src/bin/ is the right answer when it does.