Working with Files and Paths: fs, path and JSON

Node.js and Express: Build a Back End API from Scratch · Node.js Basics · 5 min read · Updated

Saving and loading data

  1. Your data lives in memory as JavaScript objects and arrays.
  2. JSON.stringify turns it into text that can be saved.
  3. writeFile saves the text in a file on disk.
  4. Later, readFile and JSON.parse bring the data back as objects.

A browser page is not allowed to read files from your disk. Node.js is. That makes it good for tools that process files, and for small apps that save data in a file. In this lesson you will use two built-in modules, node:fs/promises to read and write files, and node:path to build file paths, and you will store data as JSON. The file store you write here is the same idea we use for the task API later.

Reading and writing a file

The fs module has older callback functions and newer promise functions. Use the promise version, node:fs/promises. It works with async and await, which you will study in the next lesson. In ES modules you can use await at the top level of a file, so this works as it is.

import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const file = path.join(import.meta.dirname, 'tasks.json');
const tasks = [{ id: 1, title: 'Learn Node', done: false }];
await writeFile(file, JSON.stringify(tasks, null, 2));
const text = await readFile(file, 'utf8');
const back = JSON.parse(text);
console.log(back.length, back[0].title);
try {
  await readFile(path.join(import.meta.dirname, 'missing.json'), 'utf8');
} catch (err) {
  console.log(err.code, err.message.slice(0,40));
}
node files.js
1 Learn Node
ENOENT ENOENT: no such file or directory, open

Walk through it line by line. writeFile creates the file, or replaces it if it exists. readFile with the encoding 'utf8' returns text, and without an encoding it returns raw bytes. JSON.stringify(tasks, null, 2) turns the array into text with two-space indentation, which is easy to read. JSON.parse turns the text back into JavaScript objects. The last part reads a file that does not exist, and the catch block shows the error. The text in the output above is cut short because the real message continues with the full path of the missing file, which is different on every computer.

Build paths with the path module

Never join paths by hand with '/' or '\'. Windows and Linux use different separators. path.join does it correctly for the system it runs on. And never depend on the current folder, which is wherever you started the program. If you run node from another folder, a path like ./tasks.json points to a different place. Instead, build paths from the folder that contains the script.

  • import.meta.dirname is the folder of the current file. It is available in current Node versions (20.11 and newer).
  • path.join(import.meta.dirname, 'tasks.json') builds a full path to a file beside your script.
  • path.extname('photo.png') returns .png, and path.basename('/a/b/c.txt') returns c.txt.
  • path.resolve('data') turns a relative path into an absolute one, starting from the current folder.

Error codes

When a file operation fails, the error has a code. The most common are ENOENT (the file or folder does not exist), EACCES (no permission) and EEXIST (it already exists). Check err.code to decide what to do. A missing file at start-up is often fine, for example the first time the app runs, and the program should create an empty list. Any other error should stop the program or be reported.

async function loadTasks(file) {
  try {
    return JSON.parse(await readFile(file, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return [];   // first run: no file yet
    throw err;                              // anything else is a real problem
  }
}

The last line, throw err, passes the problem on instead of hiding it. Silent failures are the hardest bugs to find.

Other useful file functions

  • readdir(folder) lists the names inside a folder.
  • mkdir(folder, { recursive: true }) creates a folder and any missing parent folders, and does nothing if it already exists.
  • appendFile(file, text) adds text to the end of a file. It suits simple logs.
  • rename, copyFile and rm move, copy and delete files. Be careful with rm: it does not use a recycle bin.
  • stat(file) returns information such as size and last modified time.

Limits of storing data in a file

A JSON file is a very good way to learn, and it is fine for a tiny personal tool. But it has real limits. The whole file must be rewritten on every change. If two requests write at the same moment, one change can be lost. It cannot search large data quickly. And if the program crashes in the middle of writing, the file can be left half written. Databases solve these problems. Later in this course you will use a file first, because it is simple, and then MongoDB.

Your task

  1. Create files.js as shown and run it. Open tasks.json in your editor and look at it.
  2. Add a second task to the array and run again. Check that the file has two tasks.
  3. Write a function loadTasks(file) that returns an empty list when the file is missing, and test it with a file name that does not exist.
  4. Use appendFile to write a line with the current date and time to log.txt each time the script runs.

How to check it worked

  • node files.js prints 1 Learn Node and then an ENOENT line.
  • tasks.json contains your tasks with two-space indentation.
  • loadTasks('nothing.json') returns [] instead of crashing.

Frequently asked questions

What does ENOENT mean?

It means the file or folder does not exist. It is the usual error code when you read a missing file.

Why use path.join instead of joining strings?

path.join uses the right separator for the operating system and cleans up extra slashes, so the same code works on Windows, macOS and Linux.