File Stats and Permissions
Every file and directory on disk carries metadata alongside its contents: how big it is, when it was last modified, whether it’s a file or a directory, and who is allowed to read, write, or execute it. Node’s fs module exposes all of this through fs.stat(), fs.access(), and fs.chmod(). Knowing how to read and change this metadata is essential for build tools, upload handlers, deployment scripts, and anything that needs to make a decision based on what’s actually on disk before touching it.
Overview / How it works
When you call fs.stat(path), Node asks the operating system for the file’s inode metadata — not the file’s contents. On Linux and macOS this maps almost directly to the underlying stat() system call; libuv runs it on its internal thread pool (the same pool used for most other file operations) so it doesn’t block the event loop, and the result is delivered back as a resolved promise (or callback) once the OS responds. The result is a fs.Stats object with properties like size, mtime (modified time), atime (last accessed time), birthtime (creation time, when the filesystem supports it), and a numeric mode field that encodes both the file type and its permission bits.
Permissions on Unix-like systems (Linux, macOS) are a 9-bit value split into three groups of three: owner, group, and others, each with read (4), write (2), and execute (1) bits. That’s why you see permissions written as octal numbers like 644 (owner read/write, everyone else read-only) or 755 (owner full access, everyone else read/execute). Windows does not have this same permission model — it uses ACLs instead — so Node approximates the Unix bits on Windows, and fs.chmod() has very limited effect there beyond toggling the read-only attribute.
There are three related “stat” functions you’ll encounter:
fs.stat(path)— follows symbolic links and returns stats for the target they point to.fs.lstat(path)— does not follow symlinks; it returns stats about the link itself, which is the only way to detect that something is a symlink.fs.fstat(fd)— takes an already-open file descriptor instead of a path, useful when you already have a file open and want to avoid a second filesystem lookup.
Each has a sync version (fs.statSync) and a promise version (fs.promises.stat / require("node:fs/promises")). Prefer the promise version in almost all real code — the sync version blocks the entire event loop until the OS responds, which is fine for a one-off CLI script but wrong inside a request handler.
Syntax
const stats = await fs.stat(path);
const stats = await fs.lstat(path);
await fs.access(path, mode);
await fs.chmod(path, mode);
| Part | Meaning |
|---|---|
path |
A string, Buffer, or URL pointing at the file or directory. |
stats |
An fs.Stats object with size, timestamps, mode, and type-check methods. |
mode (access) |
One of fs.constants.F_OK, R_OK, W_OK, X_OK, or a bitwise OR of several. |
mode (chmod) |
An octal number like 0o644 describing the new permission bits. |
Key Stats properties and methods
| Member | Description |
|---|---|
size |
File size in bytes. |
mode |
Numeric type + permission bits; mask with & 0o777 to get just the permission part. |
mtime |
Date the content was last modified. |
atime |
Date the file was last read. |
birthtime |
Date the file was created (filesystem-dependent). |
isFile() |
True if it’s a regular file. |
isDirectory() |
True if it’s a directory. |
isSymbolicLink() |
True if it’s a symlink (only meaningful after lstat). |
Examples
Example 1: Reading basic file stats
const fs = require("node:fs/promises");
async function main() {
try {
const stats = await fs.stat("app.js");
console.log("Size (bytes):", stats.size);
console.log("Is file:", stats.isFile());
console.log("Is directory:", stats.isDirectory());
console.log("Last modified:", stats.mtime);
console.log("Created:", stats.birthtime);
console.log("Permissions (octal):", (stats.mode & 0o777).toString(8));
} catch (err) {
if (err.code === "ENOENT") {
console.error("File not found: app.js");
} else {
console.error("Failed to read stats:", err.message);
}
}
}
main();
Size (bytes): 512
Is file: true
Is directory: false
Last modified: 2026-07-30T10:15:23.000Z
Created: 2026-07-20T09:00:00.000Z
Permissions (octal): 644
The mode field packs both the file type and the permission bits into one number, so masking it with & 0o777 strips the type bits and leaves just the permission octal. Wrapping the call in try/catch matters — a missing file throws an error with code === "ENOENT" rather than returning null or undefined.
Example 2: Checking read/write access before using a file
const fs = require("node:fs/promises");
const { constants } = require("node:fs");
async function checkAccess(path) {
try {
await fs.access(path, constants.F_OK);
console.log(`${path} exists`);
} catch {
console.log(`${path} does not exist`);
return;
}
try {
await fs.access(path, constants.R_OK);
console.log(`${path} is readable`);
} catch {
console.log(`${path} is NOT readable`);
}
try {
await fs.access(path, constants.W_OK);
console.log(`${path} is writable`);
} catch {
console.log(`${path} is NOT writable`);
}
}
checkAccess("config.json");
config.json exists
config.json is readable
config.json is writable
fs.access() doesn’t return a boolean — it resolves if the check passes and rejects if it doesn’t, so every check needs its own try/catch. constants.F_OK just checks existence; R_OK, W_OK, and X_OK check read, write, and execute permission respectively, and can be combined with a bitwise OR, e.g. constants.R_OK | constants.W_OK.
Example 3: Listing a directory with type and permission info, then fixing permissions
const fs = require("node:fs/promises");
const path = require("node:path");
async function listWithStats(dirPath) {
const entries = await fs.readdir(dirPath);
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
const stats = await fs.lstat(fullPath);
let type = "file";
if (stats.isDirectory()) type = "directory";
else if (stats.isSymbolicLink()) type = "symlink";
console.log(
`${entry.padEnd(20)} ${type.padEnd(10)} ${stats.size} bytes mode=${(stats.mode & 0o777).toString(8)}`
);
}
}
async function main() {
await listWithStats(".");
await fs.chmod("deploy.sh", 0o755);
console.log("deploy.sh is now executable (rwxr-xr-x)");
}
main().catch((err) => {
console.error("Error:", err.message);
});
app.js file 512 bytes mode=644
node_modules directory 4096 bytes mode=755
deploy.sh file 220 bytes mode=644
deploy.sh is now executable (rwxr-xr-x)
This walks a directory, uses lstat (not stat) so symlinks are reported as symlinks instead of silently resolved, and then uses fs.chmod() to grant the owner execute permission on a shell script — a common step in deployment scripts that generate or download an executable.
How it works step by step
- You call
fs.stat(path)(oraccess/chmod); Node hands the request to libuv. - libuv dispatches the actual OS system call to a worker thread in its thread pool (default size 4), because these calls are blocking at the OS level and must not run on the main thread.
- Your JavaScript continues running — the
awaitsuspends only the current async function, not the whole program. - When the worker thread gets a result from the OS, libuv queues a completion callback back onto the event loop’s poll phase.
- The event loop picks up that callback, which resolves your promise (or invokes your callback), and your
awaitresumes with theStatsobject or the access/chmod result. - If the OS call failed (file missing, permission denied), the promise rejects with an
Errorcarrying acodelikeENOENTorEACCESthat you should branch on.
Common Mistakes
Mistake 1: Using stat instead of lstat on symlinks
const stats = await fs.stat("shortcut-to-file");
console.log(stats.isSymbolicLink()); // always false, even if it IS a symlink
fs.stat() follows the symlink and returns stats for whatever it points to, so isSymbolicLink() can never be true on its result. To detect a symlink, or to inspect the link itself, use fs.lstat():
const stats = await fs.lstat("shortcut-to-file");
console.log(stats.isSymbolicLink()); // true, if it really is a symlink
Mistake 2: Calling the sync stat functions inside a request handler
http.createServer((req, res) => {
const stats = fs.statSync("./big-report.csv"); // blocks the ENTIRE event loop
res.end(String(stats.size));
});
fs.statSync() blocks the main thread until the OS responds. In a CLI script that runs once at startup that’s harmless; inside an HTTP handler it stalls every other in-flight request. Use the promise version instead:
http.createServer(async (req, res) => {
try {
const stats = await fs.promises.stat("./big-report.csv");
res.end(String(stats.size));
} catch {
res.writeHead(404);
res.end("Not found");
}
});
Mistake 3: Treating a failed fs.access() as “file doesn’t exist”
try {
await fs.access("secret.txt", constants.W_OK);
} catch {
console.log("File does not exist"); // wrong! it might just not be writable
}
fs.access() rejects both when the path doesn’t exist and when the requested permission is denied — those are different problems. Inspect err.code to tell them apart (ENOENT vs EACCES) instead of assuming one cause.
Best Practices
- Prefer
fs/promiseswithasync/awaitover the sync API in servers and long-running processes; reservestatSyncfor short CLI scripts. - Use
lstat()whenever a path might be a symlink and you care about that fact, or when you want to avoid accidentally following a link into an unexpected location. - Always branch on
err.code(ENOENT,EACCES,EPERM) rather than the error message string, since messages can vary by platform. - Mask
stats.modewith& 0o777before formatting it as octal, or the leading file-type bits will corrupt the reading. - Remember that a successful
fs.access()check is not a guarantee — another process could change or delete the file before you actually open it (a time-of-check-to-time-of-use, or TOCTOU, race). For correctness, just attempt the operation (e.g.fs.open) and handle the error, rather than relying solely on a prioraccesscheck. - Don’t rely on precise Unix permission semantics on Windows —
chmodthere mostly only toggles the read-only attribute.
Practice Exercises
- Write a function
formatSize(path)that stats a file and returns its size formatted as"1.2 KB"or"3.4 MB"instead of a raw byte count. - Write a script that scans a directory and prints only the entries that are not writable by the owner (hint: use
fs.accesswithconstants.W_OKon each entry). - Write a script that takes a path from
process.argv, and prints whether it’s a file, directory, or symlink, along with its permission octal — handle the case where the path doesn’t exist gracefully.
Summary
fs.stat()returns aStatsobject with size, timestamps, type, and permission info; it follows symlinks.fs.lstat()is the only way to detect a symlink itself, since it does not follow the link.fs.access(path, mode)checks existence and permission usingfs.constants.F_OK/R_OK/W_OK/X_OK, rejecting the promise on failure rather than returning a boolean.fs.chmod(path, mode)changes Unix-style permission bits, expressed as an octal like0o755; its effect is limited on Windows.- Prefer the promise-based
fs/promisesAPI over sync calls in servers, since sync calls block the whole event loop. - An
access()check followed by a separate operation is inherently racy; prefer attempting the operation directly and handling the error.
