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.
| Property | Type | Meaning |
|---|---|---|
size | number | File size in bytes |
mtime / mtimeMs | Date / number | Last time the file contents changed |
atime / atimeMs | Date / number | Last time the file was accessed (read) |
ctime / ctimeMs | Date / number | Last time the inode (metadata) changed |
birthtime / birthtimeMs | Date / number | Creation time (not reliable on every filesystem) |
mode | number | Permission bits and type flags |
uid / gid | number | Owning user and group IDs |
The difference between
mtimeandctimetrips people up.mtimechanges when the file’s data is written;ctimechanges whenever the metadata changes too — including renames, permission changes, or ownership changes. Usemtimewhen 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
| Method | Returns 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
ENOENTerror if it is missing. Useaccessonly when existence is genuinely all you need to know.
Best Practices
- Default to
statfromnode:fs/promiseswithasync/awaitfor application code. - Use
lstatwhenever symbolic links matter, sincestatsilently follows them and reports the target. - Prefer the
*Mstimestamp variants (mtimeMs) for numeric comparisons instead of constructingDateobjects. - Reach for
fstat(via aFileHandle) when you already have a file open, to avoid an extra path resolution and a race. - Do not use
statmerely to test existence; useaccess, or better, just attempt the operation and catchENOENT. - Treat
birthtimeas 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.