I’ve never needed workspaces. Never used a monorepo either. I’ve also never worked in a massive team. The projects I work on are small enough that a single package per repo works fine, and when I need to coordinate changes across packages, publishing isn’t that painful.
But every major package manager now has workspaces or something like them. In JavaScript: Yarn, npm, pnpm, Bun. In other ecosystems: Cargo (Rust), uv (Python), go.work (Go), Composer (PHP), pub (Dart), Mix (Elixir). Even Bundler and NuGet have workarounds. When every ecosystem independently arrives at the same shape, something structural is going on. So I wanted to understand why.
The basic problem: you have two packages in your repo, and one depends on the other. Without workspaces, you’d have to publish the dependency every time you change it, or manually symlink it and deal with links that persist invisibly across your system, break in subtle ways, and behave differently than published packages.
Workspaces let the package manager wire up local dependencies automatically during install. You edit one package, the other sees the changes immediately. When you publish, normal version resolution takes over.
Common use cases
People often associate workspaces with monorepos, but you don’t need a massive codebase to benefit. Common cases:
- A library and its plugins
- An app with local utilities that won’t be published separately
- A package tested against an example app
- Cloning a dependency locally to debug an issue
Workspaces solve “these packages are developed together.” Monorepos solve “all our code lives in one place.” They overlap but aren’t the same thing. Coordinating changes across multiple repos is painful (separate PRs, separate CI, separate release schedules), which is why monorepos became attractive. Workspaces make monorepos practical by handling the dependency wiring.
How they work in practice
npm (v7+) uses a workspaces field in the root package.json:
{
"workspaces": ["packages/*"]
}
Running npm install creates symlinks from node_modules to each workspace package. If package-b lists package-a as a dependency, npm links to the local copy instead of fetching from the registry. Dependencies get hoisted to the root node_modules where possible, which can cause phantom dependency issues (more on that below). npm has no special publish support for workspaces. The escape hatch for manual linking is npm link.
Yarn works similarly but had workspaces from the start. Yarn 1 popularized the pattern. Yarn Berry (v2+) changed the internals but kept the same configuration. Like npm, Yarn hoists dependencies and has no workspace-aware publishing.
pnpm differs in two ways. First, it doesn’t hoist dependencies to the root. Each package gets its own node_modules with symlinks into pnpm’s content-addressable store. This means packages can only import what they explicitly declare. Second, pnpm has the workspace: protocol:
{
"dependencies": {
"sibling-package": "workspace:*"
}
}
This tells pnpm to always resolve from the workspace, never the registry. When you publish, pnpm replaces workspace:* with the actual version number. npm and Yarn don’t have this, so it’s easier to accidentally publish a package that references a local path. The strict isolation catches dependency bugs earlier, at the cost of some migration pain from npm or Yarn.
Bun supports workspaces with the same configuration as npm and Yarn. It uses the workspaces field in package.json and creates symlinks like the others. Bun’s speed advantage applies to workspace installs too.
Cargo uses a [workspace] section in Cargo.toml:
[workspace]
members = ["crates/*"]
All workspace members share a single Cargo.lock and build into a single target directory. When one crate depends on another via path = "../other", Cargo handles linking. The shared lockfile provides consistency across the workspace. Cargo also unifies feature resolution: if two crates enable different features of the same dependency, Cargo resolves them across the whole workspace rather than duplicating the dependency. cargo publish understands workspace relationships and can publish members in dependency order, making it one of the more complete implementations.
Go took a different approach. Before go.work existed, you’d use replace directives:
replace example.com/mylib => ../mylib
This tells the compiler to resolve that import from a local path instead of fetching it. The directive lives in go.mod and is explicit about what it’s doing.
Go 1.18 added go.work files for multi-module workspaces. Instead of adding replace directives to each module’s go.mod, you create a go.work file at the repo root:
go 1.18
use (
./app
./lib
)
This tells Go to resolve imports across these modules locally. The key difference: go.work is typically kept out of version control. It’s a local development convenience, not part of the published module. For ecosystems like Go (and Swift, which also fetches packages from git), workspaces are partly about short-circuiting the network: without them, you’d have to push a commit just to see if things compile together. Go has no registry to publish to (modules are fetched from version control via proxies like proxy.golang.org), so the publishing coordination problem doesn’t arise in the same way.
Bundler has no formal workspace support. You use path dependencies in the Gemfile:
gem 'my_gem', path: '../my_gem'
This works for development but doesn’t compose with publishing. You’d need to change the Gemfile before releasing. There’s no isolation between gems and no publish support. bundle config local lets you redirect a git dependency to a local path without editing the Gemfile, which is cleaner but still a workaround.
Composer (PHP) supports path repositories. You add a repository entry pointing to a local directory:
{
"repositories": [
{ "type": "path", "url": "../my-package" }
]
}
Composer symlinks the local package. Like Bundler, this is a development convenience without workspace-aware publishing. You’d need to remove the path repository before releasing.
Swift Package Manager handles local development through Xcode’s UI or by editing Package.swift to use a local path:
.package(path: "../MyLibrary")
SPM doesn’t have a central registry (packages are fetched from git), so the publishing coordination problem is similar to Go’s.
pub (Dart/Flutter) added workspace support. You define a pubspec.yaml at the root with a workspace field:
name: my_workspace
workspace:
- packages/app
- packages/shared
Members share a resolution, and pub get links them together. Dart packages are published to pub.dev individually.
Mix (Elixir) has umbrella projects. You create a parent project with child apps in an apps/ directory:
# mix.exs at root
defmodule MyUmbrella.MixProject do
use Mix.Project
def project do
[
apps_path: "apps",
deps: deps()
]
end
end
Each app has its own mix.exs but they share dependencies and can reference each other. Umbrella apps can be published to Hex individually.
NuGet (.NET) uses project references for local dependencies. In a solution, projects reference each other directly:
<!-- In MyApp.csproj -->
<ItemGroup>
<ProjectReference Include="..\MyLibrary\MyLibrary.csproj" />
</ItemGroup>
For centralized dependency management, NuGet supports Directory.Packages.props to share versions across projects. Publishing to nuget.org is per-package.
Common problems
Phantom dependencies. npm and Yarn hoist dependencies to the root node_modules. A package can import something it doesn’t declare, as long as a sibling declared it and it got hoisted. This works in the workspace but breaks when you publish the package and a consumer installs it standalone. pnpm avoids this by not hoisting.1
Version mismatches. In a workspace, "sibling": "^1.0.0" resolves to whatever version is on disk, even if the local package.json says version 2.0.0. The version constraint is ignored during development. You only find out there’s a mismatch after publishing.
Tooling assumptions. Jest, TypeScript, ESLint, and other tools need configuration to understand workspace layouts. Some follow symlinks correctly; some don’t. You end up with config files that exist solely to make tools aware of the structure.
CI divergence. The workspace graph during local development can differ from what CI or consumers see. A dependency that got hoisted locally might resolve differently in a fresh install.
Build orchestration. Workspaces solve where code lives, not how it gets built. If package A is TypeScript and package B imports it, you need to compile A before B can see the types. Workspaces handle linking but not build order. This is why tools like Turborepo and Nx exist on top of workspaces: they understand the dependency graph and run builds, tests, and lints in the right order, with caching.
Publishing coordination. Workspaces wire up development, but publishing is a separate problem. If you update two packages together, you probably want to release them together with matching versions. Workspaces have no opinion on this. Tools like Changesets (JavaScript-only) track changes across workspace packages and coordinate version bumps. Lerna’s lerna publish does something similar. Cargo’s cargo publish can publish workspace members in dependency order, but you still manage versioning manually. npm has scoped packages (@babel/core, @myorg/utils) but scopes are just namespacing for ownership. The registry has no concept of “these packages form a coherent unit.” You publish each package individually and hope consumers update them in sync.
Looking at all this, my sense is that ecosystems made package creation cheap but left coordination expensive. People created lots of small packages, then needed workspaces to manage the friction that created.
As I said at the start, I’ve never needed workspaces myself. If you use them regularly, I’d be curious to hear what pushed you there and whether they’ve been worth the complexity. What’s worked? What’s bitten you? Let me know on Mastodon.
-
pnpm’s motivation explains their non-flat
node_modulesstructure and why it prevents phantom dependencies. ↩