· 6 min read ·

How Cargo's Build Directory Became a Mess, and What Layout v2 Does to Fix It

Source: rust

The target/ directory in a Rust project is simultaneously one of the most useful and most frustrating parts of the toolchain. Open it after a clean build of a moderately sized workspace and you will find hundreds of files with names like libfoo-3a7b2c1d.rlib, foo-3a7b2c1d.d, and foo-3a7b2c1d.rmeta, all sitting alongside the actual binary you care about. This mixed state, final artifacts alongside intermediate build state, was never a deliberate design decision. It was accretion.

Now the Cargo team is asking people to test a nightly-only flag, -Zbuild-dir-new-layout, that restructures this entirely. The call for testing targets nightly builds from 2026-03-10 onward.

How the Current Layout Came to Be

The early Cargo design, circa 2014-2015, put everything in target/debug/. The logic was simple: compile things, put them there. Cross-compilation added the target/<triple>/debug/ subdirectory later, as an afterthought. When workspaces grew and target/debug/ filled with thousands of .rlib files, Cargo added a deps/ subdirectory to hold those intermediates. Final binaries stayed at the top level of target/debug/.

Then incremental compilation arrived in Rust 1.24 (2018), adding an incremental/ directory. Cargo’s own fingerprinting, its change-detection mechanism independent of rustc’s incremental cache, added .fingerprint/. Build scripts got a build/ subdirectory for their outputs. Each feature appended to the layout without rethinking the whole structure.

The result is a target/debug/ directory that looks something like this:

target/debug/
├── mybinary                    ← final artifact
├── libmylib.rlib               ← also a final artifact
├── .fingerprint/               ← cargo's change detection cache
├── build/                      ← build script output (OUT_DIR lives here)
│   └── mylib-3a7b2c/
│       ├── build-script-build
│       └── out/
├── deps/                       ← intermediate compilation units
│   ├── mylib-3a7b2c.rlib
│   ├── mylib-3a7b2c.rmeta
│   └── mylib-3a7b2c.d
└── incremental/                ← rustc's incremental compilation state
    └── mylib-3a7b2c/

Final binaries sit at the top of debug/, but nearly every other file is intermediate state. There is no stable, documented boundary between “things you care about” and “build artifacts Cargo manages internally.”

What Build Dir Layout v2 Changes

The new layout, enabled with -Zbuild-dir-new-layout on nightly (at least 2026-03-10), separates the two concerns at the top level of the build directory. Final artifacts go to one location; all intermediate build state goes to another.

The Cargo team is also developing the broader build-dir feature, which lets you configure where intermediates land independently of the final target-dir. With Cargo 1.91, users can already set CARGO_BUILD_BUILD_DIR to route intermediates somewhere separate from final outputs. The layout v2 change defines the internal structure within that build directory, which is why the two features interact.

This separation has several practical consequences. Docker multi-stage builds that do COPY target/release/myapp /usr/local/bin/ continue working as expected because the final artifact location stays stable. CI caches can target intermediates and finals independently, which matters when you want fast rebuilds without mixing cache layers. Build caching tools like sccache benefit from a clearer boundary between inputs they intercept and outputs they do not need to replicate.

The Actual Problem: Tools That Read target/ Internals

The Cargo team is asking for testing specifically because a lot of tooling has grown to depend on implementation details that were never part of any public API. The call for testing notes that many projects “need to rely on the unspecified details due to missing features within Cargo.”

The most common failure mode is integration tests that locate compiled binaries by constructing paths from CARGO_MANIFEST_DIR:

// This pattern breaks with layout changes:
let bin = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
    .join("../target/debug/mybinary");

This kind of path construction is brittle. It breaks with cross-compilation targets, custom profile names, CARGO_TARGET_DIR overrides, and now with build-dir-new-layout.

Another common failure is build scripts that locate sibling crate outputs by traversing from their OUT_DIR upward through the directory tree and then back down into another crate’s build output. The specific depth of OUT_DIR within the build directory changes between layouts, so any code that counts directory levels from OUT_DIR to get somewhere else will fail.

A third pattern, flagged explicitly in the call for testing, is inferring the path of a [[bin]] from the path of a [[test]]. The call for testing suggests using std::env::var_os("CARGO_BIN_EXE_*") for Cargo 1.94 and later, with the path inference kept as a fallback for older versions.

CARGO_BIN_EXE_*: The Right API Since 1.43

Cargo has had a stable answer to the “how do I find my binary in tests” problem since Rust 1.43.0, released April 2020. The CARGO_BIN_EXE_<name> environment variable is set automatically when running integration tests, and it expands to the absolute path of the compiled binary named <name> in the workspace.

#[test]
fn test_binary_runs() {
    // env! resolves at compile time; points to the right binary
    // regardless of layout, profile, or target triple
    let bin_path = env!("CARGO_BIN_EXE_mycli");
    let output = std::process::Command::new(bin_path)
        .arg("--version")
        .output()
        .unwrap();
    assert!(output.status.success());
}

This works across all layouts because Cargo itself sets the variable to the correct path for the current build configuration. The variable name uses the [[bin]] name from Cargo.toml, with hyphens preserved. For projects that need to support Cargo versions older than 1.94, the runtime fallback approach still makes sense, but the CARGO_BIN_EXE_* path should be the primary mechanism.

The ecosystem situation here is that projects have not migrated to this API because the old path-construction approach kept working. Six years after stabilization, that changes when the layout shifts. The call for testing is partly an invitation to find these cases before the layout becomes default.

Crater Runs and What They Miss

Before pursuing a default change (tracked in cargo#16147), the Cargo team ran a crater check. Crater is an automated tool that builds and tests every crate on crates.io under a proposed Rust or Cargo change, then reports regressions. It covers tens of thousands of packages and typically takes several days to complete on dedicated Rust project infrastructure.

A clean crater run provides high confidence that public crates do not regress. What it cannot catch is private tooling: internal CI scripts, deployment pipelines, Dockerfiles, Makefile and justfile targets, and xtask workspace patterns that construct paths into target/ by convention. Those failures appear only when teams run their own builds with the flag.

This is the gap the call for testing addresses. The Cargo team can run crater and get a clean result, then have the layout change break real-world projects that do not publish to crates.io. Each report to the tracking issue helps other teams dealing with the same tool find the context they need.

How to Test and What to Look For

With a nightly toolchain from 2026-03-10 or later, the process is direct:

# Run your full test suite under the new layout
cargo +nightly test -Zbuild-dir-new-layout

# Run your release build process end-to-end
cargo +nightly build --release -Zbuild-dir-new-layout

# Isolate whether build-dir separation (not the layout) is the issue:
CARGO_BUILD_BUILD_DIR=build cargo +nightly test

If you find failures, the recommended path is to fix the issue locally (usually by switching to CARGO_BIN_EXE_* or fixing OUT_DIR traversal), then report to the affected upstream tool’s repository with a note on the tracking issue so others can find it.

Tools worth specifically exercising: anything using cargo-nextest, coverage tools like cargo-tarpaulin or cargo-llvm-cov (both of which have historically inspected target/debug/deps/ directly), and any custom workspace tooling built with the xtask pattern that locates build outputs by constructing paths.

What This Signals About Cargo’s Direction

The separation of build-dir from target-dir is part of a longer effort to give Cargo a principled artifact API. Artifact dependencies (RFC 3028), which allow one crate to declare a dependency on the compiled binary output of another, need a stable way to locate those binaries. A clean separation between final outputs and Cargo’s internal build state makes that possible without the path-traversal guesswork that currently permeates the ecosystem.

The migration cost falls primarily on tooling that relied on unspecified implementation details. The stable API to avoid that reliance, CARGO_BIN_EXE_*, has existed since Rust 1.43. The call for testing is the mechanism to surface what six years of compatibility silently preserved, before the default changes and those assumptions stop holding.

Was this interesting?