cargo tree is the unsung hero for understanding and optimizing Rust project dependencies, and its most surprising trick is how it can reveal circular dependencies that would otherwise silently bloat your build times.
Let’s see it in action. Imagine a simple project with a few dependencies:
[package]
name = "my_project"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = "0.11"
Running cargo tree in your project directory will produce output like this:
my_project v0.1.0 (/path/to/my_project)
├── tokio v1.35.1
│ ├── bytes v1.3.0
│ ├── futures-core v0.3.30
│ ├── futures-util v0.3.30
│ ├── mio v0.8.11
│ ├── num_cpus v1.16.0
│ ├── parking_lot v0.12.1
│ │ ├── parking_lot_core v0.9.0
│ │ └── cfg-if v1.0.0
│ ├── tokio-macros v0.1.1
│ ├── tokio-runtime v0.1.37
│ │ ├── trace-subscriber v0.1.40
│ │ └── tracing v0.1.40
│ ├── tokio-util v0.7.10
│ │ ├── bytes v1.3.0 (*)
│ │ └── futures-util v0.3.30 (*)
│ ├── tracing v0.1.40 (*)
│ └── winapi v0.3.9
├── reqwest v0.11.22
│ ├── bytes v1.3.0 (*)
│ ├── error-chain v0.12.0
│ ├── futures-util v0.3.30 (*)
│ ├── http v0.2.9
│ │ ├── bytes v3.0.2
│ │ ├── case-insensitive v0.2.0
│ │ ├── http-body v0.4.5
│ │ ├── http-body-util v0.1.0
│ │ ├── hyper v0.14.27
│ │ │ ├── bytes v1.3.0 (*)
│ │ │ ├── futures-util v0.3.30 (*)
│ │ │ ├── http v0.2.9 (*)
│ │ │ ├── http-body v0.4.5 (*)
│ │ │ ├── tokio v1.35.1 (*)
│ │ │ └── tokio-io v0.1.20
│ │ ├── indexmap v2.2.6
│ │ ├── mime v0.3.7
│ │ ├── percent-encoding v2.3.0
│ │ ├── url v2.5.0
│ │ └── version_check v0.9.4
│ ├── hyper v0.14.27 (*)
│ ├── httparse v1.8.0
│ ├── mime v0.3.7 (*)
│ ├── multipart v0.6.0
│ ├── reqwest-charsets v0.1.1
│ ├── reqwest-cookie-store v0.2.0
│ ├── reqwest-disallowed v0.2.0
│ ├── reqwest-headers v0.2.0
│ ├── reqwest-method v0.2.0
│ ├── reqwest-mime v0.2.0
│ ├── reqwest-url v0.2.0
│ ├── url v2.5.0 (*)
│ └── webpki-roots v0.5.0
└── serde v1.0.197
├── serde_derive v1.0.197
└── cfg-if v1.0.0 (*)
The asterisks (*) indicate that a dependency has already been shown higher up in the tree. This is crucial for understanding how many times a specific crate is being pulled in. cargo tree helps you visualize the flattened dependency graph, showing you the effective set of crates your project depends on, including transitive dependencies.
The core problem cargo tree solves is managing dependency complexity. As projects grow, so do their dependencies, and these dependencies often share sub-dependencies. Without a clear view, you might end up with multiple versions of the same crate, leading to increased compile times, larger binary sizes, and potential version conflicts. cargo tree provides the visibility needed to identify redundant dependencies and understand the impact of adding new ones.
Internally, cargo tree works by traversing the dependency resolution graph that Cargo builds during the compilation process. It starts with your direct dependencies and recursively explores their dependencies, building a tree-like structure. It then "prunes" redundant branches by marking already-seen crates with an asterisk, giving you a clear, hierarchical view of what’s actually being included.
You can prune the output further with flags. For instance, --prefix " " adds indentation to make the tree structure more apparent. To see only the direct dependencies, use --depth 1. If you want to see the exact version of each dependency, including those marked with (*), use --all-features. And to specifically target a problematic crate, you can filter the output: cargo tree --prefix " " | grep reqwest.
The exact levers you control are primarily through your Cargo.toml file: adding, removing, or updating dependencies. But cargo tree is your diagnostic tool. If you suspect a dependency is too large, or if compile times are creeping up, cargo tree is the first command to run. You can see, for example, how bytes and futures-util are pulled in by both tokio and reqwest. If reqwest had a newer, more optimized version of bytes available, but tokio forced an older one, that’s a potential area for investigation (though Rust’s dependency resolver is quite good at picking compatible versions).
A common, yet often overlooked, optimization is to check the size of the features you’re enabling. Many crates, like tokio, have a massive number of optional features. Running cargo tree --features "your_crate/feature_you_care_about" can reveal the dependency subgraph for a specific feature. If you notice a feature pulls in a huge amount of code you don’t need, consider disabling it in your Cargo.toml or looking for a more minimal alternative. You can also explicitly pin versions in Cargo.toml to ensure you’re getting specific, tested dependency sets, and cargo tree will reflect those explicit pins.
When a dependency is included multiple times, it’s not always a bug; Cargo’s resolver tries to find a common compatible version. However, if you see many different versions of the same crate, or if a seemingly small dependency pulls in a vast number of other crates, it’s a signal to investigate. You might be able to replace a large dependency with a smaller, more focused one, or to selectively disable features on a dependency to reduce its transitive impact.
The most impactful use case is often identifying and understanding the transitive dependencies that are contributing most to your build times. You might see a seemingly innocent crate like log or tracing appearing dozens of times, but these are usually small and well-optimized. The real culprits are often larger, feature-rich libraries where their default features pull in a wide array of sub-dependencies.
Once you’ve trimmed your dependency tree by disabling unnecessary features or replacing bulky crates, the next step you’ll likely encounter is optimizing the build process itself, perhaps by leveraging parallel compilation more effectively or exploring build caching.