Sharing Dependency Versions with pnpm Catalogs
pnpm catalogs let a workspace define a dependency's version once, in pnpm-workspace.yaml, and have every package reference it with the catalog: protocol. Upgrading React, TypeScript or Zod across thirty packages becomes a one-line change, version drift between packages becomes impossible for catalogued dependencies, and merge conflicts in individual manifests largely disappear. This guide sets up default and named catalogs, migrates an existing workspace to them, explains how catalog: is resolved and published, and covers the rules that keep catalogs from becoming a new source of confusion.
The problem catalogs solve
Without catalogs, each package declares its own range for shared dependencies, and the ranges drift:
$ grep -h '"react":' apps/*/package.json packages/*/package.json | sort | uniq -c
4 "react": "^18.3.1",
2 "react": "^18.2.0",
1 "react": "18.3.1",
Drift causes duplicate installs, confusing type errors between packages and scattered upgrade pull requests. Tools such as syncpack detect and fix drift after it happens, as covered in Keeping Workspace Dependency Versions in Sync with syncpack. Catalogs prevent it structurally: there is only one place the version can be written. They are available from pnpm 9.5 onwards, and pnpm 10 extends them with configuration options that control how new dependencies are added. Because a catalog is part of pnpm-workspace.yaml, it is reviewed like any other workspace configuration change, and every version bump is a one-line diff that is easy to read. The broader dependency model is covered in Cross-Package Dependency Management.
Defining catalogs
Catalogs live in pnpm-workspace.yaml. The default catalog uses the catalog key; named catalogs go under catalogs:
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^18.3.1
react-dom: ^18.3.1
typescript: 5.7.2
zod: ^3.24.1
vitest: ^2.1.8
catalogs:
react19:
react: ^19.0.0
react-dom: ^19.0.0
legacy:
webpack: ^4.47.0
Reference them from any package manifest:
{
"name": "@acme/web",
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
}
}
catalog: (or catalog:default) uses the default catalog; catalog:react19 uses the named one. A package migrating early to React 19 switches its two references to catalog:react19 and every other package stays on the default.
How catalog: resolves and publishes
At install time, pnpm replaces each catalog: reference with the catalog's range before resolution, so the lockfile records the resolved version once per catalog entry. The lockfile also stores a snapshot of the catalogs, which is why changing a catalog entry without reinstalling fails frozen installs with a configuration mismatch — see Fixing pnpm ERR_PNPM_OUTDATED_LOCKFILE in CI.
At publish time, pnpm pack and pnpm publish rewrite catalog: into the concrete range, exactly as they do for workspace:. The published manifest for @acme/web would contain "react": "^18.3.1". As with the workspace protocol, publishing with npm instead of pnpm ships the literal catalog: string and breaks consumers.
Migrating an existing workspace
- Find the shared dependencies. Anything declared in three or more packages is a good candidate. syncpack's list output, or a short script over manifests, gives you the counts.
- Pick the version for each. Usually the highest range already in use; align first if the spread is large.
- Write the catalog in
pnpm-workspace.yaml. - Replace ranges with
catalog:in every manifest. A short script that walks the manifests and swaps the range for each catalogued name is the most reliable way; review the diff to confirm only catalogued entries changed. - Reinstall and commit
pnpm-workspace.yaml, every manifest andpnpm-lock.yamltogether. - Enforce it. pnpm's
catalogModesetting (in recent pnpm 10 releases) can makepnpm addwritecatalog:automatically for dependencies that already exist in a catalog, or refuse ranges that bypass the catalog.
# pnpm-workspace.yaml
catalogMode: prefer # or "strict" to reject non-catalog ranges for catalogued packages
Where catalogMode is not available, syncpack can enforce that catalogued dependencies always use catalog: by pinning a version group to that string.
What belongs in a catalog
Not every dependency benefits. A good catalog contains:
- Framework and runtime libraries that must be identical across packages to avoid duplicate instances — React, Vue, a GraphQL client.
- Toolchain packages where consistency matters — TypeScript, the test runner, the bundler, ESLint.
- Widely shared utilities — Zod, date libraries, logging.
Leave out dependencies used by one package only (a catalog entry adds indirection for no benefit) and peer dependency ranges of published libraries, which should stay wide and explicit. A published component library typically declares "react": "^18.2.0 || ^19.0.0" in peerDependencies directly, and "react": "catalog:" in devDependencies for its own development.
Catalogs and caching task runners
Moving versions into pnpm-workspace.yaml changes where dependency changes show up, and task runners need to know. Turborepo and Nx compute a package's cache key partly from its dependencies. When a package declares "react": "catalog:", its own package.json does not change when the catalog bumps React from 18.3.1 to 18.3.2 — only pnpm-workspace.yaml and the lockfile do. Both tools hash the resolved dependency versions from the lockfile, so a catalog bump still invalidates the right caches, but custom input globs that list only package.json and src/** would miss it. If you have customised task inputs, include pnpm-workspace.yaml in the global inputs, or rely on the tools' default lockfile-aware hashing.
The same reasoning applies to affected detection. A pull request that only edits a catalog entry changes no package manifest, so tools that compute affected packages from changed files alone see nothing affected. Turborepo and Nx both analyse lockfile changes to find packages whose resolved dependencies changed; pnpm's --filter "...[origin/main]" looks at changed files only, so for catalog-only changes run the full test suite or add a CI rule that treats changes to pnpm-workspace.yaml as affecting everything.
Common mistakes
A few mistakes come up repeatedly in the first weeks after adopting catalogs. The most common is committing a catalog change without the lockfile, which fails every frozen install in CI; a pre-commit hook that runs pnpm install --lockfile-only when pnpm-workspace.yaml is staged prevents it. The second is adding one-off dependencies to the catalog "for consistency", which turns the catalog into a long list nobody reviews; keep it to genuinely shared packages. The third is catalogues for peers of published libraries, discussed above, which silently narrow what consumers may install. And the fourth is forgetting that named catalogs need cleaning up: once a migration catalog such as react19 has no remaining references, delete it so the next reader is not left wondering which one is current.
Worked example: a React 19 migration package by package
A workspace with nine applications wants to move to React 19 gradually. The team adds a react19 named catalog next to the default one. Each application migrates in its own pull request by changing four references — react, react-dom, @types/react, @types/react-dom — from catalog: to catalog:react19, fixing whatever breaks, and merging. Shared component packages keep React in devDependencies from the default catalog and a wide peer range, so they are tested against React 18 until the last application moves. When it does, the team updates the default catalog to React 19, switches every catalog:react19 reference back to catalog:, deletes the named catalog and reinstalls. At no point did two applications disagree about which React 19 version they used.
Prevention and CI/CD guardrails
- Commit catalog changes with the lockfile; CI's frozen install rejects anything else.
- Enforce
catalog:usage withcatalogMode: strictor a syncpack rule, so new packages cannot bypass the catalog. - Publish only with pnpm, so
catalog:is rewritten in packed manifests. - Configure dependency bots to update catalog entries: Renovate supports pnpm catalogs and opens a single pull request per catalog entry.
Frequently Asked Questions
Do catalogs work with npm or Yarn? No. Catalogs are a pnpm feature. Yarn Berry users can approximate them with constraints; npm users can use syncpack.
Can I use catalog: in peerDependencies? Yes, but it publishes as the catalog's range, which is usually narrower than a library's real compatibility. For published libraries, prefer explicit wide peer ranges.
What happens if a catalog entry is missing?
The install fails with an error naming the missing entry, so a typo in catalog:react19 is caught immediately.
How do I see which packages use a catalog entry?
Search the manifests for the reference — grep -rln '"react": "catalog:react19"' apps packages — or use pnpm why react -r, which lists every importer and the version it resolved, confirming they all share one version.
Can catalogs pin exact versions?
Yes. A catalog entry is any valid range, so typescript: 5.7.2 pins exactly while zod: ^3.24.1 allows compatible updates in the lockfile.
Related
- Cross-Package Dependency Management covers internal and shared dependencies in monorepos.
- Keeping Workspace Dependency Versions in Sync with syncpack aligns versions without changing the declaration format.
- Using the workspace: Protocol Correctly explains the sibling protocol for internal packages.
- Forcing a Single Dependency Version with pnpm overrides handles versions deeper in the tree.