Understanding workspace builds

Hello everyone,

I am trying to deepen my understanding of how workspaces work during compilation, and have the following example I am puzzled with

Imagine the following workspace:

[workspace]
resolver = "3"
members = ["math", "add-example", "subtract-example"]

[workspace.dependencies]
math = { path = "./math" }

The add-example, and subtract-example are binary crates, and both depend on the math crate via different features. Here are the Cargo.toml files for each individual crate:

# Path: math/Cargo.toml
[package]
name = "math"
version = "0.1.0"
edition = "2024"

[features]
add = []
subtract = []
[package]
name = "add-example"
version = "0.1.0"
edition = "2024"

[dependencies]
math = { workspace = true, features = ["add"] }
[package]
name = "subtract-example"
version = "0.1.0"
edition = "2024"

[dependencies]
math = { workspace = true, features = ["subtract"] }

This is a simplified example of course, but has the following behavior;
If I compile the workspace then build add-example using the --package option, some compilation seems to occur for the add-example crate, as can be seen below.

romaincomeau@cerberus workspace-example % cargo build
   Compiling math v0.1.0 (/Users/romaincomeau/sandbox/workspace-example/math)
   Compiling add-example v0.1.0 (/Users/romaincomeau/sandbox/workspace-example/add-example)
   Compiling subtract-example v0.1.0 (/Users/romaincomeau/sandbox/workspace-example/subtract-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
romaincomeau@cerberus workspace-example % cargo build -p add-example
   Compiling math v0.1.0 (/Users/romaincomeau/sandbox/workspace-example/math)
   Compiling add-example v0.1.0 (/Users/romaincomeau/sandbox/workspace-example/add-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
romaincomeau@cerberus workspace-example %

This is surprising to me, and may be the reason why my organization workspace is quickly taking gigabytes of disk, for a relatively small workspace.
Is it the case that when I build add-example only, after building the workspace, create a smaller build which does not include the features required by subtract-example or else what is the reason behind it?

Thank you in advance for your time :slight_smile:

1 Like

Yes, I think this is exactly what occurs. The term is "feature unification" - when you build an entire workspace, dependencies' features are "unified" by enabling every feature required by at least one package in the workspace.

1 Like

This is also why features need to be additive (see cargo reference). Cargo assumes that different features can be enabled independently and this is what allows it to do this feature unification.

Thanks for the clarification, I think feature unification is perfectly fine for my purposes.

What I am trying to avoid, is incremental compilation occuring for different builds footprints at the same time, hence increasing the size of my cache on disk.

Example, I want to test my workspace as a whole. I can do: cargo test in order to test math, add-example and subtract-example.

However, if I wanted only to test the add-example crate, I would do cargo test --package add-example, which would compile again, this time with a subset of the unified features from the workspace build.


Is there a way to just accept the larger build with unified features, to avoid compilation of smaller subset of features?

Thanks again for your time.

For features of external dependencies, some projects create a “workspace hack” package which is depended on by all workspace packages and which depends on all external packages with all features enabled. cargo-hakari is a tool to manage such a package and also has documentation about the overall idea. (Not a specific recommendation; I have never used cargo-hakari myself.)

For packages within the workspace, however, this becomes more or less “don’t use features” — or if you do, and then have a workspace-hack package that enables them, you lose the ability to test whether your packages work as intended with any features disabled. But features within the workspace are usually less of a problem since the dependency chains are shorter.

You could add another feature F to add-example and subtract-example, which would enable ALL features of math. Then, enable it in each test, so that list of features is the same exactly.

1 Like

Thank you @kpreid.

After reading your comment, I tried out an example and found that even if I used cargo-hakari, it would lead to behavior I'm not happy with

Case in point

# Workspace Cargo.toml
[workspace]
resolver = "3"
members = ["one", "two"]
[workspace.dependencies]
tracing = { version = "0.1.44" }
tracing-subscriber = { version = "0.3.22" }
[package]
name = "one"
version = "0.1.0"
edition = "2024"

[dependencies]
tracing = { workspace = true, features = ["max_level_warn"] }
tracing-subscriber = { workspace = true }
[package]
name = "two"
version = "0.1.0"
edition = "2024"

[dependencies]
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

This is a workspace with two crates (one and two) that depend on tracing and tracing-subscriber. If one enables tracing’s max_level_warn feature (which statically disables anything below the warn log level), and I run crate one and then crate two, I observe that one’s feature choice “leaks” into two, effectively disabling logs below warn in two as well.

Given Cargo’s feature unification (as I understand it), this is expected: Cargo resolves tracing for the workspace with the union of features (here, ["max_level_warn"]). But the outcome is still unintuitive to me.

In fact, running crate two can behave differently depending on how it’s run: running it from the workspace root can cause two to run with max_level_warn enabled, whereas running it as --package two can cause it to rebuild and run without max_level_warn.

I’ve been thinking about the comments here and now have more questions than I did initially. I may need to reconsider my Cargo workspace architecture.

The way Cargo is supposed to be used is “features should be additive”; that is, enabling a feature of a library package should never be detrimental to a dependent’s use of that library; it should only add more functionality that the dependent can choose to use. Under this condition, such a “leak” is never harmful to the functionality of another dependent.

So, in this case, tracing is abusing the feature system as configuration.

1 Like

This makes a lot of sense.

This thread helped me a lot, thank you all!