Files and paths

Intermediate55 min

Read, write and list files with node:fs/promises, build file names safely with node:path, and work with URLs and file URLs, using a temporary directory that cleans up after itself.

What you will learn
  • Read and write text and JSON files with node:fs/promises
  • Catch file errors by their code, such as ENOENT and EEXIST
  • Create, list and remove directories, and inspect files with stat
  • Build and take apart file names with node:path, and locate files with import.meta.dirname
  • Parse and build URLs with the URL class and convert between file URLs and paths
On this page

Programs that matter keep data somewhere: settings, logs, user documents. In Node the main tool for that is the node:fs module (file system). It comes in three flavours: callbacks, synchronous functions (names ending in Sync) and promises. The callbacks lesson showed why the first is awkward, so this lesson uses node:fs/promises with async and await. Alongside it we need node:path for file names and the URL class for addresses.

A safe place to experiment

Examples that write files should not litter your project. Operating systems have a temporary folder, and os.tmpdir() gives its location. fs.mkdtemp(prefix) creates a new, empty directory there with a random name and returns its path. This helper wraps the whole pattern: create the directory, run your function with it, and remove the directory afterwards, even if the function throws.

fp_helper.mjs
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
export async function withTempDir(work) {
const dir = await mkdtemp(join(tmpdir(), "lesson-"));
try {
return await work(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}

rm with recursive: true deletes a directory and everything inside it, and force: true stops it complaining when the path does not exist. The finally block runs whatever happens, which is what clean-up code needs. Save the file as fp_helper.mjs; the examples in this module import it. The random name is never printed, so the output is the same on every machine.

Reading and writing text

writeFile(path, data) creates the file or replaces it. appendFile adds to the end. readFile(path, "utf8") returns a string; without the encoding you get a Buffer, a typed array of raw bytes.

fp_readwrite.mjs
import { writeFile, appendFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
await withTempDir(async (dir) => {
const file = join(dir, "notes.txt");
await writeFile(file, "first line\n");
await appendFile(file, "second line\n");
const text = await readFile(file, "utf8");
console.log(JSON.stringify(text));
const bytes = await readFile(file);
console.log(bytes.length, bytes instanceof Uint8Array);
});
Output
"first line\nsecond line\n"
23 true

JSON.stringify on the string makes the \n characters visible. The file holds 23 bytes, and a Buffer is a Uint8Array, hence the true. Data is stored the same way as in the JSON lesson: JSON.stringify to write, JSON.parse to read.

fp_json.mjs
import { writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
await withTempDir(async (dir) => {
const file = join(dir, "settings.json");
const settings = { theme: "dark", fontSize: 14, tags: ["a", "b"] };
await writeFile(file, JSON.stringify(settings, null, 2));
const loaded = JSON.parse(await readFile(file, "utf8"));
console.log(loaded);
console.log(loaded.tags !== settings.tags);
});
Output
{ theme: 'dark', fontSize: 14, tags: [ 'a', 'b' ] }
true

The object came back equal but not identical: loaded.tags is a new array. Functions and Date objects would not survive the trip.

The synchronous flavour

The Sync functions do the same jobs but block the program until the disk answers, and they throw where the promise versions reject. That is fine for start-up code and short scripts, and wrong inside a request handler, where the event loop must stay free.

fp_sync.mjs
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = mkdtempSync(join(tmpdir(), "sync-"));
const file = join(dir, "settings.json");
console.log(existsSync(file));
writeFileSync(file, JSON.stringify({ retries: 3 }));
console.log(JSON.parse(readFileSync(file, "utf8")));
rmSync(dir, { recursive: true });
Output
false
{ retries: 3 }

When things go wrong

File operations fail for ordinary reasons: the file is not there, it already exists, you may not touch it. The promise rejects with an Error that has a code. Test the code, never the message text.

fp_errors.mjs
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
await withTempDir(async (dir) => {
const missing = join(dir, "missing.txt");
try {
await readFile(missing, "utf8");
} catch (error) {
console.log(error.code, error.syscall);
console.log(error.message.replace(dir, "<dir>"));
}
const file = join(dir, "once.txt");
await writeFile(file, "x", { flag: "wx" });
await writeFile(file, "y", { flag: "wx" }).catch((e) => console.log(e.code));
});
Output
ENOENT open
ENOENT: no such file or directory, open '<dir>/missing.txt'
EEXIST
  • ENOENT means “no such file or directory”. The syscall names the operating system call that failed, and the message includes the full path, which is why the example replaces the random directory.
  • The flag option controls how a file is opened. "wx" means “write, but fail if the file exists”: the safe way to say “create, never overwrite”.
  • Other codes: EACCES (permission denied), EISDIR (it is a directory), ENOTEMPTY (the directory has contents).

Do not check first and then act. access() followed by readFile() leaves a gap in which another program can change the file, and Node’s own docs discourage it. Try the operation and handle the error.

Directories and file information

mkdir creates a directory; with { recursive: true } it also creates missing parents and does not complain when the directory exists. readdir lists names, and stat describes one entry.

fp_dirs.mjs
import { mkdir, writeFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
await withTempDir(async (dir) => {
await mkdir(join(dir, "docs", "old"), { recursive: true });
await writeFile(join(dir, "docs", "a.txt"), "hello");
await writeFile(join(dir, "docs", "old", "b.txt"), "hi");
console.log((await readdir(join(dir, "docs"))).sort());
console.log((await readdir(dir, { recursive: true })).sort());
const info = await stat(join(dir, "docs", "a.txt"));
console.log(info.size, info.isFile(), info.isDirectory());
});
Output
[ 'a.txt', 'old' ]
[ 'docs', 'docs/a.txt', 'docs/old', 'docs/old/b.txt' ]
5 true false

The lists are sorted because the operating system decides the order readdir returns. With recursive: true the names are paths relative to the directory you asked about. stat gives the size in bytes, the modification time and methods such as isFile().

To tell files from folders while listing, ask for withFileTypes: true. You get objects with a name and the same isFile() and isDirectory() methods, with no extra stat call.

fp_entries.mjs
import { mkdir, writeFile, readdir, rename } from "node:fs/promises";
import { join } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
await withTempDir(async (dir) => {
await mkdir(join(dir, "pics"));
await writeFile(join(dir, "todo.txt"), "buy milk");
await rename(join(dir, "todo.txt"), join(dir, "done.txt"));
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(entry.name, entry.isDirectory() ? "folder" : "file");
}
});
Output
done.txt file
pics folder

rename moves or renames. cp(from, to, { recursive: true }) copies a tree (stable since 22.3). glob(pattern) lists files matching a pattern such as **/*.txt as an async iterable; it is stable from 22.17, and on 22.0 to 22.16 it prints an experimental warning.

Building file names with node:path

Never build a path by gluing strings with "/": Windows uses a backslash, and doubled or missing separators cause quiet bugs. node:path does it correctly for the system the program runs on. The listings show Linux and macOS results.

fp_path.mjs
import path from "node:path";
console.log(path.join("data", "..", "logs", "app.log"));
console.log(path.resolve("/srv/app", "data", "../logs"));
const file = "/srv/app/logs/app.2026.log";
console.log(path.basename(file), path.extname(file), path.dirname(file));
console.log(path.basename(file, ".log"));
console.log(path.parse(file));
console.log(path.relative("/srv/app", file));
Output
logs/app.log
/srv/app/logs
app.2026.log .log /srv/app/logs
app.2026
{
root: '/',
dir: '/srv/app/logs',
base: 'app.2026.log',
ext: '.log',
name: 'app.2026'
}
logs/app.2026.log
  • join glues the pieces with the right separator and tidies .. and .. The result can be relative.
  • resolve works from right to left until it has an absolute path, starting from the current working directory if it must.
  • basename, extname and dirname take a path apart. extname includes the dot and looks only at the last one.
  • parse returns all the parts as one object, and relative(from, to) is the route from one path to another.

Where is my script?

A relative path such as "data/users.json" is relative to the current working directory, the folder where you typed node, not to the script’s folder. The same program then works from one folder and fails from another. In an ES module, import.meta.dirname is the folder of the current file and import.meta.filename its full path. Both are stable from Node 22.16 (they work on earlier 22.x). Build paths from them with join.

fp_meta.mjs
import path from "node:path";
console.log(path.basename(import.meta.filename));
console.log(import.meta.dirname === path.dirname(import.meta.filename));
console.log(import.meta.url.startsWith("file:///"));
const dataFile = path.join(import.meta.dirname, "data", "users.json");
console.log(path.basename(dataFile), path.basename(path.dirname(dataFile)));
Output
fp_meta.mjs
true
true
users.json data

URLs

A URL is an address with parts: protocol, host, path, query and fragment. The URL class is a web standard and works the same in browsers and Node. It does the parsing, escaping and resolving of relative links that hand-written string code gets wrong.

fp_url.mjs
const url = new URL("https://example.com:8080/a/b.html?q=js&page=2#top");
console.log(url.protocol, url.hostname, url.port);
console.log(url.pathname, url.hash);
console.log(url.searchParams.get("q"), url.origin);
url.searchParams.set("page", "3");
url.searchParams.append("tag", "x y");
console.log(url.href);
Output
https: example.com 8080
/a/b.html #top
js https://example.com:8080
https://example.com:8080/a/b.html?q=js&page=3&tag=x+y#top

searchParams is a live URLSearchParams object: changing it changes href, and it escapes values for you (the space became +). A URL can also be built from a relative reference and a base, and URL.canParse asks whether a string is acceptable without throwing.

fp_url.mjs (continued)
console.log(new URL("../c", "https://example.com/a/b/").href);
console.log(URL.canParse("not a url"));
console.log(URL.canParse("/a", "http://x.test"));
try {
new URL("not a url");
} catch (error) {
console.log(error.name, error.code);
}
Output
https://example.com/a/c
false
true
TypeError ERR_INVALID_URL

A bad string throws a TypeError with the code ERR_INVALID_URL, so use canParse or a try for text from a user. The old url.parse() is deprecated and not safe; use new URL().

To escape one piece of text yourself, use encodeURIComponent, and decodeURIComponent to reverse it. A malformed escape such as a lone % throws a URIError, so decode untrusted text inside a try.

fp_uri.mjs
console.log(encodeURIComponent("red shoes & socks/100%"));
console.log(decodeURIComponent("red%20shoes"));
try {
decodeURIComponent("100%");
} catch (error) {
console.log(error.name, error.message);
}
Output
red%20shoes%20%26%20socks%2F100%25
red shoes
URIError URI malformed

File URLs

A file can be written as a URL that starts with file://, and that is how import.meta.url describes the current module. node:url converts both ways, and handles characters that need escaping.

fp_fileurl.mjs
import { fileURLToPath, pathToFileURL } from "node:url";
const url = pathToFileURL("/tmp/my notes/a.txt");
console.log(url.href);
console.log(fileURLToPath(url));
console.log(fileURLToPath("file:///tmp/x%23y.txt"));
Output
file:///tmp/my%20notes/a.txt
/tmp/my notes/a.txt
/tmp/x#y.txt

Putting it together: word count for a folder

The program creates three small files, lists the folder, reads the .txt files at once with Promise.all and prints the number of words in each.

fp_wordcount.mjs
import { writeFile, readdir, readFile } from "node:fs/promises";
import { join, extname } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
const countWords = (text) => text.split(/\s+/).filter(Boolean).length;
await withTempDir(async (dir) => {
await writeFile(join(dir, "a.txt"), "one two three\n");
await writeFile(join(dir, "b.txt"), "four\nfive six\n\nseven\n");
await writeFile(join(dir, "skip.md"), "ignored text");
const names = (await readdir(dir)).filter((n) => extname(n) === ".txt");
const counts = await Promise.all(
names.sort().map(async (name) => {
const text = await readFile(join(dir, name), "utf8");
return `${name}: ${countWords(text)}`;
}),
);
console.log(counts.join("\n"));
});
Output
a.txt: 3
b.txt: 4

Promise.all starts all the reads together and keeps the results in input order. For thousands of files you would limit the concurrency, as in the combinators lesson. A single huge file should not be read in one piece at all; that is the subject of the streams lesson.

Common pitfalls

  • Relative paths. readFile("data.json") looks in the working directory. Use join(import.meta.dirname, ...) for files that ship with your program.
  • Building paths with + "/" +. Use join or resolve.
  • Forgetting await. The file may not be written when the next line reads it, and an error becomes an unhandled rejection.
  • writeFile replaces silently. Use the "wx" flag or appendFile when overwriting would be a bug.
  • Checking with access first, or matching the error message. Handle the error and compare error.code.
  • Trusting user-supplied path parts. ../../etc/passwd escapes your folder (second exercise).

Exercises

ExerciseCount files by extension

In a temporary directory create a.txt, b.md, c.txt and d.js (empty content is fine). Read the directory and count the files for each extension. Print one line per extension in alphabetical order, as extension: count.

Expected output:

.js: 1
.md: 1
.txt: 2
Show solution

A Map collects the counts, and sorting the keys makes the output independent of the order that readdir returns.

fp_ex_ext.mjs
import { writeFile, readdir } from "node:fs/promises";
import { join, extname } from "node:path";
import { withTempDir } from "./fp_helper.mjs";
await withTempDir(async (dir) => {
for (const name of ["a.txt", "b.md", "c.txt", "d.js"]) {
await writeFile(join(dir, name), "");
}
const counts = new Map();
for (const name of await readdir(dir)) {
const ext = extname(name);
counts.set(ext, (counts.get(ext) ?? 0) + 1);
}
for (const ext of [...counts.keys()].sort()) {
console.log(`${ext}: ${counts.get(ext)}`);
}
});
Output
.js: 1
.md: 1
.txt: 2
ExerciseStay inside the folder

A web server serves files from a folder /srv/public. Write safeJoin(root, userPath) that returns the resolved absolute path, but throws an Error with the message outside root when the result is not inside root. Test it with docs/a.txt, ./b.txt and ../secret.txt, and print either the resolved path or the error message.

Expected output:

/srv/public/docs/a.txt
/srv/public/b.txt
outside root
Show solution

resolve removes the .. parts. Then relative(root, target) tells whether the target is inside: if the route starts with .. (or is absolute), the target is outside.

fp_ex_safe.mjs
import { resolve, relative, isAbsolute } from "node:path";
function safeJoin(root, userPath) {
const target = resolve(root, userPath);
const route = relative(root, target);
if (route.startsWith("..") || isAbsolute(route)) {
throw new Error("outside root");
}
return target;
}
for (const input of ["docs/a.txt", "./b.txt", "../secret.txt"]) {
try {
console.log(safeJoin("/srv/public", input));
} catch (error) {
console.log(error.message);
}
}
Output
/srv/public/docs/a.txt
/srv/public/b.txt
outside root
ExerciseBuild a search URL

Write searchUrl(base, params) that returns the full address of a search page. It takes a base such as https://shop.test/search and an object of parameters. Skip parameters whose value is undefined. Print the result for { q: "red shoes", size: 42, color: undefined } and then the result of reading q back from the finished URL.

Expected output:

https://shop.test/search?q=red+shoes&size=42
red shoes
Show solution

searchParams.set turns every value into a string and escapes it, so numbers need no conversion. Reading q back decodes the + into a space.

fp_ex_search.mjs
function searchUrl(base, params) {
const url = new URL(base);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) url.searchParams.set(key, value);
}
return url;
}
const url = searchUrl("https://shop.test/search", {
q: "red shoes",
size: 42,
color: undefined,
});
console.log(url.href);
console.log(url.searchParams.get("q"));
Output
https://shop.test/search?q=red+shoes&size=42
red shoes

Summary

  • node:fs/promises gives promise versions of the file functions: readFile, writeFile, appendFile, mkdir, readdir, stat, rename, rm. Pass "utf8" to read text. The Sync versions block; keep them for start-up code.
  • Errors carry a code such as ENOENT or EEXIST. Test the code, and try the operation instead of checking first.
  • Do experiments in a directory from mkdtemp, and remove it in a finally block.
  • node:path builds and parses file names portably. Anchor program files to import.meta.dirname.
  • URL parses, builds and escapes web addresses. encodeURIComponent escapes one piece and decodeURIComponent throws URIError on bad input. pathToFileURL and fileURLToPath convert between paths and file: URLs.

Next: command-line tools that take arguments and report success or failure to the shell.