On Windows, pnpm 12.3.4 through 12.4.1 kills pnpm install with ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR Failed to remove modules directory contents: Access is denied. (os error 5) while clearing node_modules. Four different conditions print that string. This page separates them, names the release that fixes the common one, and gives the unlink sequence for a host you cannot upgrade today.
TL;DR: pnpm 12.3.4 through 12.4.1 on Windows sends a junction inside node_modules to DeleteFileW, which refuses directory links and returns os error 5. Upgrade to pnpm 12.4.2, released 15 September 2026, and pin it in packageManager. If the upgrade does not clear it, you are on one of the other three causes: a held-open file, a NO_TTY abort, or a symlinked node_modules.
What actually fails here?
Windows reports a junction as a symlink rather than a directory, so pnpm's Rust purge path took the file branch and called DeleteFileW, which refuses directory links and returns ERROR_ACCESS_DENIED. Everything after step 4 is for readers stuck on an older binary, or readers who upgraded and still see the string.
Two details make this worth writing down rather than guessing at. The break and the fix both landed inside a ten-day window in September 2026, and the reason a junction escapes the directory branch is visible only in the merged patch, not in any published doc. If you have been working through the pnpm 12 upgrade traps, treat this as one more Rust-CLI regression in that family. The shape also rhymes with uv refusing to install into a symlinked directory: a package manager rewritten in Rust, a link type the old implementation tolerated, a new one that does not.
Prerequisites
- Windows 10 or 11, or a
windows-latestGitHub Actions runner. Linux and macOS never hit the junction path, sinceremove_fileunlinks a symlink there without complaint. - pnpm 12.x, checked with
pnpm --version. Affected releases run from v12.3.4 (4 September 2026) to v12.4.1 (10 September 2026). The reporter in pnpm issue #14790 confirmed 12.3.4 and 12.4.0. - Node.js 20 or newer, and a shell from which you can run PowerShell and
cmd.exe. - Write access to
package.jsonfor thepackageManagerpin, and to your continuous integration (CI) workflow file.
One assumption I am making: the purge itself is legitimate. pnpm clears node_modules when the settings that shaped the existing tree no longer match the settings for the install about to run, and step 2 is how you confirm that rather than assume it.
1. Which of the two modules-dir errors did you get?
pnpm install 2>&1 | tail -20
Two error codes carry the words "modules dir" and they want opposite fixes. ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR means pnpm tried to purge and the filesystem refused. ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY means pnpm never tried, because there was no terminal attached to confirm on, a case described in issue #11562. Step 6 covers that second one; the rest of this page is about the first.
2. Why does pnpm want to purge at all?
cat node_modules/.modules.yaml | grep -E "nodeLinker|virtualStoreDir|hoistPattern|publicHoistPattern"
pnpm writes the settings your current tree was built with into node_modules/.modules.yaml and compares them against the settings for this run. A changed nodeLinker triggers it most often, and issue #14790 reproduces it exactly: add a pnpm-workspace.yaml containing nodeLinker: hoisted after a default install. The default is isolated, per the node-modules settings reference. A drifting virtualStoreDir does it too. Issue #12307 shows .modules.yaml written twice with conflicting values when enableGlobalVirtualStore: true and the install runs from inside a workspace package, which re-purges on every install even though nothing changed.
3. How do I list the directory links pnpm chokes on?
Get-ChildItem -Force node_modules |
Where-Object { $_.LinkType } |
Select-Object Name, LinkType, Target
Every row with a LinkType of Junction or SymbolicLink is a candidate. From cmd.exe, dir /A:L node_modules gives the same picture, and fsutil reparsepoint query node_modules\is-odd confirms one specific entry is a reparse point. An isolated install creates one such link per direct dependency, so a plain pnpm install on a project with a single dependency is enough to reproduce the failure.
4. Does upgrading to 12.4.2 fix it?
pnpm self-update
pnpm --version # expect 12.4.2 or newer
PR #14798, merged 14 September 2026, replaces the std::fs::remove_file call in crates/package-manager/src/install/prepare_modules_state/purge.rs with pnpm_fs::remove_dirent, which routes directory links through remove_symlink_dir and retries transient locks. The v12.4.2 release notes state it plainly: pnpm install on Windows no longer fails with this code when clearing a node_modules that contains linked dependencies, "such as when changing nodeLinker."
Pin the version so a stale global binary on one laptop or one runner does not walk you back into it:
{
"packageManager": "pnpm@12.4.2"
}
5. What if you cannot upgrade today?
for /d %d in (node_modules\*) do rmdir "%d" 2>nul
rmdir /s /q node_modules
rmdir against a junction removes the link and leaves the target alone; the old code path never made that call. Run it from cmd.exe, not from Windows PowerShell 5.1, for the reason in the pitfalls below. Then run pnpm install normally.
6. How do I stop the NO_TTY abort in CI?
- run: pnpm install --config.confirmModulesPurge=false
env:
CI: "true"
confirmModulesPurge appears in neither the settings reference nor the pnpm install CLI page; the error text doubles as its documentation, and issue #6778 confirms the --config. prefix form. Setting CI=true has the same effect. Apply this only to the NO_TTY variant, since it does nothing at all for os error 5.
How do I prove the fix landed?
Reproduce the original trigger deliberately in a throwaway directory:
mkdir pnpm-purge-check && cd pnpm-purge-check
pnpm init
pnpm add is-odd@3.0.1
printf 'nodeLinker: hoisted\n' > pnpm-workspace.yaml
pnpm install
On 12.3.4 through 12.4.1 that last pnpm install exits non-zero with Failed to remove modules directory contents: Access is denied. (os error 5). On 12.4.2 it completes on the first attempt, and the word "first" is load-bearing: the broken versions often succeed on a second run, because the failed pass removed enough of the tree to change what the next pass encounters.
Then confirm the tree really flipped linker:
Select-String -Path node_modules\.modules.yaml -Pattern "nodeLinker"
Get-ChildItem -Force node_modules | Where-Object { $_.LinkType } | Measure-Object
A hoisted tree reports a nodeLinker: hoisted line and a link count at or near zero.
Common pitfalls
Filing it as flaky CI. The retry-succeeds pattern looks exactly like a race, and the usual response is a retry: 2 on the job. Nothing races here: a type check fails deterministically, and the retry buys silence until the first pass fails somewhere less convenient, such as a release build.
Remove-Item -Recurse in Windows PowerShell 5.1. It has a long history of walking through a junction instead of deleting it, which turns a cleanup step into deleting the contents of your pnpm content-addressable store. Use cmd /c rmdir, or PowerShell 7.
Reaching for confirmModulesPurge=false first. It suppresses the confirmation prompt, and the removal still runs. On the Windows bug the purge proceeds and fails anyway; on a repo whose node_modules should never have been purged, it deletes the tree without asking.
Assuming 12.4.2 ends every occurrence. A genuinely locked entry produces the same code: an antivirus (AV) scan, the Windows Search indexer, a node.exe still running, an editor holding a file open. On 12.4.2 the message is worth reading, because PR #14605, merged 8 September 2026, changed the output from a bare OS error to Failed to remove C:\...\node_modules\is-odd from the modules directory, naming the entry. Open the path, close whatever holds it.
A symlinked node_modules on a deploy host. Capistrano-style layouts that point node_modules at a shared cache take a full purge on every deploy. Issue #9973 has been open since September 2025 with no maintainer answer, so treat a symlinked node_modules as unsupported until one arrives.
FAQ
Does pnpm 12.4.2 fix ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR for good? It fixes the junction cause, which covers most Windows reports. A locked file, a NO_TTY abort and a symlinked node_modules still print the same family of message.
Why does the same pnpm install succeed on the second attempt? The failed pass already removed part of the tree, so the next pass meets fewer directory links. Nothing was fixed, and the failure returns whenever a fresh tree is in place.
Is it safe to set confirmModulesPurge=false? Only when you already know the purge is correct. It skips the question, not the deletion, so on a repo whose node_modules should be left alone you lose the tree silently.
Does this error happen on Linux or macOS? No. remove_file unlinks a symlink on those platforms without complaint, so the file branch that breaks on Windows never trips.
Can I just delete node_modules with Remove-Item -Recurse? Not from Windows PowerShell 5.1, which can follow a junction and empty the store behind it. Use cmd /c rmdir, or PowerShell 7.
Wrap-up
You have the version boundary (12.3.4 to 12.4.1 broken, 12.4.2 fixed), the mechanism (junction reported as a symlink, routed to DeleteFileW, refused), a PowerShell one-liner that lists the offending links, and a separating test for each of the three causes an upgrade will not touch.
The follow-up worth doing this week: pin packageManager in every repo and put that pinned version in your Windows runner matrix, so the next Rust-CLI regression surfaces in one job instead of on one engineer's laptop at 5pm. If you would rather hand the runner matrix and the pinning sweep to someone else, I take that kind of work. The pnpm deploy symlink escape on 11.19 sits one layer up in the same family. For the npm-side version of one crash line covering several broken trees, see the edgesOut crash and the TypeScript 7 ESLint break.

Comments
Be the first to comment.