Skip to main content
BlogSoftware Development

pnpm Catalogs: Fix Monorepo Dependency Version Drift

How I use pnpm workspaces and the catalog: protocol to define dependency versions once, kill version drift and package.json merge conflicts, and hand task orchestration to Turborepo.

Matthews Wong

Software Engineer

August 20269 min read
pnpm Catalogs: Fix Monorepo Dependency Version Drift

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.

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.2

The 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.

ConcernWithout catalogsWith catalogs
Source of truth for a versionRepeated in every package.jsonOne entry in pnpm-workspace.yaml
Upgrading a shared dependencyEdit every package that uses itEdit one catalog line
Merge conflicts on bumpsOne per affected package.jsonNone: package.json is untouched
Risk of duplicate versionsHigh, drift is the defaultLow, 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What version of pnpm added catalogs?

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.

How does the catalog: protocol work in package.json?

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.

How do catalogs reduce merge conflicts in a monorepo?

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.

What is the difference between the default catalog and a named catalog?

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.

Do pnpm catalogs replace Turborepo?

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.