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.
The version-drift tax you pay without catalogs
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.
pnpm-workspace.yaml: one place to declare versions
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.
The catalog: protocol in package.json
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.
Named catalogs for deliberate version splits
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.
With catalogs versus without
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 |
Where catalogs stop and Turborepo begins
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:
- Scheduling tasks across packages in dependency order and running independent work in parallel.
- Caching task outputs, locally and remotely, so unchanged work is never rebuilt twice.
- Reading the package.json scripts and dependency graph you already have, via a single turbo.json config.
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.

