Fix npm ENOTEMPTY: directory not empty, rename
Read both paths in the rename error, preserve your lockfile, and distinguish a stale retirement directory from an npm regression or a file lock.
TL;DR#
For ENOTEMPTY: directory not empty, rename, read both paths: npm is moving a package from path to dest. A confirmed non-empty retirement destination can block the underlying rename. Stop other installs using that directory, inspect the exact destination, move a confirmed stale directory outside node_modules, then retry the intended install while keeping package-lock.json. Current npm already retries this collision, so a persistent failure needs more evidence than the presence of a hidden folder.
This is the underlying filesystem error reproduced in an isolated Linux container:
Error: ENOTEMPTY: directory not empty, rename '/work/project/node_modules/rename-demo' -> '/work/project/node_modules/.rename-demo-x4V4s7hH'The package in that line is a local test fixture, not a dependency to download. The report that anchors this troubleshooting path is npm/cli issue #4455, filed during npm audit fix. It names npm 8.3.1 on macOS. A maintainer could not reproduce it and closed the issue after requesting fuller steps; that closure does not establish a release containing a fix. The historical report supplies the symptom, not proof that its original trigger still exists.
Why npm renames a directory full of package files#
An installed package is supposed to contain files. Seeing its lib, dist or package.json entries does not mean you have found the fault. A rename moves the directory itself; it does not require the source directory to be empty.
During dependency replacement, npm's Arborist installer can move the existing package aside before putting the new package in its place. The retirement-path implementation builds a sibling name from the package basename and a hash of its full path. That explains the hidden destination after the arrow. It is an installation working directory, rather than a second package you should add to package.json.
The key distinction is between the source and the destination. In the example, rename-demo contains the installed package. The dot-prefixed sibling already contains leftover.txt. Renaming the source onto that non-empty destination fails. Moving only the destination elsewhere allows the same source, with the same files, to be renamed successfully. Node's system-error documentation describes ENOTEMPTY as a directory-not-empty error; the syscall and paths identify the operation that encountered it.
There is another important detail in the tested npm installer source: its rename helper catches EEXIST and ENOTEMPTY, removes the destination recursively, then retries the move. Consequently, a stale retirement destination alone is not a reliable way to make that version of npm fail. If the retry also fails, or another process recreates the destination, the complete trace matters. The error line does not prove who created the directory or why recovery failed.
Inspect the destination before changing the tree#
First, let the failed command finish. Stop any other install, audit fix or automated dependency task operating on this same project. Pause a watcher only if it writes into the affected package. The aim is to inspect a stable directory, not to terminate every Node process on your computer.
Record the environment and the complete error, including the source path, destination path and syscall:
node --version
npm --version
npm prefixRun the investigation in that project root. In a workspace or monorepo, do not assume the terminal's current directory is the install root: use the absolute paths in the log. If the failed command targeted a global installation, investigate that prefix instead of running a local repair against an unrelated project. The discussion on issue #4455 also warns that audit fix is not a global-install repair command.
Inspect the destination using the exact path from your error. This complete example uses the fixture's destination; replace that argument with your own value:
node -e "const fs=require('node:fs'); const p=process.argv[1]; console.log(fs.lstatSync(p)); console.log(fs.readdirSync(p));" "node_modules/.rename-demo-x4V4s7hH"Check that it is the hidden sibling npm was trying to use, that it contains files, and that no active install owns it. If it has already disappeared, do not guess a replacement by deleting every dot-prefixed directory. Hidden entries such as the installer lockfile are not interchangeable retirement targets. Keep the original log and repeat the command once with logging if necessary.
An ENOTEMPTY involving rmdir or a different destination requires its own investigation. The procedure here is specifically for rename onto a confirmed stale package-retirement directory.
Move the confirmed stale sibling aside#
Preserve the evidence by moving the destination outside node_modules rather than erasing it immediately. Save this as quarantine-retired.cjs in the project root:
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve('node_modules');
if (!process.argv[2]) throw new Error('Pass the exact dest path from the npm error');
const target = path.resolve(process.argv[2]);
const relative = path.relative(root, target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Destination must be inside this project node_modules');
}
if (!path.basename(target).startsWith('.') || path.basename(target) === '.package-lock.json') {
throw new Error('Expected a confirmed hidden retirement directory');
}
if (!fs.lstatSync(target).isDirectory()) {
throw new Error('Destination must be a directory, not a file or symlink');
}
const backupRoot = fs.mkdtempSync(path.join(process.cwd(), '.npm-retired-'));
const backup = path.join(backupRoot, path.basename(target));
fs.renameSync(target, backup);
console.log('Moved to ' + backup);After checking the exact destination, run the script with that path. Again, the argument below belongs to the fixture:
node quarantine-retired.cjs "node_modules/.rename-demo-x4V4s7hH"The checks reduce accidental broad moves; they do not decide whether a directory is stale for you. That decision comes from the failed log and the absence of an active install. A scoped package can have its hidden retirement sibling inside its scope directory, so retain the full destination instead of rebuilding its name from memory.
Retry the operation you intended to perform. For a normal dependency installation:
npm installThis leaves the source package available for npm to inspect and replace. It also leaves your existing dependency decisions in package-lock.json. An install or audit fix may legitimately change the lockfile as part of the requested dependency update; review that separately from the directory repair. The installation documentation explains the lockfile's role in choosing the dependency tree.
Do not introduce a force option or relax peer constraints just to address a filesystem destination. Neither action removes the directory identified by this rename error. Similarly, npm's cache documentation describes an integrity-checked cache that normally repairs corruption automatically. Clearing that cache does not move the retirement sibling in your project's node_modules.
What the isolated reproduction proves#
The reproduction ran on 2 November 2026 in the cached official node:24-alpine Docker image, using Node v24.22.2 and npm 11.19.1. All package files lived on the container's Linux filesystem. The fixture was packed locally in two versions, so it required neither a third-party dependency nor a running registry service.
After installing the first tarball, the script used npm's own retirement-path helper to calculate the destination. It created a file inside that destination and called Node's rename directly. That produced the ENOTEMPTY line above. Moving only the destination aside made the identical rename succeed:
rename succeeds with non-empty source and absent destinationThe script then restored the package's ordinary name and ran npm install. A SHA-256 comparison of package-lock.json before and after the targeted repair reported:
lock-unchanged=trueThere was also a negative control. The script deliberately created another stale retirement destination, then used normal npm install to replace the fixture with its second tarball. The command succeeded, the new package loaded, and the retirement destination disappeared:
retirement-destination-removed=trueThis reproduces the filesystem collision and its one-change repair. It does not reproduce issue #4455's audit-fix failure on modern npm. The successful update is evidence against claiming that every leftover destination still breaks npm. If yours repeatedly fails after quarantine, retain the debug log, both directory listings and your runtime versions, then investigate the retry path rather than repeating increasingly broad deletions.
Confirm the installation and recognise the next failure#
A successful rename is only one part of a successful install. Check that the command exits successfully, then inspect the affected package with npm ls and run your project's normal test or build command. Compare package.json and package-lock.json with their intended state. Once the project works, remove the quarantine backup after checking its contents; it is no longer part of the active dependency tree.
For a disposable checkout with a committed, matching lockfile, npm ci is a separate way to recreate the installation. The npm ci documentation states that it removes an existing node_modules tree and does not write package.json or package-lock.json. Choose that deliberately when you want a clean reconstruction; it is a broader operation than moving a single stale sibling, and it needs the lockfile to agree with the manifest.
If the retry now waits on idealTree buildDeps, continue with finding the registry or proxy request that stalls npm. That page diagnoses the network wait, which occurs at a different step. If dependency resolution instead reports ERESOLVE, the Firebase prerelease peer-conflict example shows how selected versions can fail a peer range. Neither message means the hidden destination still needs moving.
In Docker Compose, a successful host install can also be followed by a missing module inside the container. Check how bind mounts and reused dependency volumes hide installed packages before reinstalling repeatedly in the wrong filesystem. If the error changes to EPERM or operation not permitted on Windows, treat it as a separate permissions or active-handle investigation: the non-empty-destination proof here does not establish a Windows file-lock cause. Let the actual syscall, destination and new error code determine the next action.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
Fix npm WARN package.json: No repository field
The warning is informational and harmless. Silence it for good in an application, fill it in properly for a library, and know why modern npm no longer prints it.
npm ERR! Invalid version: "1" — make it full semver
Find which package.json carries the short version, rewrite it as major.minor.patch, and know which npm commands check the field on old and current npm.
Fix Docker npm EBADENGINE: Unsupported engine
Read the Required and Actual lines, distinguish npm self-upgrades from application installs, and stop a floating npm release breaking an unchanged Dockerfile.


