Using the workspace: Protocol Correctly
The workspace: protocol tells pnpm, Yarn Berry and Bun to satisfy a dependency with a package from the same repository — always, with no fallback to the registry. It is the safest way to link packages in a monorepo, but its variants (workspace:*, workspace:^, workspace:~, explicit versions and aliases) behave differently at publish time, and choosing the wrong one produces published packages with overly strict ranges, loose ranges, or no valid range at all. This guide explains each variant, what it becomes when published, and which to use for internal and public packages.
Why the protocol exists
Before workspace protocols, monorepos linked sibling packages by plain semver ranges. If apps/web depended on "@acme/ui": "^1.4.0" and the local @acme/ui was at 1.4.2, the package manager linked the local copy. If the local version drifted outside the range — someone bumped it to 2.0.0 — the package manager quietly installed 1.x from the registry instead, and the app tested against published code while developers edited local code. The resulting bugs are maddening: changes to packages/ui have no effect on apps/web, the lockfile quietly records a registry tarball for a package that also exists locally, and CI tests code that nobody in the repository is editing.
workspace: removes that ambiguity. The dependency must be satisfied from the workspace, or the install fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND — a loud failure instead of a silent registry fallback, covered in Fixing ERR_PNPM_WORKSPACE_PKG_NOT_FOUND. How cross-package dependencies fit together more broadly is covered in Cross-Package Dependency Management.
The variants and what they publish as
When you run pnpm publish (or yarn npm publish, or pnpm pack), every workspace: range in the packed manifest is replaced with a concrete range based on the local package's current version. Assume the local @acme/ui is at 1.5.0:
| In your manifest | Published as | Use when |
|---|---|---|
workspace:* |
1.5.0 (exact) |
Packages released together in lockstep |
workspace:^ |
^1.5.0 |
Independent versioning, allow compatible updates |
workspace:~ |
~1.5.0 |
Allow patch updates only |
workspace:^1.2.0 |
^1.2.0 |
Enforce a minimum local version |
workspace:../ui |
1.5.0 |
Path-based reference (rare) |
The rewrite happens only when the package manager that understands the protocol does the packing. npm publish does not, and publishing with it ships the literal workspace:^ string, which breaks every consumer install — the failure covered in Fixing 'Unsupported URL Type workspace:' After Publishing.
Choosing a variant
Private, internal packages (never published): use workspace:*. The published form never matters, and * means "whatever version is local", so internal packages can stay at 0.0.0 forever.
Public packages released in lockstep — every package gets the same version on each release (Changesets fixed groups, Lerna fixed mode): workspace:* produces exact pins between your packages, which is correct because they are only guaranteed to work together at the same version.
Public packages versioned independently: workspace:^. Consumers get caret ranges that allow compatible updates of each package independently, which avoids duplicate installs when two of your packages depend on slightly different versions of a third.
Peer dependencies between your own packages: use workspace:^ in peerDependencies as well, and keep the peer also listed in devDependencies so it is installed locally. At publish time the peer range becomes ^1.5.0, which may be narrower than you want — many teams instead write an explicit wide peer range such as "@acme/core": "^1.0.0" and rely on the devDependencies entry for the local link.
How the protocol interacts with versioning tools
Release tooling changes versions, and the protocol determines what those version changes mean for dependents. With Changesets, bumping @acme/core from 2.3.0 to 2.4.0 does not require editing @acme/react's manifest at all when it uses workspace:^ — the range is recomputed at publish time from the new local version. What Changesets does decide is whether @acme/react also gets a new release: its updateInternalDependencies setting (patch or minor) controls when a dependent is bumped because a dependency changed. With workspace:* and exact pins, every change to @acme/core must be followed by a release of @acme/react, or the published @acme/react keeps pinning the old core forever. That coupling is correct for lockstep versioning and wasteful for independent versioning, which is the practical reason the variant should follow the strategy.
Lerna-style tools that predate the protocol rewrite plain ranges in manifests on every release instead. If you migrate from such a tool, convert internal ranges to workspace: in one pull request, publish once to confirm the packed manifests contain the expected ranges, and remove any release configuration that edits internal ranges, since the package manager now owns that job.
Edge cases worth knowing
Aliases. "ui": "workspace:@acme/ui@*" installs the local @acme/ui under the name ui. At publish time the alias is rewritten to npm:@acme/ui@1.5.0, which consumers' package managers understand.
Dev-only internal dependencies. Test helpers and shared configuration packages usually appear in devDependencies with workspace:*. They are rewritten too, but devDependencies are not installed by consumers, so the published value rarely matters — as long as it is a valid range and not the literal protocol string.
Private packages depending on public ones. A private application can depend on a public package with workspace:* without concern; nothing about it is ever published.
Public packages depending on private ones. This is a bug, not an edge case. A published package cannot depend on a package that is never published — consumers will get a 404 for it. Either publish the dependency, or bundle its code into the dependent package and move it to devDependencies.
Adding workspace dependencies
# pnpm: adds "@acme/ui": "workspace:^" to apps/web
pnpm add @acme/ui --workspace --filter @acme/web
# Force a specific variant
pnpm add "@acme/ui@workspace:*" --filter @acme/web
# Yarn Berry
yarn workspace @acme/web add @acme/ui@workspace:^
pnpm's save-workspace-protocol setting controls what pnpm add writes by default (rolling writes workspace:^/workspace:* style ranges; true writes the concrete range prefixed with workspace:). Set it in the project configuration so everyone gets the same style.
npm workspaces do not support the protocol at all. In an npm workspace, local packages are linked when a plain range matches, with the silent-fallback risk described above; pin internal versions carefully and use "*" for private packages.
Worked example: two packages, two strategies
A repository publishes @acme/core and @acme/react, versioned independently with Changesets, plus several private applications. @acme/react depends on @acme/core with workspace:^; applications depend on both with workspace:*. When @acme/core releases 2.3.0 and @acme/react releases 1.8.0, the published @acme/react manifest contains "@acme/core": "^2.3.0". A consumer who already has @acme/core@2.4.1 from another dependency gets one deduplicated copy, because the caret range accepts it. Had the team used workspace:*, the published range would be exactly 2.3.0, forcing a second copy of @acme/core in that consumer's tree — and potentially two instances of any singleton state it holds.
Validation commands
# See what a package's manifest will look like when published
pnpm --filter @acme/react pack --pack-destination /tmp && tar -xOzf /tmp/acme-react-*.tgz package/package.json | jq .dependencies
# Confirm no literal workspace: ranges will ship
tar -xOzf /tmp/acme-react-*.tgz package/package.json | grep -q '"workspace:' && echo "LEAK" || echo "ok"
# List every workspace dependency in the repo
grep -rn '"workspace:' --include=package.json apps packages | head
Prevention and CI/CD guardrails
- Use
workspace:for every internal dependency in pnpm, Yarn Berry and Bun workspaces. - Publish only with the workspace-aware package manager, never
npm publishinside such a workspace. - Check packed manifests for leaked
workspace:strings in the release job. - Standardise the default variant with
save-workspace-protocolso new dependencies are consistent.
Frequently Asked Questions
Does workspace: in a published package mean "any version"?* No. It is rewritten to the exact local version at publish time. Consumers see a pinned version, not a wildcard.
Can I mix workspace: and plain ranges for the same package?
You can, but it defeats the purpose: the plain range can fall back to the registry. Use the protocol consistently for every internal dependency; syncpack can enforce that.
Does Changesets understand the workspace protocol?
Yes. Changesets bumps versions and relies on the package manager to rewrite ranges during publish. It also updates dependent packages when a workspace dependency's version changes according to its updateInternalDependencies setting.
What happens if the local package has no version field?
workspace:* still links it for installs, but publishing a dependent package fails or produces an invalid range, because there is no version to write. Give every workspace package a version, even private ones (0.0.0 is fine).
Can a workspace dependency point at a package in another repository? No. The protocol only resolves packages discovered through the workspace globs. For another repository's package, depend on its published version, or bring it into the monorepo.
Related
- Cross-Package Dependency Management explains how packages in a monorepo depend on each other.
- Fixing 'Unsupported URL Type workspace:' After Publishing repairs a leaked protocol in a published package.
- Choosing Fixed vs Independent Versioning in a Monorepo decides which variant fits your releases.
- Workspace Configuration Deep Dive covers workspace setup for each package manager.