Files and paths
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.
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.
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);});"first line\nsecond line\n"23 trueJSON.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.
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);});{ theme: 'dark', fontSize: 14, tags: [ 'a', 'b' ] }trueThe 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.
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 });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.
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));});ENOENT openENOENT: no such file or directory, open '<dir>/missing.txt'EEXISTENOENTmeans “no such file or directory”. Thesyscallnames the operating system call that failed, and the message includes the full path, which is why the example replaces the random directory.- The
flagoption 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.
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());});[ 'a.txt', 'old' ][ 'docs', 'docs/a.txt', 'docs/old', 'docs/old/b.txt' ]5 true falseThe 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.
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"); }});done.txt filepics folderrename 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.
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));logs/app.log/srv/app/logsapp.2026.log .log /srv/app/logsapp.2026{ root: '/', dir: '/srv/app/logs', base: 'app.2026.log', ext: '.log', name: 'app.2026'}logs/app.2026.logjoinglues the pieces with the right separator and tidies..and.. The result can be relative.resolveworks from right to left until it has an absolute path, starting from the current working directory if it must.basename,extnameanddirnametake a path apart.extnameincludes the dot and looks only at the last one.parsereturns all the parts as one object, andrelative(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.
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)));fp_meta.mjstruetrueusers.json dataURLs
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.
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);https: example.com 8080/a/b.html #topjs https://example.com:8080https://example.com:8080/a/b.html?q=js&page=3&tag=x+y#topsearchParams 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.
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);}https://example.com/a/cfalsetrueTypeError ERR_INVALID_URLA 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.
console.log(encodeURIComponent("red shoes & socks/100%"));console.log(decodeURIComponent("red%20shoes"));
try { decodeURIComponent("100%");} catch (error) { console.log(error.name, error.message);}red%20shoes%20%26%20socks%2F100%25red shoesURIError URI malformedFile 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.
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"));file:///tmp/my%20notes/a.txt/tmp/my notes/a.txt/tmp/x#y.txtPutting 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.
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"));});a.txt: 3b.txt: 4Promise.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. Usejoin(import.meta.dirname, ...)for files that ship with your program. - Building paths with
+ "/" +. Usejoinorresolve. - Forgetting
await. The file may not be written when the next line reads it, and an error becomes an unhandled rejection. writeFilereplaces silently. Use the"wx"flag orappendFilewhen overwriting would be a bug.- Checking with
accessfirst, or matching the error message. Handle the error and compareerror.code. - Trusting user-supplied path parts.
../../etc/passwdescapes your folder (second exercise).
Exercises
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: 2Show solution
A Map collects the counts, and sorting the keys makes the output independent of the order that readdir returns.
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)}`); }});.js: 1.md: 1.txt: 2A 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.txtoutside rootShow 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.
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); }}/srv/public/docs/a.txt/srv/public/b.txtoutside rootWrite 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=42red shoesShow 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.
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"));https://shop.test/search?q=red+shoes&size=42red shoesSummary
node:fs/promisesgives promise versions of the file functions:readFile,writeFile,appendFile,mkdir,readdir,stat,rename,rm. Pass"utf8"to read text. TheSyncversions block; keep them for start-up code.- Errors carry a
codesuch asENOENTorEEXIST. Test the code, and try the operation instead of checking first. - Do experiments in a directory from
mkdtemp, and remove it in afinallyblock. node:pathbuilds and parses file names portably. Anchor program files toimport.meta.dirname.URLparses, builds and escapes web addresses.encodeURIComponentescapes one piece anddecodeURIComponentthrowsURIErroron bad input.pathToFileURLandfileURLToPathconvert between paths andfile:URLs.
Next: command-line tools that take arguments and report success or failure to the shell.