pnpm Catalogs: Fix Dependency Drift in Monorepos
How pnpm catalogs stop dependency drift in JavaScript monorepos: one place to declare shared versions, named catalogs for legacy packages, and CI enforcement.
In a JavaScript monorepo, dependency drift starts quietly. One service declares lodash at ^4.17.20, another at ^4.17.21, and the installer gives you both. Weeks later a shared helper behaves differently depending on which service imports it, and the bug reproduces on one machine out of five.
pnpm catalogs close that gap at the source. Each shared version is declared once in pnpm-workspace.yaml, packages reference it with catalog:, and a CI check fails the build when a package declares a version inline. The trade is that one file becomes a shared bottleneck: exactly what you want for react, and exactly what you do not want for a parser only one service uses.
The Dependency Drift Challenge
Drift is not one bad version. It is the absence of a place where versions get decided. Each package.json is its own authority, so an authentication service can sit on a different @types/node than the payment service whose types it imports. Nothing in the toolchain objects until a build fails with a type error that names neither package.
Workspace tooling does not close this gap. Linking local packages and hoisting shared ones only changes how a declared range gets resolved. Every package still declares its own range.
Drift in Practice
Two services, two package.json files, one shared library:
// packages/service-a/package.json
{
"dependencies": {
"lodash": "^4.17.21",
"axios": "^1.6.2"
}
}
// packages/service-b/package.json
{
"dependencies": {
"lodash": "^4.17.20", // Different version!
"axios": "^1.6.5" // Different version!
}
}
Nothing here is invalid. Both ranges resolve, both services build, and the store keeps two copies of lodash. Repeat that across a few dozen packages and node_modules carries the same library at three or four versions at once, which is how a bundle grows without anyone adding a feature.
Traditional Solutions Fall Short
npm workspaces, Yarn workspaces, Lerna, and Rush all manage a monorepo. None of them owns the version decision:
| Capability | npm Workspaces | Yarn Workspaces | pnpm Catalogs |
|---|---|---|---|
| One place to declare a shared version | overrides forces, does not declare | resolutions forces, does not declare | Yes |
| Blocks phantom dependencies | No | With PnP | Yes |
| Bump touches a single file | No | No | Yes |
| Separate versions per package group | No | No | Named catalogs |
The cost that never shows up in a build log is attention. Every version mismatch is an interruption: someone reads two lockfile diffs, picks a winner, and re-runs CI. The work is not hard, which is why it never gets prioritized, and why it keeps coming back as the repo grows.
What a Version Governance Layer Must Provide
A governance layer earns its place only if it covers all five of these:
- One declaration per shared version, readable by every package
- No silent fallback to a hoisted copy when a declaration is missing
- A bump that touches one file instead of every
package.json - An escape hatch for packages that cannot move to the current version yet
- A check that fails CI rather than a convention people are expected to remember
Implementing pnpm Catalogs
Catalogs landed in pnpm v9.5 and are stable across the v10 line. The configuration lives in the workspace file:
# pnpm-workspace.yaml
packages:
- 'apps/**'
- 'packages/**'
- 'services/**'
catalog:
# Core dependencies
typescript: ^5.9.2
lodash: ^4.17.21
axios: ^1.6.5
# React ecosystem (using stable versions)
react: ^18.3.1
react-dom: ^18.3.1
"@types/react": ^18.3.3
# Testing
vitest: ^1.2.0
"@testing-library/react": ^14.1.2
Now your individual packages simply reference the catalog:
{
"name": "@mycompany/user-service",
"dependencies": {
"lodash": "catalog:",
"axios": "catalog:",
"react": "catalog:"
}
}
The version now lives in one place, and pnpm install is what enforces it. A package that hand-writes "lodash": "^4.17.19" no longer wins the argument; it fails the check added in step 3.
Migration in Three Steps
Each step below ships on its own. None of them requires freezing the repo.
Step 1: Automated Catalog Generation
Start from what is already declared. This script walks the monorepo and produces a first draft of the catalog:
// scripts/migrate-to-catalogs.js
const fs = require('fs');
const yaml = require('js-yaml');
const glob = require('glob');
// Collect all unique dependencies
const dependencies = new Map();
glob.sync('**/package.json', {
ignore: ['**/node_modules/**', '**/dist/**']
}).forEach(file => {
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
Object.entries(pkg.dependencies || {}).forEach(([name, version]) => {
if (!dependencies.has(name) || dependencies.get(name) < version) {
dependencies.set(name, version);
}
});
});
// Generate catalog configuration
const catalog = Object.fromEntries(dependencies);
// Update pnpm-workspace.yaml
const workspace = yaml.load(fs.readFileSync('pnpm-workspace.yaml', 'utf8'));
workspace.catalog = catalog;
fs.writeFileSync('pnpm-workspace.yaml', yaml.dump(workspace));
console.log(`Migrated ${dependencies.size} dependencies to catalog`);
The comparison above sorts version strings alphabetically, so ^4.17.9 wins over ^4.17.21. Treat the output as a draft and read the generated file before committing it.
Step 2: Handling Legacy Services
Not everything can move to the latest versions immediately. Legacy services might remain on React 17 while newer ones use React 18. Named catalogs provide a solution:
catalogs:
# Legacy services on React 17
legacy:
react: ^17.0.2
react-dom: ^17.0.2
"@types/react": ^17.0.39
# Modern services on React 18
modern:
react: ^18.3.1
react-dom: ^18.3.1
"@types/react": ^18.3.3
Each package picks the catalog it needs:
{
"name": "@mycompany/legacy-dashboard",
"dependencies": {
"react": "catalog:legacy",
"react-dom": "catalog:legacy"
}
}
The upgrade then becomes a one-line change in whichever package is ready for it, instead of a repo-wide event.
Step 3: Enforcement and Validation
A catalog without enforcement is only a naming convention. This script runs in CI and fails the build on any inline version:
// scripts/validate-catalogs.js
const validateCatalogs = () => {
const violations = [];
glob.sync('**/package.json', {
ignore: ['**/node_modules/**']
}).forEach(file => {
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
Object.entries(pkg.dependencies || {}).forEach(([name, version]) => {
// Skip workspace protocol and local packages
if (version.startsWith('workspace:') || version.startsWith('file:')) {
return;
}
// Check if it should use catalog
if (!version.startsWith('catalog:')) {
violations.push(`${file}: ${name}@${version} should use catalog`);
}
});
});
if (violations.length > 0) {
console.error('Catalog violations found:', violations);
process.exit(1);
}
console.log('All dependencies using catalog protocol');
};
validateCatalogs();
What Changes After Migration
The gains come from two mechanisms.
The first is pnpm’s content-addressable store. A given version of a package is written to disk once and hard-linked into each project that needs it. Catalogs push more work onto that store: when every workspace resolves lodash through the same entry, there is one copy to link instead of three near-identical ones.
The second is the shape of the diff. A dependency bump used to touch every package.json that declared the package. Now it touches pnpm-workspace.yaml and the lockfile. Two engineers upgrading different libraries in the same week stop colliding on the same lines.
Take your own baseline before migrating: cold install time, warm install time, node_modules size, and how many PRs per month exist only to change a version string. Those four numbers are the ones that tell you whether the migration paid off in your repo, and they are cheap to collect before you start.
Common Pitfalls and Solutions
The Over-Centralization Trap
A common mistake is placing every dependency in the catalog. A specialized parser or a build tool used by one service does not belong there; it only adds a file that unrelated teams have to review. Reserve the catalog for dependencies that appear in more than one package.
The Phantom Dependency Surprise
During migration, services sometimes break because they were relying on a hoisted dependency. The code imports a package that is available in node_modules but never declared in its own package.json, and the stricter layout exposes it.
Run strict mode during development so the failure happens locally rather than in CI:
# .npmrc
strict-peer-dependencies=true
shamefully-hoist=false
The Publishing Gotcha
When a package is published, the catalog: protocol is replaced with the resolved version, because consumers outside the workspace have no catalog to read. The published manifest can therefore carry a version nobody reviewed in the diff.
Check the packed manifest in CI before the release job runs:
# .github/workflows/publish.yml
- name: Validate published package
run: |
npm pack
tar -xzf *.tgz
cat package/package.json | jq '.dependencies'
Advanced Implementation Patterns
Multi-Environment Catalogs
Named catalogs solve a second problem beyond legacy splits. When services deploy onto different Node runtimes, the type packages have to follow the runtime rather than the repo:
catalogs:
# Services still deployed on the Node 20 runtime
node20:
"@types/node": ^20.19.0
# Services moved to Node 22
node22:
"@types/node": ^22.7.0
A service pins itself with "@types/node": "catalog:node20", and a runtime upgrade becomes a one-word change in that package instead of a search across the repo.
Automated Dependency Updates
Because catalog entries live in one file, Renovate can group them into a single scheduled PR:
// renovate.json
{
"extends": ["config:recommended"],
"packageRules": [
{
"matchFileNames": ["pnpm-workspace.yaml"],
"groupName": "catalog dependencies",
"schedule": ["every weekend"]
}
]
}
A version bump becomes one PR against pnpm-workspace.yaml instead of one PR per package that declared it.
Decisions to Make Up Front
Five choices are cheaper now than later:
-
Adopt catalogs before the repo grows. Retrofit cost scales with the number of packages that already declare a version.
-
Turn on strict mode from the start. A permissive install hides phantom dependencies until the migration surfaces them all at once.
-
Design named catalogs before you need them. Splitting a single catalog later means touching every package that should have been in the second group.
-
Write the validation script alongside the first catalog. Enforcement added after adoption always finds violations already merged.
-
Document the reasoning behind exceptions. The
catalog:protocol explains itself; the reason a package is pinned outside the catalog does not.
Migration Checklist
Assessment
- Audit the dependencies declared across all packages
- List every package declared at more than one version
- Record cold install time, warm install time, and
node_modulessize
Pilot
- Install pnpm v10 or newer
- Move the two or three most widely shared dependencies into
catalog: - Add catalog validation to CI before migrating anything else
- Write down which packages could not move, and why
Scale
- Run the generation script over the remaining packages
- Split named catalogs where runtimes or major versions differ
- Point Renovate at
pnpm-workspace.yaml
Tighten
- Set
shamefully-hoist=falseand fix what breaks - Remove catalog entries used by only one package
- Re-measure against the baseline
Conclusion
Catalogs are the right default for any pnpm workspace with more than a handful of packages that share dependencies. The payoff is largest where the same framework, test runner, and type packages appear in every package.json.
There are two cases for skipping them. A single-package repository gains nothing from a shared version file. And a workspace whose packages are published independently, to consumers with deliberately different support windows, will fight the catalog constantly; per-package ranges with a targeted override for security patches is less friction there.
If you migrate, start with the two or three packages that appear everywhere rather than the full dependency list. The validation script matters more than the catalog itself: without a check in CI, inline versions come back the first time someone is in a hurry.
References
- pnpm Catalogs Documentation - Official pnpm docs for the catalogs feature: defining reusable dependency version constants in pnpm-workspace.yaml.
- pnpm Workspaces Documentation - Official guide to pnpm’s built-in monorepo support, covering the workspace protocol and package linking.
- pnpm-workspace.yaml Reference - Complete reference for the pnpm-workspace.yaml configuration file, including catalog and workspace definitions.
- pnpm Package Manager - Official pnpm documentation and feature overview, covering the content-addressable store and hard-link approach that reduces disk usage.
- pnpm Package Sources - Documentation on how pnpm resolves packages, relevant to understanding the catalog: protocol and dependency resolution.
- Renovate Configuration Options - Reference for
packageRules,matchFileNames, and scheduling, used above to group catalog updates into one PR.
Related posts
When a Node.js to Go move on AWS Lambda pays for itself and when it does not: the decision framework, the serverless Go patterns, and the cost math behind the call.
Why time bugs hide in production, how to migrate from Moment.js to Day.js or date-fns, and how to keep UTC everywhere with conversion only at the display boundary.
Build the redirect engine, analytics collection, and API Gateway config: performance optimizations and debugging strategies for millions of daily redirects.
When a Lambda fleet outgrows Middy's static middleware model, how a project-specific engine handles per-request config, and what owning one costs
Hold AWS Lambda warm-path latency inside a 10 ms budget with runtime choice, connection reuse, bundle discipline, caching, and memory tuning.