pnpm Catalogs: Fix Monorepo Dependency Version Drift

Photo by Yoav Aziz on Unsplash
pnpm added catalogs in version 9.5, released on July 7, 2024. From that version you can declare catalogs in pnpm-workspace.yaml and reference them with the catalog: protocol. The feature had been discussed as an RFC since 2022 before it shipped.
Instead of a concrete version range, you write catalog: to pull the version from the default catalog, or catalog:name to use a named catalog. It works in dependencies, devDependencies, peerDependencies, and optionalDependencies. On pnpm publish or pnpm pack, pnpm replaces it with the real resolved version range.
Without catalogs, a dependency version is repeated in every package.json that uses it, so bumping it on two branches collides in each file. Catalogs move the version into one entry in pnpm-workspace.yaml, so the package.json files stay untouched during upgrades and the conflict has nowhere to happen.
The default catalog is an unnamed map under the catalog key and is referenced with bare catalog:. Named catalogs live under the catalogs key and are referenced by name, like catalog:react17. Use a named catalog when you deliberately need more than one version of a dependency in play, such as during a migration.
No. Catalogs solve dependency version management: what is installed and at which version across the workspace. Turborepo is a build system and task runner that schedules tasks and caches their outputs, and it delegates dependency management to your package manager. The two are complementary, not alternatives.

Photo by Yoav Aziz on Unsplash
Key Takeaway
pnpm Catalogs let a monorepo define each dependency version once in pnpm-workspace.yaml, then reference it from every package.json with the catalog: protocol. Upgrades touch one line, versions stay identical across packages, and merge conflicts vanish. Catalogs manage versions; a task runner like Turborepo handles builds and caching.
Every monorepo I have worked in eventually develops the same quiet disease. One package pins React at 18.2.0, another at 18.3.1, a third writes a loose caret and gets whatever the lockfile last resolved. Nothing is obviously broken, so nobody fixes it, and then a subtle bug appears that only reproduces when two copies of the same library end up in the bundle. Diagnosing that costs an afternoon I never get back.
The root cause is that in a plain workspace, the version of a shared dependency is written down in as many places as there are packages that use it. There is no single source of truth, so drift is the default state and staying in sync is manual work. pnpm Catalogs, added in pnpm 9.5 in July 2024, fix this at the tooling level: you declare a version once and reference it everywhere.
Before catalogs, keeping a dependency aligned across twenty packages meant editing twenty package.json files for every bump. In practice that rarely happens cleanly. Someone updates the three packages they are touching, forgets the rest, and the repo slides back into a mix of versions. The lockfile then installs multiple copies, which inflates install size and, for stateful libraries like React or a validation library with a shared registry, can cause real runtime bugs.
There is a second, more mundane cost: merge conflicts. When two branches both bump a widely used dependency, they collide in every package.json that names it. Reviewers waste time resolving the same version string over and over. Catalogs move that version string out of each package.json entirely, so the collision has nowhere to happen.
A pnpm workspace is defined by a pnpm-workspace.yaml file at the repository root; its packages field lists the globs that make up the monorepo. Catalogs live in the same file. The default catalog is an unnamed map of package names to version ranges under the catalog key. You can also declare named catalogs under catalogs when you deliberately need more than one version of something in play at once.
# pnpm-workspace.yaml (at the repo root)
packages:
- "apps/*"
- "packages/*"
# The default catalog: unnamed version ranges
catalog:
react: ^18.3.1
react-dom: ^18.3.1
typescript: ^5.5.4
zod: ^3.23.8
# Named catalogs: deliberate, parallel version sets
catalogs:
react17:
react: ^17.0.2
react-dom: ^17.0.2The version ranges you put in a catalog are ordinary semver specifiers, exactly what you would otherwise write in a package.json. The catalog does not change how resolution works; it only moves where the range is written down. That is why adopting catalogs is a mechanical refactor with no change in installed versions on day one.
Inside a package, you replace the concrete version range with the catalog: protocol. Written bare, catalog: pulls the version from the default catalog. It works in dependencies, devDependencies, peerDependencies, and optionalDependencies, so a package.json can point every shared dependency at the catalog and stop carrying version numbers of its own.
{
"name": "@acme/web",
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
}
}When you run pnpm publish or pnpm pack, pnpm replaces the catalog: reference with the real version range it resolves to, exactly like it does for the workspace: protocol. Published packages therefore contain normal version ranges, so consumers outside your monorepo never see or need to understand catalogs.
Sometimes you genuinely need two versions coexisting, for example while migrating a legacy widget from React 17 to React 18. A named catalog captures that intent. You reference it by name with catalog:react17, which reads unambiguously as a deliberate exception rather than accidental drift. The name documents the reason the split exists.
{
"name": "@acme/legacy-widget",
"dependencies": {
"react": "catalog:react17",
"react-dom": "catalog:react17"
}
}This is the difference between drift and a decision. Drift is invisible and nobody chose it; a named catalog is a labelled, reviewed choice sitting in one file. When the migration finishes you delete the named catalog and switch those packages back to the default, and the exception is gone in a single, obvious commit.
Catalogs centralize the version range, not the update mechanism. When catalogs first shipped, pnpm update did not rewrite catalog entries, so you upgraded by editing pnpm-workspace.yaml directly and reinstalling. Always check your pnpm version's docs for current update-command support before assuming a bump command will touch your catalog for you.
The contrast is easiest to see side by side. The same three concerns, handled the old way and the catalog way, show why the feature is worth the small migration.
| Concern | Without catalogs | With catalogs |
|---|---|---|
| Source of truth for a version | Repeated in every package.json | One entry in pnpm-workspace.yaml |
| Upgrading a shared dependency | Edit every package that uses it | Edit one catalog line |
| Merge conflicts on bumps | One per affected package.json | None: package.json is untouched |
| Risk of duplicate versions | High, drift is the default | Low, versions stay aligned |
Catalogs solve exactly one problem: what version of each dependency the whole workspace agrees on. They say nothing about how you build, test, or ship those packages. Once versions are centralized, the next monorepo pain is task orchestration, and that is a different tool's job.
Turborepo is a build system and task runner for JavaScript and TypeScript monorepos. Its own documentation is explicit that it delegates dependency management to your package manager and works with npm, yarn, or pnpm. Turborepo is responsible for a separate set of concerns:
So the division is clean. pnpm workspaces plus Catalogs decide what is installed and at which versions; Turborepo decides how the resulting tasks run and how their outputs are cached. Reach for catalogs the moment a dependency appears in more than one package, and add a task runner when running your builds in the right order becomes the slow part.