del
del deletes files and directories matched by glob patterns. You give it patterns like 'dist/**' or an array with negations like ['temp/*.js', '!temp/keep.js'], and it resolves them through globby, deletes what matches, and resolves with the absolute paths of everything it removed. Two things separate it from a loop over fs.rm. First, it refuses by default to delete the current working directory or anything above it, so a pattern that accidentally resolves to '..' throws instead of destroying your machine. Second, it has a dryRun option that returns the exact list it would have deleted without touching anything. There are two named exports, deleteAsync and deleteSync, and the package has been ESM only since version 7. It is a build-script tool, not a runtime library.
For a clean step that needs globs, negations, and a guard against deleting your working directory, del does the job and the dryRun option makes it reviewable. If your target is a known path, use fs.rm from node:fs instead and skip 28 packages you do not need.
Use it if
- Your clean step needs glob patterns with exceptions, for example wiping dist but keeping dist/.gitkeep, which is awkward to express with fs.rm and a manual walk
- You want the safety guard. Deleting anything at or above the current working directory throws a clear error unless you explicitly pass force: true, and that check has stopped a lot of npm scripts from eating a home directory
- You want to see what a destructive command would do before it does it. dryRun: true returns the same array of absolute paths without unlinking anything, which makes a delete step reviewable in CI logs
- You need the list of what was removed. Both functions resolve with absolute paths, so you can log, count, or diff the result rather than guessing
- You are deleting enough files that pacing matters. onProgress gives you totalCount, deletedCount, percent, and the current path, and concurrency caps how many unlink calls are in flight
- You know the path you want gone. Node has had fs.rm(path, { recursive: true, force: true }) built in since 14.14, and fs.promises.rm alongside it. For 'delete the dist folder' that is one line, zero dependencies, and no glob engine
- You count what gets installed. del pulls 28 transitive packages, almost all of them the globby and fast-glob stack (micromatch, braces, picomatch, fastq, and friends), plus presentable-error at ^0.0.1, which is a pre-1.0 dependency sitting in the path of a destructive operation
- Your build tooling is CommonJS on Node 18 or 20. The package is ESM only and its own engines field allows Node 18, but require('del') throws ERR_REQUIRE_ESM there. It happens to work on Node 22.12 and later only because Node added require(esm), which is a coincidence rather than support
- You expect ** to mean 'the contents of'. It matches the parent directory too, which the README flags under a heading literally called Beware. deleteSync(['public/assets/**', '!public/assets/goat.png']) deletes public/assets itself, taking the file you tried to keep with it
- A human runs this by hand. There is no undo and no recycle bin. The trash package moves things to the system trash instead, which is the correct default for anything interactive; del is for scripts where the target is known and reproducible
- You are still on del 6 or earlier and hoping for a drop-in upgrade. Version 7 removed the default export and renamed the API to deleteAsync and deleteSync, and version 8 dropped Node below 18, so migrating is a real edit rather than a version bump
Setup reality
npm install del brings in 28 transitive packages, and the reason is globby: the glob engine (fast-glob, micromatch, braces, picomatch, @nodelib/*) is most of the tree. There is no native code and no build step. The module is ESM only, so import { deleteAsync, deleteSync } from 'del' is the supported form; require() works on Node 22.12 and later purely because Node now allows requiring ESM, and throws ERR_REQUIRE_ESM on Node 18 and 20 even though the engines field says node >= 18. Three behavioural details matter more than the install. The options object accepts every globby option, but del flips three of globby's defaults to false: expandDirectories, onlyFiles, and followSymbolicLinks. That means a pattern you copied from a globby example can match a different set of paths here, and it is why directories are matched at all. Glob patterns must use forward slashes on every platform, so a pattern built with path.join breaks on Windows and you need path.posix.join or the slash package instead. And patterns that match nothing are not an error: you get an empty array back, which is convenient in a clean script and unhelpful when you have a typo in a pattern and cannot tell why nothing happened. Deleting outside the working directory throws a PresentableError with the message 'Cannot delete files/directories outside the current working directory. Can be overridden with the force option.'
Patterns
Delete files matching a patterndelete-by-glob
import {deleteAsync} from 'del'
const deleted = await deleteAsync(['temp/*.js', '!temp/keep.js'])
console.log(deleted)
//=> ['/abs/path/temp/a.js', '/abs/path/temp/b.js']
// directories work too
await deleteAsync(['dist', 'coverage', '.cache'])Paths come back absolute, not relative to where you ran it. Patterns that match nothing resolve to an empty array with no error, so a typo in a pattern looks identical to a directory that was already clean; log the length if you care about the difference.
Delete synchronously in a small scriptdelete-sync
import {deleteSync} from 'del'
const deleted = deleteSync(['dist/**', '!dist/.gitkeep'])
console.log(`removed ${deleted.length} paths`)The sync version blocks the event loop for the whole traversal and every unlink, which is fine in a prebuild script and wrong inside a server. It also ignores the concurrency and onProgress options, since neither means anything without an event loop.
See what would be deleted firstdry-run
import {deleteAsync} from 'del'
const wouldDelete = await deleteAsync(['build/**'], {dryRun: true})
if (wouldDelete.length > 200) {
throw new Error(`refusing to delete ${wouldDelete.length} paths`)
}
await deleteAsync(['build/**'])dryRun returns the identical array the real call would, so a sanity check like this one costs a second traversal and catches a pattern that widened after a refactor. Worth wiring into any delete step that runs unattended in CI.
Keep specific files while clearing the restignore-patterns
import {deleteAsync} from 'del'
await deleteAsync([
'dist/**',
'!dist', // do not delete the directory itself
'!dist/.gitkeep',
'!dist/vendor/**',
])Order matters: negations are applied after the positive patterns, so '!dist' has to be present or the ** above it takes the directory out along with everything you tried to exclude. Excluding a nested path also needs its parent directories excluded, which is the part people miss.
Empty a directory without removing itdelete-subdirectories
import {deleteSync} from 'del'
// all subdirectories inside public/, keeping public/ itself
deleteSync(['public/*/'])
// everything inside public/, files and directories
deleteSync(['public/*'])
// WRONG: ** matches public/assets itself, not just its contents
// deleteSync(['public/assets/**', '!public/assets/goat.png'])This is the documented Beware case. A single * stays one level down and does not touch the parent, which is why 'public/*' is the safe way to empty a folder. The trailing slash in 'public/*/' restricts the match to directories.
Include hidden files in the matchdot-files
import {deleteSync} from 'del'
deleteSync('tmp/*') //=> ['tmp/package.json']
deleteSync('tmp/*', {dot: true}) //=> ['tmp/.editorconfig', 'tmp/package.json']dot defaults to false, so a clean step that looks complete can leave .DS_Store, .cache, and .env behind and the next build picks them up. An explicit dot in the pattern, such as 'tmp/.*', always matches regardless of the option.
Delete outside the working directory on purposedelete-outside-cwd
import {deleteAsync} from 'del'
// throws: Cannot delete files/directories outside the current
// working directory. Can be overridden with the `force` option.
await deleteAsync(['../old-build'])
// intentional, and you own the consequences
await deleteAsync(['../old-build'], {force: true})Verified on 8.0.1: the guard throws a PresentableError before deleting anything. force: true disables the check entirely, including for the working directory itself, so scope the pattern tightly and never build it from user input or an environment variable you did not set.
Report progress on a large deletetrack-progress
import {deleteAsync} from 'del'
await deleteAsync(['node_modules/.cache/**'], {
onProgress: progress => {
const {deletedCount, totalCount, percent, path} = progress
process.stdout.write(
`\r${deletedCount}/${totalCount} (${Math.round(percent * 100)}%) ${path ?? ''}`,
)
},
})percent is a fraction between 0 and 1, not 0 to 100. path is optional and absent when the callback fires without anything having been removed, so guard it before printing. The callback runs after each deletion, which means a slow callback throttles the whole operation.
Cap parallel deletionslimit-concurrency
import {deleteAsync} from 'del'
await deleteAsync(['cache/**'], {concurrency: 20})The default is Infinity, which on a directory with tens of thousands of entries opens as many file handles as the OS allows and can surface as EMFILE. A network or fuse-mounted filesystem is the other case where a bound helps rather than hurts.
Run the patterns relative to another directoryscope-to-directory
import {deleteAsync} from 'del'
import path from 'node:path'
const projectRoot = path.resolve('packages/api')
await deleteAsync(['dist/**', '!dist/.gitkeep'], {cwd: projectRoot})cwd is a globby option passed straight through, and it moves where patterns resolve but not where the safety check applies; the guard still compares against process.cwd(), so a cwd outside your project still needs force: true. Returned paths remain absolute.
Wire a clean step into package.jsonclean-script
// package.json
// "scripts": { "clean": "del-cli dist coverage .cache" }
// or in a script file, when you need conditions
import {deleteAsync} from 'del'
import {rm} from 'node:fs/promises'
// globs and negations: del earns its place
await deleteAsync(['dist/**', '!dist/.gitkeep'])
// a known path: no dependency needed
await rm('coverage', {recursive: true, force: true})Reach for fs.rm whenever the target is a fixed path. force: true there means 'do not throw if it is missing', which is a different meaning from del's force option, and mixing the two up is easy when both are in the same file.
Build patterns that work on Windowswindows-safe-patterns
import path from 'node:path'
import {deleteAsync} from 'del'
// path.join gives backslashes on Windows and breaks globbing
const bad = path.join('packages', pkg, 'dist', '**')
// forward slashes always
const good = path.posix.join('packages', pkg, 'dist', '**')
await deleteAsync([good, `!${path.posix.join('packages', pkg, 'dist')}`])Glob patterns are forward-slash only on every platform, which the README states outright. A Windows path without any glob characters in it is accepted as-is, so the bug only appears once a pattern contains * or **, which is exactly when it matters.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| rimraf | npm | You want a battle-worn recursive delete with a CLI, glob support, and both CommonJS and ESM builds |
| trash | npm | A person triggers the delete and you want it recoverable from the system trash instead of gone |
| fs-extra | npm | You already depend on it for copy and ensureDir, and remove() covers the delete without adding a glob engine |
| del-cli | npm | You only need this from a package.json script and would rather not write a JS file to call it |