Working with Directories
Directories are the scaffolding of any file system task: before you can write a log file you usually need its folder to exist, and before you can process a batch of uploads you need to enumerate what’s inside one. Node.js exposes a complete set of directory operations through the node:fs module — creating nested trees, listing entries with rich type information, and removing whole subtrees in a single call. This page focuses on the Promises API (fs.mkdir, fs.readdir, fs.rm) using modern async/await, which is the recommended way to work with directories in Node.js 20 and 22 LTS.
Importing the API
All examples use the Promises flavour of the node:fs module. It returns awaitable results instead of taking callbacks, which keeps directory code flat and readable.
import { mkdir, readdir, rm, rmdir } from "node:fs/promises";
import { join } from "node:path";
In CommonJS, swap the import for
const { mkdir, readdir, rm } = require("node:fs/promises");. The function names and behaviour are identical — only the module syntax differs.
Creating directories with mkdir
fs.mkdir(path[, options]) creates a single directory. By default it throws an EEXIST error if the directory already exists, and an ENOENT error if a parent directory is missing. The recursive: true option fixes both problems at once: it creates any missing parent directories and silently succeeds if the target already exists, making it the safe, idempotent choice for setup code.
import { mkdir } from "node:fs/promises";
// Creates ./data, ./data/cache, and ./data/cache/images as needed.
const created = await mkdir("data/cache/images", { recursive: true });
console.log("Created up to:", created ?? "(already existed)");
Output:
Created up to: /home/app/data
When recursive is true, mkdir resolves with the path of the first directory it had to create, or undefined if everything already existed. You can also pass a mode option (an octal permission like 0o755) to control permissions on POSIX systems; it is ignored on Windows.
| Option | Type | Default | Description |
|---|---|---|---|
recursive | boolean | false | Create missing parents; don’t error if the path exists |
mode | integer | 0o777 | POSIX permission bits (umask applied), ignored on Windows |
Reading directory contents with readdir
fs.readdir(path[, options]) lists the entries inside a directory. Called plainly it resolves to an array of file and subdirectory names as strings. That is fine when you only need names, but you frequently need to know whether each entry is a file or a folder — and calling fs.stat on every entry is slow and verbose.
import { readdir } from "node:fs/promises";
const names = await readdir("data");
console.log(names);
Output:
[ 'cache', 'config.json', 'users.csv' ]
Reading entries as Dirent objects
Passing withFileTypes: true returns an array of Dirent objects instead of strings. Each Dirent exposes the entry’s name plus type-check methods such as isDirectory(), isFile(), and isSymbolicLink() — derived from data the OS already returned, so there’s no extra syscall per entry.
import { readdir } from "node:fs/promises";
const entries = await readdir("data", { withFileTypes: true });
for (const entry of entries) {
const kind = entry.isDirectory() ? "dir " : "file";
console.log(`${kind} ${entry.name}`);
}
Output:
dir cache
file config.json
file users.csv
You can also pass recursive: true to readdir (Node 18.17+) to walk an entire tree in one call. Combined with withFileTypes, each Dirent then carries a parentPath property pointing at the directory it was found in, so you can reconstruct full paths.
import { readdir } from "node:fs/promises";
import { join } from "node:path";
const tree = await readdir("data", { recursive: true, withFileTypes: true });
const files = tree
.filter((e) => e.isFile())
.map((e) => join(e.parentPath, e.name));
console.log(files);
Output:
[ 'data/config.json', 'data/users.csv', 'data/cache/images/logo.png' ]
Older code used
Dirent.path, but it was deprecated in favour ofparentPath. PreferparentPathin new code and join it withnode:pathto stay cross-platform.
Removing directories with rm and rmdir
There are two APIs for deletion. fs.rmdir(path) is the classic call, but it only removes an empty directory — passing a non-empty one throws ENOTEMPTY. The recursive option on rmdir is deprecated, so the modern choice is fs.rm(path, options), which can delete files and directories uniformly.
To remove a whole subtree, use fs.rm with recursive: true. Add force: true to suppress errors when the path doesn’t exist, mirroring rm -rf semantics.
import { rm, rmdir } from "node:fs/promises";
// Remove an entire tree, ignoring a missing path.
await rm("data/cache", { recursive: true, force: true });
// Remove a single empty directory (errors if it still has contents).
await rmdir("data/empty-folder");
| Call | Removes | Notes |
|---|---|---|
rmdir(path) | Empty directory only | Throws ENOTEMPTY on non-empty dirs |
rm(path, { recursive: true }) | Files and full directory trees | Modern replacement for rmdir recursion |
rm(path, { force: true }) | — | No error if path is missing |
The rm options also include maxRetries and retryDelay, which help on Windows where antivirus or file-locking can cause transient EBUSY/EPERM errors during a recursive delete.
Best Practices
- Use
mkdirwithrecursive: truefor setup code so it’s idempotent and won’t crash on re-runs. - Prefer
readdirwithwithFileTypes: trueover callingstatper entry — it avoids a syscall for every file. - Build paths from
Dirent.parentPath(not the deprecatedDirent.path) and combine them withnode:pathfor cross-platform correctness. - Reach for
fs.rmwithrecursive: trueinstead of the deprecated recursivermdir; addforce: trueonly when a missing path is acceptable. - Treat recursive deletion with caution — validate the target path so a bad variable never expands into deleting the wrong tree.
- Always
awaitthese operations (or handle the returned Promise) so errors surface instead of becoming unhandled rejections.