Yarn Workspaces in a Monorepo: Lockfile, Hoisting, and Failure Modes
Yarn workspaces let a monorepo install once from the root, but hoisting is not a guarantee. Learn how the resolution graph works, where phantom dependencies appear, and how to verify the result.
04 Mar 2026, 20:07 UTC

The problem: shared code, divergent installs
When several packages live in one repository but each manages its own node_modules and lockfile, small changes ripple into version drift and duplicated dependencies. A package might pass tests locally because a transitive dependency happens to be present, then fail in CI where the tree is built differently.
The useful takeaway: declare a private root package.json with a workspaces glob, run yarn install once from the repository root, and keep a single lockfile. Yarn then links every matching package into one resolution graph and builds the whole tree in a single pass.
How the workspace resolution graph works
At install time Yarn reads the workspaces globs from the root manifest and treats each matching directory as a package in the graph. It resolves the union of all dependency ranges. If a dependency version satisfies every workspace that requests it, Yarn hoists it to the root node_modules. If two workspaces need incompatible ranges, the conflicting copy stays nested under the workspace that requires it.
This example assumes Yarn 1 Classic, which installs into node_modules by default. The root manifest looks like this:
{
"name": "repo",
"private": true,
"workspaces": [
"packages/*"
]
}
With two packages under packages/, one depending on left-pad@^1.0.0 and the other on left-pad@^2.0.0, the resolution graph contains both ranges. Yarn hoists the version that satisfies the root-level union if one exists; when ranges conflict, each package receives its own nested copy. The result is one lockfile at the root and a single node_modules tree that reflects every declared range.
In Yarn 2+ (Berry), sibling references use the workspace protocol instead of relying on registry resolution. A dependent package would declare:
{
"dependencies": {
"@repo/core": "workspace:^"
}
}
This tells Yarn to use the local copy rather than fetching from a registry. Berry also defaults to Plug'n'Play, which changes the linker behavior described below.
Running tasks across workspaces
To execute a script in every workspace, use the workspace runner rather than a shell loop. The subcommand differs by major version:
- Yarn 1 Classic:
yarn workspaces run <script>, run from the repository root. - Yarn 2+ (Berry):
yarn workspaces foreach <script>, also run from the root.
Because the CLIs diverged, confirm the exact subcommand for your installed major version before wiring it into CI.
Limits and common mistakes
Phantom dependencies
Hoisting is a resolution convenience, not an import guarantee. If a workspace imports a package it never declared, the import may succeed only because another workspace pulled that package into the root node_modules. The build is fragile: removing the sibling dependency breaks it. Only declared dependencies are guaranteed to resolve.
Per-package lockfiles
Committing a lockfile inside an individual workspace reintroduces drift. The single root lockfile is the source of truth; per-package lockfiles can disagree with it and cause different resolutions on different machines.
Installing from inside a workspace
Running yarn install inside packages/foo rather than the root can create a local node_modules and lockfile that diverge from the repo-wide tree. Always install from the root so every contributor and CI job resolves against the same graph.
Berry and Plug'n'Play
Yarn 2+ defaults to Plug'n'Play, which does not create a traditional hoisted node_modules tree. Tools that assume hoisted paths may break. If you need classic behavior, set nodeLinker: "node-modules" in .yarnrc.yml and reinstall. Treat this as a deliberate migration step, not a default.
Duplicated stateful singletons
When incompatible ranges force nested copies, a library that relies on a single shared instance can end up with two copies in one process. Workspaces do not enforce version alignment across packages; they only resolve ranges independently.
Verifying the result
Use the resolver's query command to prove which workspace requested a dependency and where it resolved. From the repository root, run:
yarn why <package-name>
The output should list the workspaces that depend on the package and the resolved path. If a package appears without a matching declaration in the current workspace, you have found a phantom dependency.
In CI, cache the Yarn cache and the lockfile together, then install with the immutable flag so a stale cache cannot silently change resolution:
yarn install --immutable
For Yarn 1 Classic, the equivalent flag is --frozen-lockfile. A clean install should complete without modifying the lockfile; any diff means the committed lockfile no longer matches the manifests.
Version assumptions and current verification
This guidance describes Yarn 1 Classic node_modules behavior unless noted. Yarn 2+ (Berry) differs in default linker, lockfile format, and workspace commands. Because the CLIs and flags are version-sensitive, pin one major version for the repository and document it for the team. Before adopting a new subcommand or configuration override, test it against the real lockfile in a scratch repository and confirm the behavior with your installed Yarn version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.