Skip to content
Node.js nd fs 5 min read

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.

OptionTypeDefaultDescription
recursivebooleanfalseCreate missing parents; don’t error if the path exists
modeinteger0o777POSIX 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 of parentPath. Prefer parentPath in new code and join it with node:path to 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");
CallRemovesNotes
rmdir(path)Empty directory onlyThrows ENOTEMPTY on non-empty dirs
rm(path, { recursive: true })Files and full directory treesModern 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 mkdir with recursive: true for setup code so it’s idempotent and won’t crash on re-runs.
  • Prefer readdir with withFileTypes: true over calling stat per entry — it avoids a syscall for every file.
  • Build paths from Dirent.parentPath (not the deprecated Dirent.path) and combine them with node:path for cross-platform correctness.
  • Reach for fs.rm with recursive: true instead of the deprecated recursive rmdir; add force: true only 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 await these operations (or handle the returned Promise) so errors surface instead of becoming unhandled rejections.
Last updated June 14, 2026
Was this helpful?