Skip to content
JavaScript

How to Read and Write JSON Files in Node.js (fs, require, and import)

·8 min read
How to Read and Write JSON Files in Node.js (fs, require, and import) - JavaScript guide from JSON Prettifier

Quick answer: To read a JSON file in Node.js, call JSON.parse(fs.readFileSync(path, "utf8")), or JSON.parse(await fs.promises.readFile(path, "utf8")) in async code. To write one, call fs.writeFileSync(path, JSON.stringify(data, null, 2)). require() and import also load JSON files, but they cache the result, so use them only for data that does not change while the process runs.

A feature flag file gets edited on the server, the admin reloads the page, and nothing changes. The code reads flags.json with require(), which read the file exactly once, at startup, and will never read it again. That is the most common JSON-file bug in Node, and it is a good reason to know all four ways to load a file rather than just the shortest one.

Explore the resulting JSON in the JSON Viewer, or select nested fields with the JSONPath Query Tester.

Which Method Should You Use?

MethodBehaviorBest forWatch out
require('./x.json')Sync, cachedStatic config loaded once at startupCommonJS only; cached forever
import x from './x.json' with { type: 'json' }Sync at load, cachedStatic config in ES modulesNeeds the import attribute
fs.readFileSync + JSON.parseSync, fresh each callCLI tools, scripts, startup codeBlocks the event loop
fs.promises.readFile + JSON.parseAsync, fresh each callServers, anything at request timeNeeds await

Method 1: require() in CommonJS

const config = require("./config.json");
console.log(config.port);   // 3000

Node resolves the path relative to the current file, reads it, parses it, and hands back the object. No fs, no JSON.parse. For package.json or a config file that is part of your source tree, this is fine.

The caching trap

Every module Node loads goes into require.cache, JSON files included. A second require("./config.json") anywhere in the process returns the same object without reading the disk. Two consequences:

  • Edits to the file after startup are never seen.
  • If one module does config.port = 4000, every other module that required the file sees4000 too, because they all share one object.

You can delete the cache entry with delete require.cache[require.resolve("./config.json")], but if you find yourself doing that, the file is not static and you should be using fs.

Method 2: import in ES Modules

In an ES module (a .mjs file, or a project with "type": "module" in package.json), a bare import of a JSON file is an error. You must add an import attribute that tells Node the module is JSON:

import config from "./config.json" with { type: "json" };
console.log(config.port);

Older Node releases used the keyword assert instead of with; the with form is the standardized one and the one to write today. If you see TypeError [ERR_IMPORT_ATTRIBUTE_MISSING], the attribute is absent. If you see a parse error on the word with itself, the Node version is too old for the new syntax.

The same caching rule applies: ES modules are evaluated once, so the data is a startup snapshot. Dynamic await import("./x.json", { with: { type: "json" } }) is also cached by URL.

If importing works locally but fails after building or deploying, use these four JSON import attribute checks to separate emitted syntax, default exports, asset delivery, and cache behavior before changing the loading method.

Method 3: fs.readFileSync + JSON.parse

const fs = require("node:fs");
const path = require("node:path");

const file = path.join(__dirname, "data", "users.json");
const users = JSON.parse(fs.readFileSync(file, "utf8"));

This reads the disk every time, so it always reflects the current file. It blocks the event loop while the read happens, which is irrelevant in a CLI script and harmful inside a request handler on a busy server.

Pass "utf8". Without an encoding, readFileSync returns a Buffer. JSON.parse will call toString() on it and usually get away with it, but being explicit avoids surprises with other encodings. If the file begins with a UTF-8 byte order mark, JSON.parse fails with an unexpected token error on the invisible first character; strip it with text.replace(/^/, "") or fix the file, as covered in the UTF-8 and BOM guide.

Method 4: fs/promises for Async Code

import { readFile } from "node:fs/promises";

async function loadUsers(file) {
  const text = await readFile(file, "utf8");
  return JSON.parse(text);
}

const users = await loadUsers(new URL("./data/users.json", import.meta.url));

This is the default choice for anything that runs while a server is serving. It does not block, it reads fresh data, and the error handling is a normal try/catch around await. The callback form fs.readFile(file, "utf8", (err, text) => ...) still exists and still works, but the promise API composes better.

Resolving the Path Correctly

fs resolves relative paths against process.cwd(), the directory the process was started from, not the directory of the script. Running node src/app.js from the project root and node app.js from inside src give different results for "./data.json". Anchor the path to the file instead:

// CommonJS
const file = path.join(__dirname, "data.json");

// ES modules
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = join(__dirname, "data.json");

// ES modules, shorter: fs accepts a file URL directly
const file = new URL("./data.json", import.meta.url);

Recent Node versions also expose import.meta.dirname, which removes the fileURLToPath dance; check that your minimum supported version has it before relying on it.

Writing a JSON File

import { writeFile } from "node:fs/promises";

const data = { port: 3000, features: ["search", "export"] };

await writeFile("config.json", JSON.stringify(data, null, 2) + "\n", "utf8");

The second argument to JSON.stringify is a replacer (leave it null); the third is the indent, either a number of spaces or a string such as "\t". Two spaces is the common convention for files that live in a repository. Appending "\n" gives the file a trailing newline, which keeps git diffs clean and linters quiet. The JSON.stringify guide covers the replacer and what happens to undefined, functions, and dates.

Atomic writes: temp file plus rename

writeFile truncates the file first and then writes. If the process is killed, or the disk fills, between those two steps, you are left with an empty or half-written file, and the next read fails with Unexpected end of JSON input. For anything you cannot regenerate, write to a temporary file in the same directory and rename it into place. A rename on the same filesystem is atomic, so readers see either the old file or the new one, never a partial one:

import { writeFile, rename } from "node:fs/promises";
import { randomUUID } from "node:crypto";

async function writeJsonAtomic(file, data) {
  const tmp = file + "." + randomUUID() + ".tmp";
  await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", {encoding: "utf8", flag: "wx", mode: 0o600});
  await rename(tmp, file);
}

Keep the temp file in the same directory as the target. A cross-filesystem rename can fail with EXDEV; it does not automatically become a safe copy. This minimal example omits failed-candidate cleanup, metadata preservation, synchronization, and writer coordination. Atomic replacement alone does not prevent stale snapshots from overwriting each other. Use the six JSON file write checks to test unique staging files, the complete read-modify-write cycle, durability, and recovery before using JSON as persistent application state.

Error Handling: ENOENT and SyntaxError

Reading a JSON file can fail in two distinct ways, and they deserve different handling:

import { readFile } from "node:fs/promises";

async function readJson(file, fallback) {
  let text;
  try {
    text = await readFile(file, "utf8");
  } catch (err) {
    if (err.code === "ENOENT" && fallback !== undefined) return fallback;
    throw err;                    // EACCES, EISDIR, etc. are real problems
  }
  try {
    return JSON.parse(text);
  } catch (err) {
    throw new Error("Invalid JSON in " + file + ": " + err.message);
  }
}

A missing file (ENOENT) is often expected: a settings file the user has not created yet. A corrupt file is never expected and should fail loudly with the file name attached, because JSON.parse alone reports a position but not which file. When that happens, paste the file into JSON Prettifier to get the exact line and column; the JSON parse error guide lists what each message means.

Large Files: When JSON.parse Is the Wrong Tool

JSON.parse needs the entire document in memory as one string, and V8 caps string length at roughly 512 MB on 64-bit builds. Well before that limit, parsing a few hundred megabytes takes seconds and holds two copies of the data (text and object) at once. Options, in order of effort:

  1. Switch the format to JSON Lines. One object per line means you can read the file with readline and parse each line independently, with constant memory. See JSON Lines explained.
  2. Use a streaming parser. The stream-json package emits objects from inside a large array as they are parsed, without loading the whole file.
  3. Split the file at the producer, or paginate the data source.
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const rl = createInterface({ input: createReadStream("events.jsonl", "utf8") });
for await (const line of rl) {
  if (!line.trim()) continue;
  const event = JSON.parse(line);
  // handle one event at a time
}

The JSON file size limits post goes into the numbers for Node, browsers, and common API gateways.

Common Mistakes

  • Using require() for data that changes. The cache means you are reading a snapshot. Use fs.
  • Mutating a required JSON object. Every module shares it. Clone with structuredClone() if you must change it locally.
  • Relative paths with fs. They resolve against the working directory, not the script. Use __dirname or import.meta.url.
  • Forgetting the encoding. You get a Buffer, and console.log shows bytes instead of text.
  • Hand-editing a file and leaving a trailing comma. JSON does not allow it and Node will refuse the file. Validate after editing; deleting the comma is a thirty-second job.
  • Writing with writeFileSync inside a request handler. It blocks every other request until the disk write completes. Use the promise API.

Frequently Asked Questions

What is the easiest way to read a JSON file in Node.js?
For a file that never changes while the program runs, require('./data.json') in CommonJS or import data from './data.json' with { type: 'json' } in ES modules parses it in one line. For a file that can change, read it with fs.readFileSync or fs.promises.readFile and call JSON.parse on the result.
Why does require() return stale JSON data?
Node caches every module, including JSON files, in require.cache the first time it is loaded. Later calls return the same object without touching the disk, so edits to the file are invisible until the process restarts. Read the file with fs when you need fresh data.
How do I write a JavaScript object to a JSON file in Node.js?
Serialize it with JSON.stringify(obj, null, 2) and pass the string to fs.writeFileSync or fs.promises.writeFile. The third argument sets the indent; omit it to write a compact single-line file.
How do I handle a missing JSON file in Node.js?
Catch the error and check err.code === 'ENOENT', which is the code Node uses for a path that does not exist. Treat that case as 'use defaults' if that is sensible for your app, and rethrow everything else, since a permission error or a syntax error should not be silently ignored.
Can Node.js read a JSON file that is larger than memory?
Not with JSON.parse, which needs the whole text as one string and is limited by the V8 string length cap. For very large files, use a streaming parser such as the stream-json package, or store the data as JSON Lines so each line can be parsed independently.

Check a JSON File Before Node Loads It

Upload a file up to 5 MB or paste its contents, validate it, and get the exact line of any error. Nothing leaves your browser.

Validate My JSON File