Skip to content
Node.js nd fs 5 min read

File Stats & Metadata

Before you read, copy, or serve a file, you often need to know something about it first: how big is it, when was it last modified, and is it even a regular file rather than a directory or a symbolic link? Node.js answers these questions with the stat family of functions, each of which resolves to a Stats object packed with metadata. This page covers stat, lstat, and fstat, the most useful fields and type-check methods on the Stats object, and the right way to test for existence with fs.access.

Reading metadata with fs.stat

The modern entry point is stat from node:fs/promises. Given a path, it resolves to a Stats object describing the file. The most frequently used fields are the size in bytes and a set of timestamps. Crucially, stat follows symbolic links — it reports on the target of a link, not the link itself.

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

const info = await stat("report.pdf");

console.log(`Size:     ${info.size} bytes`);
console.log(`Modified: ${info.mtime.toISOString()}`);
console.log(`Created:  ${info.birthtime.toISOString()}`);
console.log(`Is file:  ${info.isFile()}`);

Output:

Size:     204813 bytes
Modified: 2026-06-12T09:41:07.000Z
Created:  2026-06-01T14:22:55.000Z
Is file:  true

The Stats object

The Stats object exposes both raw numbers and convenience methods. Sizes are in bytes, and each timestamp comes in two forms: a Date object (mtime, atime, ctime, birthtime) and a numeric milliseconds-since-epoch variant (mtimeMs, atimeMs, and so on) that is cheaper to compare.

PropertyTypeMeaning
sizenumberFile size in bytes
mtime / mtimeMsDate / numberLast time the file contents changed
atime / atimeMsDate / numberLast time the file was accessed (read)
ctime / ctimeMsDate / numberLast time the inode (metadata) changed
birthtime / birthtimeMsDate / numberCreation time (not reliable on every filesystem)
modenumberPermission bits and type flags
uid / gidnumberOwning user and group IDs

The difference between mtime and ctime trips people up. mtime changes when the file’s data is written; ctime changes whenever the metadata changes too — including renames, permission changes, or ownership changes. Use mtime when you mean “was the content edited?”

Checking the file type

A Stats object answers “what kind of filesystem entry is this?” through a set of boolean methods. The two you will use most are isFile() and isDirectory(), but the full set covers links, sockets, and device files.

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

const entry = await stat("/usr/local/bin");

if (entry.isDirectory()) {
  console.log("It is a directory");
} else if (entry.isFile()) {
  console.log("It is a regular file");
}

console.log("Symlink?", entry.isSymbolicLink()); // always false via stat()

Output:

It is a directory
Symlink? false
MethodReturns true for
isFile()Regular files
isDirectory()Directories
isSymbolicLink()Symbolic links (only via lstat)
isBlockDevice()Block devices
isCharacterDevice()Character devices
isFIFO()Named pipes (FIFOs)
isSocket()Sockets

lstat versus stat

Note that stat always follows symbolic links, so isSymbolicLink() on its result is always false. To inspect the link itself — for example to detect a broken link, or to avoid following it — use lstat. It is identical to stat except that when the path is a symlink, it reports on the link rather than its target.

import { stat, lstat } from "node:fs/promises";

// Suppose "shortcut" is a symlink pointing at "real.txt"
const followed = await stat("shortcut");   // describes real.txt
const link     = await lstat("shortcut");  // describes the link itself

console.log("stat  isSymbolicLink:", followed.isSymbolicLink());
console.log("lstat isSymbolicLink:", link.isSymbolicLink());

Output:

stat  isSymbolicLink: false
lstat isSymbolicLink: true

There is also fstat, which takes an open FileHandle instead of a path. It is the right choice when you already hold a handle and want metadata without a second path lookup (which avoids a race where the path changes between operations).

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

const handle = await open("data.bin", "r");
try {
  const info = await handle.stat();
  console.log(`Open file is ${info.size} bytes`);
} finally {
  await handle.close();
}

Output:

Open file is 1048576 bytes

Checking existence with fs.access

A common but flawed pattern is to call stat just to learn whether a file exists. The purpose-built tool is access, which checks visibility and permissions and rejects with an error if the file cannot be reached. Pass a constant from node:fs to test specific permissions.

import { access, constants } from "node:fs/promises";

async function canRead(path) {
  try {
    await access(path, constants.R_OK);
    return true;
  } catch {
    return false;
  }
}

console.log(await canRead("config.json"));
console.log(await canRead("does-not-exist.json"));

Output:

true
false

Avoid checking existence and then opening the file — the state can change in between (a TOCTOU race). Prefer to just open or read the file and handle the ENOENT error if it is missing. Use access only when existence is genuinely all you need to know.

Best Practices

  • Default to stat from node:fs/promises with async/await for application code.
  • Use lstat whenever symbolic links matter, since stat silently follows them and reports the target.
  • Prefer the *Ms timestamp variants (mtimeMs) for numeric comparisons instead of constructing Date objects.
  • Reach for fstat (via a FileHandle) when you already have a file open, to avoid an extra path resolution and a race.
  • Do not use stat merely to test existence; use access, or better, just attempt the operation and catch ENOENT.
  • Treat birthtime as best-effort — some filesystems do not record creation time and fall back to other timestamps.
  • Always check err.code (ENOENT, EACCES) rather than parsing error messages when a stat call fails.
Last updated June 14, 2026
Was this helpful?