jsguides

Working with the File System in Node.js

Every Node.js process that touches disk data goes through the fs module — the built-in API for working with files on disk. You will use it to read configs, write logs, handle uploads, create directories, and inspect file metadata, often in the same server or script. This guide covers each operation across all three API surfaces (callback, promise, and synchronous) so you can pick the right one without second-guessing the trade-offs.

What you’ll need

Before diving into the code, make sure Node.js 18 or later is installed. The examples use require for CJS compatibility, but every API shown also works with import in ES modules. All code runs without third-party dependencies; the fs and path modules ship with Node.js. If you want to follow along, create a working directory and a few test files:

mkdir fs-tutorial && cd fs-tutorial
echo '{"port": 3000}' > config.json
echo 'Hello, World!' > hello.txt

Reading Files

The simplest way to read a file is with fs.readFile. You pass a path and an encoding, and Node.js delivers the contents to your callback. Keep in mind that the callback-based API is the oldest layer of the fs module — it works everywhere but can get tangled when you need to read several files in sequence. Here is the basic pattern for reading a text file:

const fs = require('fs');

fs.readFile('config.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('File contents:', data);
});

The callback receives the error first (Node.js convention), then the file contents. The second argument 'utf8' tells Node.js to decode the buffer as a UTF-8 string. The callback form works everywhere, but nesting callbacks for multiple file operations quickly becomes hard to follow in longer functions.

Reading files with promises

The promise-based API in fs/promises returns the same data but integrates with async/await, making sequential operations read top-to-bottom instead of nesting inward. Here is the same read rewritten with promises:

const fs = require('fs/promises');

async function readConfig() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    console.log('Config loaded:', config);
  } catch (err) {
    console.error('Error reading config:', err);
  }
}

readConfig();

Synchronous Reading

Async patterns are idiomatic for servers, but they add overhead that is pointless in one-shot scripts. If your program starts, does one thing, and exits, the synchronous API keeps the code linear with no await noise. Build tools, code generators, and data migration scripts are the places where readFileSync and its siblings shine. Just keep them out of request handlers.

For scripts where async doesn’t matter, use the synchronous versions:

const fs = require('fs');

const data = fs.readFileSync('config.txt', 'utf8');
console.log('Contents:', data);

Warning: Avoid synchronous file operations in production servers; they block the event loop and can cause performance issues.

Writing Files

That covers reading. Writing follows the same API shape: you pass a path and content, and Node.js handles the buffer. The default behavior replaces the file entirely, which is what you want for config dumps, data exports, and most save operations. If you need to preserve what is already there, switch to appendFile, which opens the file and writes to the end without touching the existing bytes.

Writing files works similarly with fs.writeFile:

const fs = require('fs/promises');

async function saveData() {
  const data = { name: 'Alice', age: 30 };
  await fs.writeFile('user.json', JSON.stringify(data, null, 2));
  console.log('Data saved!');
}

saveData();

Overwriting is the safer default for structured data like JSON and YAML because you want the file to match the current in-memory state exactly. Appending is better for logs and event streams, where every write adds a new record without erasing history. The timestamp prefix in the example below makes each log entry independently searchable.

By default, writeFile overwrites the file. To append instead:

const fs = require('fs/promises');

async function appendToLog(message) {
  const timestamp = new Date().toISOString();
  await fs.appendFile('app.log', `[${timestamp}] ${message}\n`);
}

appendToLog('Application started');

Working with Directories

File I/O is the headline feature, but real applications also create folder trees for uploads, caches, build artifacts, and project scaffolding. The mkdir method handles that, and the recursive flag is the detail that saves you from checking whether each parent directory already exists.

Create directories with fs.mkdir:

const fs = require('fs/promises');

async function setupProject() {
  await fs.mkdir('src/utils', { recursive: true });
  await fs.mkdir('src/components', { recursive: true });
  console.log('Directories created!');
}

setupProject();

Without recursive: true, mkdir throws if any segment of the path already exists — a common surprise in scripts that run more than once. Once the directories are in place, readdir gives you a flat list of names. It does not recurse and does not include . or .., so the output is ready to iterate over directly.

The recursive: true option prevents errors if the directory already exists.

List directory contents with fs.readdir:

const fs = require('fs/promises');

async function listFiles(dir) {
  const files = await fs.readdir(dir);
  console.log('Files:', files);
}

listFiles('./src');

Checking file stats

A file name and a file path are not the same thing. You might need to know whether an entry is a file or a directory before you open it, or you might want the last-modified timestamp to skip stale data. fs.stat answers all of those questions in one call.

Get file metadata with fs.stat:

const fs = require('fs/promises');

async function fileInfo(filepath) {
  const stats = await fs.stat(filepath);
  console.log('Is file:', stats.isFile());
  console.log('Is directory:', stats.isDirectory());
  console.log('Size:', stats.size, 'bytes');
  console.log('Created:', stats.birthtime);
  console.log('Modified:', stats.mtime);
}

fileInfo('config.json');

Deleting files and directories

Metadata queries are read-only and safe to repeat. Deletion is destructive, so it pays to be explicit. Node provides separate methods for files and directories: unlink removes a single file, and rmdir takes out a directory tree. There is no trash can — once unlinked, the entry is gone.

Remove files with fs.unlink:

const fs = require('fs/promises');

async function cleanup() {
  await fs.unlink('temp.txt');
  console.log('File deleted');
}

cleanup();

Unlike unlink, which refuses to touch directories, rmdir can clean up an entire tree when you pass recursive: true. Without that flag, the directory must be empty first, which is a useful safety net during development. In production cleanup scripts, the recursive form avoids a tedious loop over nested children.

Remove directories with fs.rmdir:

const fs = require('fs/promises');

async function removeDir(dir) {
  await fs.rmdir(dir, { recursive: true });
  console.log('Directory removed');
}

removeDir('old-folder');

Working with Paths

After you create and delete a few files, you quickly run into the fact that Windows and POSIX systems separate path segments differently. Hard-coding forward slashes or backslashes in your strings will work on one machine and break on another. The path module normalizes those differences and gives you helpers for common operations like extracting the filename or extension.

Always use the path module for cross-platform path handling:

const path = require('path');

const filePath = path.join(__dirname, 'config', 'settings.json');
console.log('Full path:', filePath);

const filename = path.basename('/home/user/documents/report.pdf');
console.log('Filename:', filename); // 'report.pdf'

const ext = path.extname('image.png');
console.log('Extension:', ext); // '.png'

Streams for large files

Every method shown so far loads the entire file into a single buffer. That is fine for configs, JSON payloads, and most everyday use. It falls apart when the file is measured in gigabytes. Streams solve this by moving data in small chunks, keeping memory use constant no matter how large the file grows. The basic pattern wires a readable stream to a writable one through event handlers.

For large files, use streams to avoid loading everything into memory:

const fs = require('fs');

const readStream = fs.createReadStream('large-file.txt', 'utf8');
const writeStream = fs.createWriteStream('copy.txt');

readStream.on('data', (chunk) => {
  writeStream.write(chunk);
});

readStream.on('end', () => {
  console.log('File copied!');
});

readStream.on('error', (err) => {
  console.error('Error:', err);
});

The event-driven approach works, but it asks you to wire up three handlers and manage the write stream manually on every chunk. The pipeline function from stream/promises collapses that boilerplate into a single await — it connects the streams, propagates errors, and tears everything down when the data flow ends. For production code that copies, compresses, or encrypts files, pipeline is the shorter and safer choice.

Or use pipeline for cleaner handling:

const fs = require('fs');
const { pipeline } = require('stream/promises');

async function copyFile(src, dest) {
  const readStream = fs.createReadStream(src);
  const writeStream = fs.createWriteStream(dest);
  await pipeline(readStream, writeStream);
  console.log('Copy complete!');
}

copyFile('large-file.txt', 'copy.txt');

Choosing the right API

The file system module has a few layers, and the best choice depends on the kind of work you are doing. For scripts that run once and exit, synchronous calls can be fine because they keep the code short and direct. For servers, background jobs, and anything that handles concurrent requests, the promise-based API is a better default because it lets the event loop keep moving while disk work is in progress. That difference matters most when several users are hitting the same process at the same time.

Streams matter when data is too large to fit comfortably in memory. Instead of loading a whole file and then copying it, a stream lets Node.js move chunks through the pipeline as they arrive. That keeps memory use predictable and gives you a natural place to react to partial progress. It also fits well with transforms such as compression, encryption, and line-by-line processing. If you are unsure which API to start with, choose fs/promises first, then move to streams when the file size or throughput makes the simpler approach awkward.

Error handling and safety

Real file systems are messy. Paths can be wrong, files can disappear between checks, permissions can change, and directories can be locked by another process. Good code treats those cases as normal rather than exceptional. That usually means wrapping reads and writes in try and catch, checking for existence by attempting the operation, and deciding what should happen when the file is already gone. A cleanup step that deletes a temp file should be okay if the file is missing, while a configuration load should fail loudly if the file is not there.

It also helps to keep writes intentional. If a file matters, write to a temporary path first and then move it into place once the write succeeds. That pattern lowers the chance of leaving a half-written file behind if the process stops in the middle. Here is a safe-write helper that illustrates the approach:

const fs = require('fs/promises');
const path = require('path');

async function safeWrite(filepath, data) {
  const tmp = filepath + '.tmp.' + Date.now();
  await fs.writeFile(tmp, data);
  await fs.rename(tmp, filepath);    // atomic on the same filesystem
  console.log('Safe write complete:', path.basename(filepath));
}

safeWrite('config.json', JSON.stringify({ port: 8080 }, null, 2));

The rename call is atomic on most filesystems, so either the old file stays or the new one replaces it, with no window where the file is half-written. For logs, append operations are often enough, but for structured data it is usually better to rewrite the whole file in a known format.

Path hygiene

Cross-platform path handling deserves the same care as file reads and writes. String concatenation can work on your laptop and fail on another operating system because separators differ. The path module avoids that problem and also makes it easier to reason about folder boundaries. Use path.join() when building a path from several parts, and use path.resolve() when you need an absolute path from a relative one. Those habits reduce bugs that only appear after deployment.

The same idea applies to user input. If a path comes from a request, normalize it and check that it stays inside the directory you expect. A file server that accepts raw path strings can accidentally expose more than intended if it does not guard against traversal. Keeping the path logic in one place makes the rest of the code easier to trust, and it gives you a single spot to test when a path-related bug appears.

You’ve learned the fundamentals of working with Node.js file system:

  • Read files with fs.readFile or fs/promises
  • Write files with fs.writeFile and append with fs.appendFile
  • Create directories with fs.mkdir (use recursive: true)
  • List contents with fs.readdir
  • Get metadata with fs.stat
  • Delete with fs.unlink and fs.rmdir
  • Use streams for large files to avoid memory issues

The fs/promises API is recommended for modern Node.js applications, and it integrates naturally with async/await and is easier to reason about.

Practical file workflows

Most real file system tasks are combinations of the basics you have already seen. A script may read a file, transform the contents, and write a new version. A server may create a temp file, stream data into it, and then move it into place once the write finishes. A cleanup job may walk a directory tree and delete files that are no longer needed. The example below reads a directory of JSON files, aggregates a summary, and writes the result — a pattern you will see in build scripts and data pipelines:

const fs = require('fs/promises');
const path = require('path');

async function aggregateJson(dir) {
  const files = await fs.readdir(dir);
  const results = [];

  for (const file of files.filter(f => f.endsWith('.json'))) {
    const raw = await fs.readFile(path.join(dir, file), 'utf8');
    results.push({ file, data: JSON.parse(raw) });
  }
  await fs.writeFile('aggregate.json', JSON.stringify(results, null, 2));
  console.log(`Aggregated ${results.length} files`);
}

aggregateJson('./data').catch(err => console.error('Aggregation failed:', err));

Keeping the file system boundary small also pays off as projects grow. One module can own reading, another can own writes, and a third can handle paths or cleanup. When the responsibilities are clear, the rest of the app does not have to remember every detail of how disk access works. The code becomes easier to change because each part has a clear job and a clear error path.

For deeper exploration, the Node.js fs documentation covers the full API surface including watch, access, and permission modes.

In the next tutorial, you’ll learn how to build an HTTP server with Node.js.

Next steps

See also