Fix: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Part of: JavaScript & TypeScript Errors
Quick Answer
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.
What the Error Looks Like
I started rolling out pnpm catalogs across a twelve-package monorepo last summer and within an hour I had hit four distinct failure modes that the release notes glossed over. If you have ever managed dependency versions across a monorepo by hand, bumping React in twelve package.json files and praying you got them all, catalogs are the feature you have been waiting for. They also have failure modes nobody warned me about.
You run pnpm install after adding catalog: references and see:
ERR_PNPM_CONFIG_DEP_CATALOG_NOT_FOUND Cannot find catalog 'default' for workspace package './packages/web'Or after refactoring pnpm-workspace.yaml:
ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC Catalog entry 'react' has invalid specOr your IDE shows red squiggles even though pnpm install succeeded:
TS2307: Cannot find module 'react' or its corresponding type declarations.These three errors look unrelated but they all come from the same gap. The catalog protocol is new (pnpm 9.5, released June 2024), and the tools that read package.json directly, TypeScript, ESLint, Renovate, Dependabot, IDE language servers, are still catching up to it. The fix is rarely catalog config itself. It is usually a tool that does not understand the catalog: prefix yet. The design discussion that produced the current syntax lives in the pnpm RFCs repository, worth skimming if you want to know why the protocol chose the prefix shape it did rather than, say, a separate top-level field in package.json.
Quick Reference Before You Dive In
If you arrived here from Google, the five most useful facts in one place:
catalog:with no name uses the default (top-level) catalog inpnpm-workspace.yaml.catalog:foouses the named catalog atcatalogs.foo. There is nocatalog:default.- Minimum pnpm version is 9.5 (June 2024). Older versions fail to parse the protocol with confusing parse errors.
pnpm publishrewritescatalog:references at pack time. If your release pipeline bypassespnpm publish(usingnpm publishdirectly), the literalcatalog:string ships to the registry and breaks consumers.- TypeScript and ESLint work with catalogs because they resolve from
node_modules. Tools that parsepackage.jsondirectly (older bundlers, audit tools, some lint plugins) may not. pnpm why <pkg>is the first command I run when a catalog resolution is misbehaving.
The rest of this article goes into each of those in detail, plus the failure modes that the docs gloss over.
How pnpm Catalogs Actually Resolve
A catalog is a named map from package name to version specifier, defined in pnpm-workspace.yaml:
packages:
- 'packages/*'
catalog:
react: ^18.3.1
react-dom: ^18.3.1
catalogs:
ui:
tailwindcss: ^3.4.10
clsx: ^2.1.1Workspace packages reference the catalog instead of pinning a version directly:
{
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "catalog:ui",
"clsx": "catalog:ui"
}
}When pnpm builds the dependency graph it sees the catalog: token and substitutes the version from the matching catalog at install time. The lockfile records the resolved version, not the catalog: reference, so reproducible installs still work. The unresolved catalog: string never reaches node_modules or runtime.
The catch is that every tool that reads package.json independently has to know about the catalog: token. Most legacy tools see "react": "catalog:" and treat the literal string catalog: as the version. That mismatch is the root cause of almost every catalog-related failure I have hunted down.
The resolution flow, drawn out:
packages/web/package.json: packages/api/package.json:
"react": "catalog:" "react": "catalog:"
| |
+-------------------+----------------------+
|
v
pnpm-workspace.yaml: catalog.react = ^18.3.1
|
v
pnpm resolves ^18.3.1 -> 18.3.1
|
v
pnpm-lock.yaml records: react@18.3.1
(the literal "catalog:" never appears in the lockfile)
|
v
node_modules/react/package.json: { "version": "18.3.1" }
(downstream tools see the resolved version, not "catalog:")The two unintuitive properties of this flow: (1) the lockfile sees only resolved versions, so reading it tells you nothing about which packages used catalog references; (2) the rewrite happens at install time, not when the workspace files are saved, so editing pnpm-workspace.yaml without re-running install leaves the lockfile out of sync.
pnpm Version History for Catalogs
Knowing which pnpm version you are on determines which catalog behaviors you can rely on. The table below lines up the milestones I have tracked since launch.
| pnpm version | Approximate release | What changed for catalogs |
|---|---|---|
| 9.5.0 | June 2024 | Catalog protocol introduced. Both the default (unnamed) catalog and named catalogs (catalog:ui) ship in the same release. |
| 9.6 – 9.x | mid to late 2024 | Stability fixes and clearer error messages for malformed entries (ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC etc.). |
| 10.0+ | early 2025 onward | Catalog usage stable for production. Lockfile format records catalog resolution metadata, which improves diff readability when entries change. |
Always cross-check against the official catalogs documentation and the live pnpm CHANGELOG before depending on dates. pnpm patches frequently and some surrounding behaviors evolved across the 9.x line.
Solution 1: Use the Right catalog: Syntax
Two syntaxes that I see confused most often.
{
"dependencies": {
"react": "catalog:",
"tailwindcss": "catalog:ui"
}
}catalog: with no name means “use the default catalog defined under the top-level catalog: key in pnpm-workspace.yaml.” catalog:ui means “use the catalog named ui defined under catalogs.ui in pnpm-workspace.yaml.”
The typo I make at least once a quarter:
"react": "catalog:default"There is no default catalog name. The default catalog is the unnamed one. catalog:default fails with Cannot find catalog 'default'. The fix is to drop the name:
"react": "catalog:"This is the single most common error I see when teams roll catalogs out for the first time.
Solution 2: Declare Every Cataloged Package
pnpm install enforces that every catalog: reference resolves to an entry. If a workspace package contains "foo": "catalog:" but foo is never declared under catalog: in pnpm-workspace.yaml, install fails:
# pnpm-workspace.yaml
catalog:
react: ^18.3.1
react-dom: ^18.3.1
# forgot to declare zod here{
"dependencies": {
"react": "catalog:",
"zod": "catalog:"
}
}The fix is to add the missing entry:
catalog:
react: ^18.3.1
react-dom: ^18.3.1
zod: ^3.23.8I now keep a CI step that runs pnpm install --frozen-lockfile on every pull request specifically to catch this. The first time a teammate forgets to declare an entry, CI fails before main does.
Solution 3: Re-run pnpm install After Catalog Changes
Editing pnpm-workspace.yaml does not automatically regenerate the lockfile. I have lost time to phantom version mismatches that turned out to be stale lockfile state. After any change to a catalog entry, run:
pnpm installIf the lockfile and the catalog diverge, pnpm refuses to install in --frozen-lockfile mode (which is what CI uses). The fix is always to commit the regenerated lockfile.
For larger refactors I run:
pnpm install --no-frozen-lockfilethen review the lockfile diff before committing. A surprising lockfile diff usually means a transitive dependency upgraded as a side effect of the catalog change. Catching that in a code review beats discovering it in production.
Solution 4: Tooling Compatibility
The hardest catalog issues are not pnpm errors at all. They are downstream tools that do not understand the catalog: token. The situation as of mid-2026:
| Tool | Status | Workaround |
|---|---|---|
| TypeScript | Works (resolves from node_modules, not package.json) | none |
| ESLint | Works for most rules | none for typical config |
| Renovate | Native catalog manager since 38.x (docs) | enable pnpm-catalog manager |
| Dependabot | Partial: opens PRs but may misread cataloged versions | review PR diffs by hand |
| npm audit / yarn audit | Refuses to parse catalog: references | run pnpm audit instead |
| IDE language servers | Mostly fine via node_modules | restart language server after install |
| Older bundlers and codegen tools | May read package.json directly | upgrade or pin versions outside catalogs |
When I onboard a tool that misbehaves with catalogs, my first question is whether the tool reads dependency versions from package.json directly (broken with catalogs) or from node_modules/.../package.json (always works with catalogs). The latter is correct.
How Catalogs Compare to Other Version-Pinning Approaches
Before pnpm 9.5, I had used three patterns for centralized version management across monorepos, and each had a failure mode that catalogs fix.
Manual pinning. Every package.json lists the exact version. Bumping React across twelve workspace packages means twelve diffs and twelve chances to forget one. I have shipped a monorepo to production with eleven packages on React 18.3.1 and one on 18.2.0 because I missed it in code review. The runtime symptom was hooks throwing on suspense boundaries, and the root cause took an afternoon to find.
npm’s overrides field. Lets the root package.json force a specific version of a transitive dependency. Useful for security patches. Useless for cross-workspace version unification because overrides only affects what npm resolves, not what each workspace’s package.json says. Source-of-truth confusion stays.
yarn’s resolutions field. Similar to npm overrides, with the same limitation. Yarn also has Plug’n’Play which sidesteps some node_modules issues but does not address version sprawl across workspace package.json files.
syncpack. A CLI tool that lints workspace package.json files and complains when versions drift. I used it for two years and it worked, but it is reactive: it tells you after the drift happened. Catalogs make drift impossible because the workspace files no longer pin a version at all. The catalog is the single source of truth and the workspace files just reference it.
Bun’s catalog support. Bun has been adding workspace features that intentionally overlap with pnpm’s, and catalog-style version pinning is one of them. The protocol shape is similar, but Bun reads its workspace configuration from package.json rather than pnpm-workspace.yaml, and the level of feature parity is still moving. If you run a polyglot monorepo (some workspaces on pnpm, some on Bun) or are considering migrating, check Bun’s release notes for the current catalog status before assuming pnpm’s docs apply. The high-level rule I follow: pnpm catalogs are the most mature implementation today; Bun’s are catching up; npm and yarn have nothing equivalent.
The other piece that catalogs solve cleanly is “I want React to be at the same version as react-dom.” With manual pinning, nothing enforces the invariant. With catalogs, both reference the same catalog entries, and bumping one without the other requires editing two lines in the same file. The intent becomes visible.
When Catalogs Are Not the Right Tool
Most catalog articles oversell. I have walked teams away from catalog adoption in at least four situations where the feature would have caused more friction than it solved.
Two- or three-package monorepos. The migration overhead and the cognitive cost of “what does catalog: mean again?” outweigh the benefit of editing two or three package.json files when bumping a version. Manual pinning is honest at that size, and the team will not forget which package is on which version.
Intentional version divergence. Some monorepos host a legacy package on React 17 alongside a modernized package on React 18, deliberately, while one is being migrated. Catalogs assume convergence is the goal. If divergence is by design, the catalog fights you. Use manual pinning during the migration window and switch later.
Workspace packages published to npm consumers outside your control. This is the gotcha I cover in detail in the next section. If the package is published and its package.json includes a catalog: reference, the consumer’s npm install fails. pnpm has a publish-time replacement step, but if your build pipeline bypasses pnpm publish, you ship broken package.json files.
Mixed package-manager environments. If part of the team uses npm or yarn locally (a common pattern when a monorepo is partway through a pnpm migration), catalog references in the shared workspace package.json files break the npm and yarn flows. Catalogs are pnpm-only.
Per-package overrides for testing. Catalogs unify versions. If a single workspace package needs a different React version temporarily (to test a release candidate, reproduce a bug on an older release, etc.), the unification gets in the way. The escape hatch is to pin that package’s package.json directly (Solution 1 above) and accept the lint warning until the test concludes.
The decision tree I follow: if my monorepo has four or more workspace packages, runs on pnpm 9.5+, and does not publish packages to public npm consumers, catalogs are worth adopting. Otherwise I think hard before pulling the trigger.
Publishing Workspace Packages With Catalog References
This is the catalog interaction I see surprise the most teams. When a workspace package’s package.json reads "react": "catalog:" and you publish that package to a registry, what does the consumer’s install see? It depends entirely on how you publish.
Through pnpm publish: pnpm replaces every catalog: reference with the resolved version in the on-disk tarball at pack time, then publishes the rewritten package.json. The consumer sees a normal pinned dependency. This is the supported path and it works.
Through npm publish directly, or a custom build pipeline that bypasses pnpm’s pack step: the literal catalog: string ships to the registry. The next consumer to install your package hits EINVALIDPACKAGENAME or similar errors that make no sense out of context. I have seen this exactly once, in a release workflow that used npm publish for its 2FA flow. The fix was to swap to pnpm publish --otp=$OTP.
The same replacement applies to workspace: protocol references. If your pipeline rewrites one, it should rewrite both. Use pnpm pack (which produces the same rewritten tarball without publishing) to inspect what the consumer would see before you push to the registry.
A second nuance: peerDependencies declared with catalog: are replaced the same way at publish time. The consumer’s package.json sees the resolved version. This is usually what you want, but it means changes to your catalog directly affect the published peer constraint on consumers’ next install.
Debugging Catalog Resolution
When something is not behaving the way I expect, the first command I run is:
pnpm why reactThis shows the resolved version and which packages depend on it. If pnpm why react returns multiple versions, I have either a catalog miss (some package is pinning a different version directly) or a transitive dependency forcing an older copy.
pnpm list --depth=0 --json | jq '.[] | .dependencies.react'I use this when I want to confirm that every workspace package resolved to the same version. The jq filter walks the workspace projects and prints what each one got for React.
To inspect the catalog itself without parsing pnpm-workspace.yaml by hand:
pnpm config get catalogsThis dumps the merged catalog state pnpm is using. Useful when a chained config file (root + per-package overrides) makes the effective catalog hard to read.
If a workspace package fails to resolve a catalog entry and the error message is unclear, run install in debug mode:
pnpm install --reporter=ndjson 2>&1 | grep -E "catalog|workspace"The ndjson reporter prints structured events for catalog substitution and workspace linking. I have used this to track down a case where a typo in pnpm-workspace.yaml had the catalog under catalogs: (plural) when it should have been catalog: (singular) for the default catalog. The error message just said “not found” with no hint that the YAML key was wrong.
Migration Recipe From Manual Pinning
The migration I recommend, based on rolling this out across two monorepos:
Step 1. Audit current versions. Across all workspace package.json files, list every dependency and the version it pins. I use a one-liner:
fd package.json packages/ -x jq '.dependencies // {}' \
| jq -s 'add | to_entries | sort_by(.key)'This shows the union of all dependencies. Versions that differ across packages are the candidates for catalog-ization. Versions that are already aligned are still good catalog candidates because the catalog locks the alignment.
Step 2. Move shared versions into pnpm-workspace.yaml. Start with the highest-shared packages: React, TypeScript, the linter, the test runner. These are the ones where drift causes the most pain.
catalog:
react: ^18.3.1
react-dom: ^18.3.1
typescript: ^5.5.3
vitest: ^2.0.0Step 3. Replace pinned versions with catalog: references in every workspace package’s package.json. A find-and-replace tool helps here. I use sd:
fd package.json packages/ -x sd '"react": "[^"]+"' '"react": "catalog:"'Repeat for each cataloged package.
Step 4. Run pnpm install and commit the lockfile. The lockfile will look almost identical because the resolved versions did not change, only the way they are referenced changed.
Step 5. Add a CI guard. I add pnpm install --frozen-lockfile to every PR build specifically to catch missing catalog entries before they reach main.
The whole migration takes a few hours for a medium monorepo. The payoff is permanent: no more “did I update React in every package?” review item.
For a clean reference to compare your migration against, the pnpm repository keeps catalog test fixtures under github.com/pnpm/pnpm (search the repo for catalog to find them). These fixtures are the closest thing to a known-good starter pnpm itself uses to validate the feature in CI; reading them is faster than spinning up a fresh sample repo when you only need to confirm “is my YAML shape correct?”
What Other Tutorials Miss About Catalogs
Most of the catalog tutorials I have read stop at “add a catalog: reference and run pnpm install.” That covers the happy path. The harder questions that decide whether catalog adoption sticks or gets reverted are the ones below, and I rarely see them addressed.
There is no codemod for adoption. Every other version-pinning approach has tooling for migration (syncpack fix, npm-check-updates, Renovate). Catalogs require a manual or scripted refactor of every workspace package.json. Tutorials that show a clean before/after never explain the migration path itself, which is where most of the real work lives.
Catalogs do not replace workspace:. This is the single most common confusion I have seen on PR reviews after a catalog rollout. workspace:* references a local workspace package; catalog: references an externally-versioned package. They solve different problems and cannot be combined. Tutorials that present them as alternatives confuse the mental model.
Catalogs offer no namespace isolation. Entries are keyed by the full package name. If your monorepo depends on both @scope-a/foo and @scope-b/foo, you can catalog each independently, but you cannot have two entries for the literal name react with different versions. Most tutorials use single-organization examples and skip this constraint.
The constraint-resolution semantics are silent. If you set react: ^18.2.0 in the catalog and a workspace package internally depends on ^19.0.0, pnpm resolves to a version satisfying both constraints if one exists, or splits into two versions if not. A split surfaces as runtime weirdness (two React copies) rather than as an install error, which makes it hard to attribute back to the catalog config.
Tooling compatibility is not a one-time check. ESLint and TypeScript work because they resolve from node_modules, but every lint plugin or codegen tool that parses package.json directly is a fresh compatibility question. I keep a private allowlist of which ESLint plugins are catalog-safe and re-validate it on every ESLint major upgrade, because the breakage is silent (lint passes; the rule just stops running on cataloged dependencies).
Sneaky Failures From the Wild
These are the catalog-related failures I have hit that are not in any docs I have read.
Mixing catalog and direct versions for the same package. If packages/web/package.json has "react": "catalog:" and packages/api/package.json has "react": "^18.2.0", pnpm allows it but the two packages can resolve to different React versions. That breaks React’s “single copy” invariant and surfaces as nightmare runtime errors. Use the catalog reference everywhere or nowhere, with no mixing.
Catalog entries narrower than what the lockfile already resolves to. Change react: ^18.3.0 to react: 18.2.0 after the lockfile resolved to 18.3.1, and pnpm install --frozen-lockfile fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. The fix is to regenerate the lockfile.
workspace: and catalog: confused. workspace:* references a local workspace package. catalog: references a shared external version. They look similar but cannot be combined. Trying "react": "workspace:catalog:" is invalid syntax and the error message does not always make the distinction clear.
peerDependencies cataloged without devDependencies cataloged. If packages/ui/package.json lists "react": "catalog:" as a peerDependency but the root package.json has no react in devDependencies, your tests fail because there is no React in node_modules. Also catalog react as a devDependency at the workspace root, or in each package’s own devDependencies.
IDE not refreshing after catalog updates. VS Code and JetBrains IDEs cache module resolution. After changing a catalog entry and running pnpm install, the IDE may still resolve to the old version. Restart the TypeScript language server (Cmd-Shift-P > “TypeScript: Restart TS Server” in VS Code) or the whole IDE for JetBrains. I have spent more time chasing this than I want to admit.
patchedDependencies silently stops applying after a catalog version bump. pnpm patches are keyed by name@version. The patchedDependencies field in package.json looks like "react@18.3.1": "patches/react@18.3.1.patch". When the catalog bumps React from 18.3.1 to 18.3.2, the patch entry no longer matches and pnpm silently skips applying it. The runtime symptom is the patched bug coming back. The fix is to update patchedDependencies keys whenever the catalog version changes, or use a glob-friendly tool that does not pin to a specific version. I now grep for patchedDependencies in the same commit as any catalog bump.
Corepack-pinned pnpm version older than 9.5. If package.json has "packageManager": "pnpm@9.4.0" and you add catalog: references, install fails with confusing parse errors because the pinned pnpm cannot parse the protocol. Bump the packageManager field to at least pnpm@9.5.0 in the same commit that introduces catalog usage, and have CI use corepack enable so the runner respects the field. The reverse (Corepack pinned to 10.x, catalog config from a contributor still on 9.5) is rarer but happens during major upgrade windows.
pnpm.overrides quietly wins over catalog:. If both the catalog entry and pnpm.overrides in the root package.json reference the same package, the override takes precedence. This is by design (overrides exist for security patches that need to win), but it makes catalog bumps appear ineffective. I have spent an hour debugging “why is React not updating” before finding a forgotten override row pinning it to an older version.
Renovate opening one PR per workspace package instead of one consolidated PR. Older Renovate versions did not understand catalogs and would open a PR per catalog: reference. Upgrade Renovate to 38.x or later and enable the catalog manager in renovate.json (the pnpm-catalog manager documentation confirms the supported config keys):
{
"pnpm-catalog": {
"enabled": true
}
}For related pnpm monorepo issues see pnpm workspace not working and pnpm peer dependency error. For npm-style dependency churn see npm warn deprecated. For bundler integration issues across monorepo packages see Webpack HMR not working. For type resolution failures that look like catalog problems but turn out to be tsconfig issues see TypeScript cannot find module.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pnpm Workspace Not Working — workspace:* Protocol, Catalog, Filters, and Hoisting Issues
How to fix pnpm workspace errors — workspace:* not resolving, catalog versions out of sync, --filter not matching, peer deps unmet across packages, shamefully-hoist trade-offs, and publishConfig for releases.
Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode
How to fix Node.js built-in test runner errors — node --test not finding files, ESM vs CJS imports, TypeScript with --experimental-strip-types, mock.method isolation, coverage reporting, and watch mode setup.
Fix: Nx Not Working — project.json Targets, Affected Commands, Caching, and Generators
How to fix Nx errors — nx.json plugin config, project.json target inputs/outputs, nx affected base branch, cache misses, generator schema, custom executors, and nx migrate failures.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.