Question

Binary-Only Dependencies in Rust Cargo

rustrust-cargo

Question

I have a Rust crate that contains both a library and a binary. The library has very few dependencies, but the binary needs additional crates for tasks such as loading files and scoped parallel processing.

My Cargo.toml currently looks like this:

[dependencies.kdtree]
path = "../kdtree"

[dependencies]
rand = "0.3.0"
rustc-serialize = "0.3"
csv = { git = "https://github.com/BurntSushi/rust-csv.git" }
crossbeam = "0.2"
num_cpus = "0.2"

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

[[bin]]
name = "ucitest"
path = "src/bin/main.rs"

Only the library needs kdtree and rand. The other dependencies are needed only by the ucitest binary. However, building the library appears to build the binary-only dependencies too.

Can Cargo declare dependencies that apply only to a specific binary target, so those dependencies are not built when users build only the library?

Short Answer

Cargo dependencies belong to a package, not directly to an individual binary target. To keep command-line or application-only dependencies out of a normal library build, mark them as optional, place them behind a feature, and require that feature for the binary. For a stronger separation, put the binary in its own package in a workspace.

Concept

A Cargo package can contain several targets:

  • a library (src/lib.rs)
  • one or more binaries (src/main.rs or src/bin/*.rs)
  • examples, tests, and benchmarks

However, the [dependencies] table is package-level. Cargo does not support a [[bin.dependencies]] table. A dependency listed as a normal dependency is available to every target in the package and is part of the package's enabled dependency graph.

The usual solution is an optional dependency controlled by a Cargo feature:

  1. Declare dependencies used only by the binary with optional = true.
  2. Create a feature that enables those dependencies.
  3. Add required-features to the binary target.
  4. Build or run that binary with its feature enabled.

This matters for published libraries. Consumers who use only your library should not need to compile command-line parsing, file-format, parallelism, or other application-specific crates.

For a large application, a separate package is often even cleaner: one package provides the reusable library, while another package provides the executable and depends on the library.

Mental Model

Think of a Cargo package as a building containing a shared supply room.

  • Normal [dependencies] are supplies placed in the shared room: every enabled build of the package can use them.
  • A feature is a locked storage cabinet: its supplies are brought in only when the corresponding key is used.
  • required-features is a sign on the binary's door saying, “This room can only be opened when you bring the cli key.”

Cargo does not normally provide a separate dependency room for each binary inside the same package. Features provide a way to make the shared supplies conditional.

Take Quiz

Syntax and Examples

Declare library dependencies normally, and make binary-only dependencies optional.

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

[dependencies]
kdtree = { path = "../kdtree" }
rand = "0.8"

csv = { version = "1", optional = true }
crossbeam = { version = "0.8", optional = true }
num_cpus = { version = "1", optional = true }

[features]
ucitest = ["dep:csv", "dep:crossbeam", "dep:num_cpus"]

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

[[bin]]
name = "ucitest"
path = "src/bin/main.rs"
required-features = ["ucitest"]

Build the library without enabling the binary feature:

cargo build --lib

Build or run the binary with the feature it requires:

cargo run --bin ucitest --features ucitest

The dep: syntax makes it clear that a feature enables a dependency. It also avoids automatically exposing the dependency name itself as a public feature name.

The binary source can import its dependencies normally because Cargo compiles that target only when ucitest is enabled:

use num_cpus;

fn main() {
    println!("Available CPUs: {}", num_cpus::get());
}

Step by Step Execution

Consider this configuration:

[dependencies]
rand = "0.8"
csv = { version = "1", optional = true }

[features]
import-tool = ["dep:csv"]

[[bin]]
name = "import-tool"
path = "src/bin/import-tool.rs"
required-features = ["import-tool"]

And this binary:

use csv::Reader;

fn main() {
    let mut reader = Reader::from_path("data.csv").unwrap();
    println!("Headers: {:?}", reader.headers().unwrap());
}

When you run the following command:

cargo build --lib

Cargo proceeds as follows:

  1. It selects the library target.
  2. It enables no import-tool feature.
  3. The optional csv dependency remains disabled.
  4. Cargo compiles the library and rand, but does not need csv.

When you run this command:

cargo run --bin import-tool --features import-tool

Cargo proceeds as follows:

  1. It enables the import-tool feature.
  2. That feature enables csv through dep:csv.
  3. The binary's required-features condition is satisfied.
  4. Cargo compiles csv, compiles the binary, and runs it.

If you omit --features import-tool, Cargo refuses to build that binary because its required feature is not enabled.

Real World Use Cases

Binary-only dependency features are useful when a reusable library also includes developer-facing tools.

  • Command-line interfaces: Keep clap or another argument-parsing crate out of the library's default dependency set.
  • Data import/export tools: Enable CSV, JSON, spreadsheet, or database drivers only for a conversion executable.
  • Parallel batch processing: Keep thread-pool or parallel-processing dependencies limited to a command-line batch tool.
  • Local administration commands: Put database migrations, maintenance commands, or seed-data tools behind an admin feature.
  • Format-specific utilities: A library can support core operations while optional binaries enable image, archive, or file-format tooling.

For example, a crate that exposes image-processing functions as a library may have a convert-image binary that depends on command-line parsing and filesystem helpers. Library users do not need those tools merely to call the image-processing API.

Take Quiz

Real Codebase Usage

A common layout uses a feature named after the binary's role rather than a dependency:

[features]
cli = ["dep:clap", "dep:anyhow", "dep:csv"]

[dependencies]
clap = { version = "4", features = ["derive"], optional = true }
anyhow = { version = "1", optional = true }
csv = { version = "1", optional = true }

[[bin]]
name = "conformal-cli"
path = "src/bin/conformal-cli.rs"
required-features = ["cli"]

This gives one meaningful switch, cli, for all dependencies needed by the command-line target.

Developers also commonly use these patterns:

  • Keep library code independent: Do not import CLI-only crates from src/lib.rs unless that code is protected by #[cfg(feature = "cli")].
  • Use a default feature carefully: Do not put the CLI feature in default if the goal is a lightweight default library build.
  • Add separate features for separate tools: A package with import, benchmark-tool, and admin binaries can give each one its own feature and dependency set.
  • Split into workspace packages when boundaries grow: If the executable has many dependencies, its own package makes ownership, tests, release behavior, and dependency boundaries clearer.

A workspace layout might look like this:

my-project/
├── Cargo.toml
├── conformal/       # reusable library package
│   └── Cargo.toml
└── ucitest/         # executable package
    └── Cargo.toml

The ucitest package then depends on conformal by path, while its extra dependencies stay completely isolated from the library package.

Common Mistakes

Trying to add [[bin.dependencies]]

Cargo has no per-binary dependency table. This is not valid Cargo configuration:

[[bin]]
name = "ucitest"
dependencies = ["csv"]

Use optional dependencies plus features, or use a separate package.

Forgetting optional = true

This keeps csv as an ordinary package dependency:

[dependencies]
csv = "1"

Even if a feature lists csv, it is already enabled as a normal dependency. Make it optional instead:

csv = { version = "1", optional = true }

Enabling the binary feature by default

This defeats the goal of a lightweight default library build:

[features]
default = ["cli"]
cli = ["dep:csv"]

If cli is a default feature, users get csv unless they explicitly use --no-default-features. Leave it out of default when it is genuinely binary-only.

Forgetting required-features

Without this setting, cargo build --bins may try to compile a binary whose optional dependencies are disabled. Add the feature requirement:

[[bin]]
name = "ucitest"
required-features = ["ucitest"]

Using an optional crate in unconditional library code

This fails when the feature is off:

// src/lib.rs
use csv::Reader;

Either keep that code in the feature-gated binary or conditionally compile it:

#[cfg(feature = "ucitest")]
use csv::Reader;

Feature-gating reusable library API should be intentional, because it changes which APIs are available to library users.

Comparisons

ApproachDependency isolationBest use caseTrade-off
Normal [dependencies]None; dependencies are package-wideSmall projects where every target needs the cratesSimplest configuration, but all normal dependencies are enabled
Optional dependencies + featuresConditional within one packageA library with one or a few small companion binariesUsers must enable the relevant feature to build the binary
Separate workspace packagesStrong; each package has its own manifestLarge CLI/application and reusable library boundariesMore files and package setup
Dev-dependenciesOnly for tests, examples, and benchmarksTest-only helper cratesNot appropriate for a production binary

dev-dependencies may look tempting, but they cannot supply dependencies to a normal binary target. They are only used for development targets such as tests, examples, and benchmarks.

Take Quiz

Cheat Sheet

[dependencies]
# Always needed by the library
core-lib = "1"

# Needed only when a feature is enabled
cli-lib = { version = "1", optional = true }

[features]
cli = ["dep:cli-lib"]

[[bin]]
name = "my-cli"
path = "src/bin/my-cli.rs"
required-features = ["cli"]
# Lightweight library build
cargo build --lib

# Build a feature-gated binary
cargo build --bin my-cli --features cli

# Run it
cargo run --bin my-cli --features cli

# Check the library with no default features
cargo check --lib --no-default-features

Key rules:

  • [dependencies] applies to the package, not an individual binary.
  • Use optional = true to prevent a dependency from being enabled automatically.
  • A feature enables optional dependencies.
  • required-features prevents a binary from being built without its needed feature.
  • Do not include a binary-only feature in default if the default library build should remain light.
  • Use a separate workspace package when the executable is substantial.

FAQ

Can Cargo have dependencies for only one binary?

Not through a dedicated [[bin.dependencies]] table. Cargo dependencies are package-level. Use optional dependencies and features, or move the binary to a separate package.

Does required-features enable a feature automatically?

No. It only prevents Cargo from building the binary unless the feature is already enabled. Pass --features feature-name when building or running the binary.

Should I put binary-only dependencies in dev-dependencies?

No. Dev-dependencies are for tests, examples, benchmarks, and build-time development targets. A normal binary cannot rely on them.

Will optional dependencies be compiled when their feature is disabled?

No. Cargo does not include an optional dependency in the active dependency graph until a feature enables it.

Should the CLI feature be a default feature?

Usually not when your goal is to keep library consumers lightweight. A default feature is enabled in ordinary builds unless users opt out with --no-default-features.

When should I use a workspace instead of features?

Use a workspace when the binary is a significant application with many dependencies, separate release needs, or its own tests and architecture. Use features for a smaller companion tool that naturally belongs with the library.

Can one feature enable several binary-only crates?

Yes. A feature can list multiple optional dependencies, such as cli = ["dep:clap", "dep:csv", "dep:anyhow"].

Take Quiz

Mini Project

Description

Create a small Rust package with a lightweight greeting library and a CSV-reporting binary. The library should work without CSV support, while the binary should become available only when its feature is enabled.

Goal

Configure an optional CSV dependency so cargo build --lib stays lightweight and the report binary runs only with --features report.

Requirements

Create a library function named greeting that returns a String without using the CSV crate. Add csv as an optional dependency. Create a report feature that enables csv. Add a report binary that requires the report feature. Make the binary read CSV data from an in-memory string and print the number of records.

Take Quiz

Keep learning

Related questions

View more