mrkeyoor.com_
Thu 06 Aug 10:54 UTC
npmCLI & Toolingupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5Within a major it does not move, but the last two majors both required edits: version 7 dropped the default export in favour of deleteAsync and deleteSync and went ESM only, and version 8 raised the Node floor to 18. Anyone still on v6 has a migration rather than an upgrade.
Docs4/5The README documents every option with its type and default, spells out the ProgressData shape, and has an explicit Beware section about ** matching the parent directory, which is more honesty than most utilities manage. It is one page with no examples beyond the basics, and half the real option surface is inherited from globby and documented over there.
Maintenance4/5Repo pushed 21 July 2026 with 8.0.1 released in September 2025, and the tracker holds only 16 open issues with no open PRs. Releases are rare because the scope is finished, and dependencies get bumped promptly, which is the right profile for a utility this small.
Ecosystem4/5Around 13.5M weekly downloads, mostly as a build-script dependency inherited from older gulp and webpack setups, with del-cli and trash-cli alongside it. There is nothing built on top of it, and Node's own fs.rm has been steadily eating its use case since 2020.

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
Skip it if

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

PackageRegistryPick it when
rimrafnpmYou want a battle-worn recursive delete with a CLI, glob support, and both CommonJS and ESM builds
trashnpmA person triggers the delete and you want it recoverable from the system trash instead of gone
fs-extranpmYou already depend on it for copy and ensureDir, and remove() covers the delete without adding a glob engine
del-clinpmYou only need this from a package.json script and would rather not write a JS file to call it