Console, readline & Terminal I/O

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. Almost every Node.js program talks to a terminal at some point: it prints results and diagnostics, it reads what the user types, or it reads data piped in from another program. Four modules cover that ground, and all four are Stability 2 (Stable): node:console (the global console), node:readline together with its promise flavour node:readline/promises, and node:tty. The streams behind them, process.stdin, process.stdout and process.stderr, are documented with the process module. This page explains how the pieces fit together and, above all, how a program should behave differently depending on whether a person or a pipe is on the other end. For the stream mechanics themselves see Streams; for exit codes and signals see Process, Signals & Exit Codes; the complete command-line tool that assembles these pieces is built in Building CLI Tools. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

console global and node:console (log, error, table, time, group, trace, count, assert, dir, Console)

2 Stable

inspectOptions can be a Map from stream to options since v24.10.0; groupIndentation since v14.2.0 and v12.17.0.

process.stdin, process.stdout, process.stderr

Stable (documented with node:process)

Types depend on what the descriptor is connected to: TTY, pipe or socket, or file.

node:readline (callback API, 'line' events, async iteration)

2 Stable

rl[Symbol.dispose]() since v23.10.0 and v22.15.0; rl.getPrompt() since v15.3.0 and v14.17.0.

node:readline/promises

2 Stable

Added in v17.0.0; the Promises API was marked stable in v24.0.0 and v22.17.0.

filehandle.readLines() (node:fs/promises)

Stable

Added in v18.11.0.

node:tty (isatty(), tty.ReadStream, tty.WriteStream)

2 Stable

readStream.setRawMode() accepts 'raw' and 'io' and readStream.rawMode exists since v26.8.0.

util.styleText()

Stable

Stable since v23.5.0 and v22.13.0; respects isTTY, NO_COLOR, NODE_DISABLE_COLORS and FORCE_COLOR since v22.8.0 and v20.18.0; hex colours since v26.1.0.

The console

The global console is a ready-made Console instance wired to process.stdout and process.stderr. It looks like the browser console and is deliberately compatible with it, but it is not the same thing: in Node.js its output goes to file descriptors, so the usual Unix tools (redirection, pipes, tee) apply. The rule worth remembering is which stream each method uses. console.log(), console.info() and console.debug() write to stdout; console.error(), console.warn(), console.trace() and the failure message of console.assert() write to stderr. (console.debug() is an alias of console.log() since v8.10.0, there is no hidden "debug level".) That split is what makes node app.js > result.txt keep the diagnostics on the screen.

Format specifiers in the first argument follow util.format(): %s (string), %d (number), %i (integer), %f (float), %j (JSON), %o and %O (object inspection), %c (CSS, ignored in Node.js) and %% (a literal percent sign). Arguments left over after the format string are appended, separated by spaces.

Basic output, format specifiers, depth, counters and assertions
console.log('Bookshelf %s has %d books (%i%% read)', 'main', 2, 50);
console.log('%j and %o', { a: 1 }, [1, 2]);
console.log('%c styled?', 'color: red');
console.info('info goes to stdout');
console.warn('warn goes to stderr');
console.error('error goes to stderr');
console.log('extra', 'args', { nested: { deep: { deeper: { deepest: 1 } } } });
console.dir({ nested: { deep: { deeper: { deepest: 1 } } } }, { depth: 0 });
console.count('request'); console.count('request'); console.countReset('request'); console.count('request');
console.assert(1 + 1 === 3, 'math is %s', 'broken');
Output (identical on Node.js 26, 24 and 22)
Bookshelf main has 2 books (50% read)
{"a":1} and [ 1, 2, [length]: 2 ]
 styled?
info goes to stdout
warn goes to stderr
error goes to stderr
extra args { nested: { deep: { deeper: [Object] } } }
{ nested: [Object] }
request: 1
request: 2
request: 1
Assertion failed: math is broken

Three details are visible in the output. %o shows hidden details such as [length], while %O and the default formatting do not. Objects nested deeper than two levels print as [Object] unless you ask for more with console.dir(obj, { depth: null }) (or util.inspect() options). And console.assert() prints only when the condition is falsy, and it does not throw: unlike the assert module it never stops the program.

Tables and groups

console.table(data[, properties]) renders an array of objects (or an object, a Map or a Set) as a box-drawn table, and the optional second argument lists the columns to keep. console.group() increases the indentation of everything printed afterwards, by two spaces per level, until the matching console.groupEnd(); console.groupCollapsed() is an alias of console.group() in Node.js.

console.table() and console.group()
import { readFile } from 'node:fs/promises';

const books = JSON.parse(await readFile(new URL('./books.json', import.meta.url), 'utf8'));
console.table(books);
console.table(books, ['title', 'year']);
console.table(new Map([['a', 1], ['b', 2]]));

console.group('Import');
console.log('reading');
console.group('Validate');
console.warn('2 warnings');
console.groupEnd();
console.groupEnd();
console.log('done');
Output (identical on Node.js 26, 24 and 22; the first table has five columns plus the index)
┌─────────┬────┬─────────────────────┬──────────┬──────┬────────────────────┐
│ (index) │ id │ title               │ author   │ year │ tags               │
├─────────┼────┼─────────────────────┼──────────┼──────┼────────────────────┤
│ 0       │ 1  │ 'Efficient Node.js' │ 'Buna'   │ 2025 │ [ 'node', 'perf' ] │
│ 1       │ 2  │ 'Node.js Projects'  │ 'Wexler' │ 2025 │ [ 'node' ]         │
└─────────┴────┴─────────────────────┴──────────┴──────┴────────────────────┘
┌─────────┬─────────────────────┬──────┐
│ (index) │ title               │ year │
├─────────┼─────────────────────┼──────┤
│ 0       │ 'Efficient Node.js' │ 2025 │
│ 1       │ 'Node.js Projects'  │ 2025 │
└─────────┴─────────────────────┴──────┘
┌───────────────────┬─────┬────────┐
│ (iteration index) │ Key │ Values │
├───────────────────┼─────┼────────┤
│ 0                 │ 'a' │ 1      │
│ 1                 │ 'b' │ 2      │
└───────────────────┴─────┴────────┘
Import
  reading
  Validate
    2 warnings
done

Grouping applies to stderr output too (the 2 warnings line above went to stderr, indented all the same). Tables are for humans: they are wide, they truncate nothing and they are written to stdout, so never use them in a program whose output another program parses.

Timers and stack traces

console.time(label) starts a named timer, console.timeLog(label, …​data) prints the elapsed time (plus any extra values) without stopping it, and console.timeEnd(label) prints the elapsed time and stops it. The unit is chosen automatically (ms, s, …​) since v13.0.0, so a 3869 ms measurement prints as 3.869s. These timers are for quick, human-read measurements; for numbers you want to store or compare use performance.now() or the node:perf_hooks module described in The Event Loop. console.trace(message, …​args) prints Trace: followed by the formatted message and the current call stack to stderr, which answers "how did the program get here?" without a debugger.

console.time(), console.timeLog() and console.trace()
import { setTimeout as sleep } from 'node:timers/promises';

console.time('load');
await sleep(120);
console.timeLog('load', 'half way', { rows: 10 });
await sleep(120);
console.timeEnd('load');

function findBook() { console.trace('lookup called with %s', 42); }
function handler() { findBook(); }
handler();
Output on Node.js 26 (timings vary; paths shortened)
load: 121.236ms half way { rows: 10 }
load: 245.927ms
Trace: lookup called with 42
    at findBook (file:///.../e3.mjs:7:31)
    at handler (file:///.../e3.mjs:8:22)
    at file:///.../e3.mjs:9:1

Calling console.timeEnd() with a label that is not running prints a warning instead of a duration, so keep labels unique per operation when several overlap.

A custom Console

console.Console (also require('node:console').Console, or import { Console } from 'node:console') creates an independent console that writes to any pair of writable streams. The options object accepts stdout, stderr, ignoreErrors (default true: errors while writing are swallowed), colorMode (true, false or 'auto'), inspectOptions and groupIndentation (default 2). It is the simplest way to send a subsystem’s output to a file, to a test buffer or to a second terminal without touching the global console. The inspectOptions option can also be a Map from stream to options since v24.10.0, so that each stream gets its own util.inspect() settings.

A Console that writes to a file
import { createWriteStream } from 'node:fs';
import { readFile, rm } from 'node:fs/promises';
import { Console } from 'node:console';
import { once } from 'node:events';

const out = createWriteStream('./app.log', { flags: 'a' });
await once(out, 'open');
const logger = new Console({ stdout: out, stderr: out, groupIndentation: 4, inspectOptions: { depth: 0 } });
logger.log('to file', { a: { b: 1 } });
logger.group('g');
logger.error('indented by four');
logger.groupEnd();
out.end();
await once(out, 'close');

console.log(await readFile('./app.log', 'utf8'));
await rm('./app.log');
Output (identical on Node.js 26, 24 and 22)
to file { a: [Object] }
g
    indented by four

A custom Console is a logging convenience, not a logging framework: there are no levels, no timestamps and no structured output. For production logging see Diagnostic Reports, Logging & Observability.

Is console.log() synchronous?

It depends on where the output goes, and the Node.js documentation says so explicitly: the global console is neither consistently synchronous like the browser’s nor consistently asynchronous like other Node.js streams. The behaviour is that of process.stdout:

Table 2. Whether writes to process.stdout and process.stderr are synchronous
Connected to POSIX (Linux, macOS) Windows

A file

Synchronous

Synchronous

A terminal (TTY)

Synchronous

Asynchronous

A pipe or socket

Asynchronous

Synchronous

Synchronous writes avoid interleaved or lost output when the process calls process.exit(), but they block the event loop until the write completes. Logging heavily to a pipe nobody reads, or to a slow terminal, can therefore stall a server; and on POSIX, a process.exit() right after a large write to a pipe can truncate the output. Prefer process.exitCode and letting the loop drain (see Process, Signals & Exit Codes).

stdin, stdout and stderr as streams

process.stdin is a readable stream on file descriptor 0, process.stdout a writable stream on descriptor 1 and process.stderr a writable stream on descriptor 2. They are real Node.js streams, so everything in Streams applies: pipeline(), async iteration, backpressure through the return value of write(), 'end' and 'error' events. Their concrete class depends on what the descriptor is connected to, which is the first thing to know when something behaves differently in a terminal and in a script.

Table 3. The class of the standard streams depends on what they are connected to (observed on Node.js 26)
Connected to process.stdin process.stdout

A terminal

tty.ReadStream

tty.WriteStream

A pipe

net.Socket

net.Socket

A file (< file, > file)

fs.ReadStream

SyncWriteStream (internal)

The documentation describes process.stdin and process.stdout as a net.Socket (a Duplex stream) unless the descriptor refers to a file. Two practical consequences follow. First, process.stdout.isTTY is true only for a terminal, and undefined otherwise, so it is the portable "is a person watching?" test (the tty section below builds on it). Second, a pipe is a byte stream with no message boundaries: a chunk read from process.stdin is not a line and may end in the middle of a multi-byte character, so set an encoding with setEncoding() or decode with a TextDecoder, or use readline (next section) to get lines.

Piping stdin through a Transform to stdout with pipeline()
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';

const upper = new Transform({
  transform(chunk, _encoding, callback) {
    callback(null, chunk.toString().toUpperCase());
  },
});

console.error('stdin is a TTY:', process.stdin.isTTY ?? false);
await pipeline(process.stdin, upper, process.stdout);
Running it with piped input
printf 'hello\nworld\n' | node upper.mjs
Output (identical on Node.js 26, 24 and 22)
stdin is a TTY: false
HELLO
WORLD

The diagnostic went to stderr, the data went to stdout: the convention that makes node upper.mjs < in.txt > out.txt work. The chunk.toString() call in the example is acceptable only because the transform is illustrative; a multi-byte character split across two chunks would be corrupted, which is exactly the problem StringDecoder and setEncoding() exist to solve (see Buffers & Binary Data).

Reading all of stdin is a one-liner with async iteration, and process.stdout.write() is the lower-level counterpart of console.log() that adds no newline and returns a boolean: false means the internal buffer is above writableHighWaterMark (65536 bytes by default here) and the program should wait for 'drain' before writing more.

Counting characters from stdin and writing without console.log()
import { stdin, stdout } from 'node:process';

let total = 0;
stdin.setEncoding('utf8');
for await (const chunk of stdin) total += chunk.length;

const ok = stdout.write(`read ${total} characters\n`);
console.error('write() returned', ok);
Running it
printf 'abcdef' | node count.mjs
Output
write() returned true
read 6 characters

(The two lines come from different streams, so their order on a screen is not guaranteed when both are piped.)

When stdin is a terminal, for await (const chunk of stdin) waits until the user types Ctrl+D (end of input on POSIX); that is why tools that accept both interactive and piped input check isTTY first and print a hint such as "reading from stdin, press Ctrl+D to finish". The old "paused" mode described in the documentation (process.stdin.resume()) still exists for compatibility with scripts written before Node.js v0.10; new code should use async iteration, pipeline() or readline.

readline and readline/promises

node:readline turns a readable stream into a sequence of lines and, when the input is a terminal, adds line editing, history and tab completion. readline.createInterface(options) returns an Interface; the same function exists in node:readline/promises, and the only difference is that rl.question() returns a promise there. The options that matter in practice:

Table 4. Common createInterface() options
Option Meaning

input (required), output

The readable stream to listen to and the writable stream to write prompts to. If output is null or undefined, question() does not print the query.

prompt

The string printed by rl.prompt(). Default '> '.

terminal

Treat the streams as a TTY and use ANSI escape codes. Default: output.isTTY at creation time.

history, historySize, removeHistoryDuplicates

The line-history list. Default size 30; it only exists when terminal is true.

crlfDelay

Maximum delay between \r and \n for them to count as one line ending. Default 100 ms, never less than 100; Infinity treats \r followed by \n as a single newline, which is what you want when reading files.

completer

A function used for Tab completion.

signal

An AbortSignal that closes the interface when aborted.

Asking questions

await rl.question(query) prints the query, waits for the user to press Enter and resolves with the line (without the line ending). Always close the interface when you are done: an open readline keeps stdin open, and with it the process alive. rl[Symbol.dispose]() (an alias of rl.close(), since v23.10.0 and v22.15.0) lets using rl = createInterface(…​) close it at the end of the block; observed: that works on Node.js 26 and 24, while the using syntax itself is a SyntaxError on Node.js 22, where try/finally is still needed.

Prompting for a title and a year
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });
try {
  const title = await rl.question('Title? ');
  const year = Number(await rl.question('Year? '));
  console.log({ title, year, valid: Number.isInteger(year) });
} finally {
  rl.close();
}

Run in a terminal, this prints the two prompts, waits for two answers and prints the object. Run with piped input, however, it shows a well-known pitfall, observed identically on Node.js 26, 24 and 22:

Piped input with sequential question() calls (observed)
printf 'Dune\n1965\n' | node ask.mjs
Title? Year? Warning: Detected unsettled top-level await at file:///.../ask.mjs:7
  const year = Number(await rl.question('Year? '));

The first question() consumed the first line; the second line was already in the buffer and was emitted as a 'line' event while nobody was listening, so it was dropped; then the input ended and the second question() never resolved. Interactive typing hides this because the user is slower than the program. The reliable pattern for programs that must accept both is one async iterator for the whole session, which buffers lines that arrive early.

A prompt function that works interactively and with piped input
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });
const lines = rl[Symbol.asyncIterator]();

async function ask(query) {
  output.write(query);
  const { value, done } = await lines.next();
  if (done) throw new Error('input ended before the answer to: ' + query);
  return value;
}

const title = await ask('Title? ');
const year = Number(await ask('Year? '));
console.log({ title, year });
rl.close();
Output with printf 'Dune\n1965\n' | node ask.mjs (identical on Node.js 26, 24 and 22)
Title? Year? { title: 'Dune', year: 1965 }

Timeouts and cancellation

rl.question(query, { signal }) accepts an AbortSignal. When it aborts, the promise rejects with an AbortError (err.code is 'ABORT_ERR'), so a prompt can have a deadline with AbortSignal.timeout(). Asking a question after rl.close() returns a rejected promise as well.

A question with a deadline
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });
const signal = AbortSignal.timeout(300);
try {
  const answer = await rl.question('Quick, a title? ', { signal });
  console.log('got', answer);
} catch (err) {
  console.log(`\n${err.name}: ${err.code}`);
} finally {
  rl.close();
}
Output when nobody answers within 300 ms (identical on Node.js 26, 24 and 22)
Quick, a title?

AbortError: ABORT_ERR

See Async Context & Cancellation for how AbortSignal is used across the platform.

Event-driven prompts: prompt() and the 'line' event

For a REPL-like tool the event style is the natural fit: rl.setPrompt() or the prompt option sets the prompt, rl.prompt() prints it, each submitted line arrives as a 'line' event, and 'close' fires on Ctrl+D, Ctrl+C or rl.close(). The 'SIGINT' event lets a program intercept Ctrl+C on the interface instead of exiting.

A tiny menu-driven prompt for the Bookshelf
import { createInterface } from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';

const shelf = ['Dune'];
const rl = createInterface({ input, output, prompt: 'bookshelf> ', historySize: 50 });

rl.prompt();
rl.on('line', (line) => {
  const [command, ...rest] = line.trim().split(/\s+/);
  switch (command) {
    case 'add': shelf.push(rest.join(' ')); console.log(`added, ${shelf.length} books`); break;
    case 'list': console.log(shelf.join('\n')); break;
    case 'quit': rl.close(); return;
    case '': break;
    default: console.log(`unknown command: ${command}`);
  }
  rl.prompt();
}).on('close', () => {
  console.log('bye');
});
Output with printf 'add Emma\nlist\nfoo\nquit\n' | node menu.mjs (identical on Node.js 26, 24 and 22)
bookshelf> added, 2 books
bookshelf> Dune
Emma
bookshelf> unknown command: foo
bookshelf> bye

Reading a file line by line

Reading a large text file one line at a time keeps memory flat, because only a buffer’s worth of data is held. Combine fs.createReadStream() with readline and iterate with for await…​of; set crlfDelay: Infinity so that Windows line endings (\r\n) count as one break. Since v18.11.0 there is a shortcut: filehandle.readLines() from node:fs/promises creates the readline interface for you. Both give the same lines, including empty ones and a final line with no trailing newline.

Line by line with createReadStream() and readline
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: createReadStream(new URL('./lines.txt', import.meta.url)),
  crlfDelay: Infinity,
});

let n = 0;
for await (const line of rl) {
  n += 1;
  console.log(String(n).padStart(2), JSON.stringify(line));
}
The same with filehandle.readLines()
import { open } from 'node:fs/promises';

const file = await open(new URL('./lines.txt', import.meta.url));
for await (const line of file.readLines()) {
  console.log(JSON.stringify(line));
}
Output for a file containing four lines, the first two ending in CRLF, the third empty, the last without a newline (identical on Node.js 26, 24 and 22)
 1 "first line"
 2 "second line"
 3 ""
 4 "last without newline"

Two caveats from the documentation are worth testing yourself. readline.createInterface() starts consuming its input immediately, so any asynchronous work between creating the interface and starting the for await loop can lose lines. Observed on Node.js 26, 24 and 22: a await sleep(100) between creating the interface over a file stream and the loop ended with the warning "Detected unsettled top-level await", because the stream had already finished and the loop never saw the lines. Create the interface and iterate in the same synchronous stretch of code. Second, iterating with for await is slower than the 'line' event, and the documentation recommends the event for performance-sensitive code; break, throw or return inside the loop closes the interface and therefore the input. For CSV, JSON Lines or logs where lines are further transformed, a stream pipeline (Streams) is usually a better fit than a hand-written loop; for the file APIs themselves see File System.

Masked input for secrets

Node.js has no built-in "password prompt". readline writes what the user types back to output so that line editing works, and the standard technique is therefore to give it a custom writable as output that forwards everything to the real stdout, except while a secret is being typed. The key points of the example below: the prompt is printed first, muted is switched on right after question() has been called, terminal: true forces the line-editing mode even though output is not a TTY, and the interface is always closed, which also restores the terminal.

Reading a passphrase without echoing it
import { createInterface } from 'node:readline/promises';
import { Writable } from 'node:stream';
import { stdin, stdout } from 'node:process';

async function askSecret(query) {
  let muted = false;
  const output = new Writable({
    write(chunk, encoding, callback) {
      if (!muted) stdout.write(chunk, encoding);   // echo only while not muted
      callback();
    },
  });
  const rl = createInterface({ input: stdin, output, terminal: true });
  try {
    const pending = rl.question(query);
    muted = true;                                  // the prompt is already printed
    return await pending;
  } finally {
    rl.close();
    stdout.write('\n');
  }
}

const secret = await askSecret('Passphrase: ');
console.log('length:', secret.length);
Typing hunter2 in a pseudo-terminal: only the prompt is echoed (identical on Node.js 26, 24 and 22)
Passphrase:
length: 7

Three security notes follow, in prose because they are about behaviour rather than API. Never print or log the secret itself, as the example prints only its length. A string cannot be wiped from memory, so keep it as briefly as possible and pass it directly to the function that uses it (a key-derivation or authentication call, see Crypto, Passwords & Secrets); and do not accept secrets as command-line arguments, which show up in ps output and shell history. The line-history list also records what was typed: with terminal: true the interface keeps history, and the 'history' event gives a program access to it, so disable it for secret prompts with historySize: 0 if the same interface is reused for other input. Finally, the technique requires a real terminal on stdin; with piped input there is nothing to hide, and a tool that supports automation should read secrets from an environment variable or a file loaded through --env-file (see CLI Options, Environment & Config File).

Single keys and raw mode

readline works on lines. For menus, "press any key" prompts and games you need single key presses: readline.emitKeypressEvents(stream) makes a readable stream emit 'keypress' events, and stdin.setRawMode(true) switches the terminal to raw mode, in which input is available character by character with no echo and no line buffering. In raw mode Ctrl+C no longer raises SIGINT: it arrives as a key (key.ctrl and key.name === 'c'), so the program must handle it, and it must restore the terminal with setRawMode(false) before exiting. setRawMode() exists only on tty.ReadStream, so check stdin.isTTY first.

Single key presses in raw mode
import { emitKeypressEvents } from 'node:readline';
import { stdin, stdout } from 'node:process';

if (!stdin.isTTY) {
  console.error('needs an interactive terminal');
  process.exit(1);
}
emitKeypressEvents(stdin);
stdin.setRawMode(true);          // no echo, no line buffering, Ctrl+C is delivered as a key
stdout.write('press a key (q or Ctrl+C quits)\n');
stdin.on('keypress', (text, key) => {
  if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
    stdin.setRawMode(false);     // always restore the terminal
    stdin.pause();
    return;
  }
  console.log(`name=${key.name} sequence=${JSON.stringify(key.sequence)}`);
});
Output after pressing a, the up arrow and q (identical on Node.js 26, 24 and 22)
press a key (q or Ctrl+C quits)
name=a sequence="a"
name=up sequence="\u001b[A"

Since v26.8.0 setRawMode() also accepts the strings 'raw' and 'io' (binary-safe input with terminal output processing disabled; not supported on Windows), and the new readStream.rawMode property reports the current mode; setRawMode(true) and setRawMode(false) behave as before on every line. Run without a terminal, the program above prints the message and exits with code 1.

The tty module: is a person watching?

node:tty exposes two classes, tty.ReadStream and tty.WriteStream, which Node.js creates by itself for the standard streams when they are connected to a terminal; the documentation says there is little reason to construct them manually. What you use daily is the detection: process.stdout.isTTY (a boolean, true for terminals and undefined otherwise), or tty.isatty(fd), which takes a file descriptor and returns false for anything that is not a terminal, including negative or non-integer numbers.

The decision to colour, animate or ask questions should always follow from that check, for each stream separately: stdout redirected to a file while stderr stays on the screen is a very common setup.

Detecting terminals and reading their properties
import { isatty } from 'node:tty';
import { stdout, env } from 'node:process';

console.log({
  stdin: isatty(0), stdout: isatty(1), stderr: isatty(2),
  columns: stdout.columns ?? null, rows: stdout.rows ?? null,
  colorDepth: stdout.isTTY ? stdout.getColorDepth() : null,
  hasColors256: stdout.isTTY ? stdout.hasColors(256) : false,
  NO_COLOR: env.NO_COLOR ?? null, FORCE_COLOR: env.FORCE_COLOR ?? null,
  notAFd: isatty(-1),
});
Output through a pipe (identical on Node.js 26, 24 and 22)
{
  stdin: false, stdout: false, stderr: false,
  columns: null, rows: null, colorDepth: null, hasColors256: false,
  NO_COLOR: null, FORCE_COLOR: null, notAFd: false
}
Output in a 100-column, 30-row pseudo-terminal with TERM=xterm-256color (identical on Node.js 26, 24 and 22)
{
  stdin: true, stdout: true, stderr: true,
  columns: 100, rows: 30,
  colorDepth: 8, hasColors256: true,
  NO_COLOR: null, FORCE_COLOR: null, notAFd: false
}

(The real output is one property per line; it is condensed here.) The properties of a tty.WriteStream:

Table 5. tty.WriteStream members
Member Meaning

columns, rows, getWindowSize()

The terminal size; kept up to date and announced by the 'resize' event. Observed: they are undefined when stdout is not a TTY, and a pseudo-terminal that was never given a size reports 0 columns, so a program should fall back to a default width such as 80.

getColorDepth([env])

1 (2 colours), 4 (16), 8 (256) or 24 (16,777,216 colours). It can give false positives and negatives, because it relies on environment variables such as TERM and COLORTERM.

hasColors([count][, env])

true if at least count colours (default 16) are supported.

cursorTo(x[, y]), moveCursor(dx, dy), clearLine(dir), clearScreenDown()

Cursor movement and erasing. Each returns false when the stream wants a 'drain' before more output. The node:readline module has equivalent functions that also accept any writable stream.

Colours and NO_COLOR

Do not hardcode ANSI escape sequences. util.styleText(format, text, options) produces them and, by default, checks that process.stdout supports colours: its validateStream option (default true) and its stream option (default process.stdout) make it return the plain text when the stream is not a terminal, when NO_COLOR or NODE_DISABLE_COLORS is set, and force colours when FORCE_COLOR is set (since v22.8.0 and v20.18.0). Pass { stream: process.stderr } when the text is going to stderr, as stdout and stderr can differ. FORCE_COLOR accepts 0 (no colours), 1 (16 colours), 2 (256) and 3 (16 million); it is also what getColorDepth() honours. This is also the supported replacement for third-party packages such as chalk, kleur or ansi-colors; the Node.js project publishes codemods for that migration.

styleText() adapts to the output stream
import { styleText } from 'node:util';
import { stdout } from 'node:process';

console.log(styleText('green', 'ok'), styleText(['bold', 'red'], 'failed'));
console.log(JSON.stringify(styleText('green', 'ok')));
console.log(JSON.stringify(styleText('green', 'ok', { stream: stdout })));
console.log(JSON.stringify(styleText('green', 'ok', { validateStream: false })));
Output piped to a file (identical on Node.js 26, 24 and 22)
ok failed
"ok"
"ok"
"\u001b[32mok\u001b[39m"

In a terminal with TERM=xterm-256color the first line is written with escape codes, and so are the second and third lines (their JSON form is "\u001b[32mok\u001b[39m"); with NO_COLOR=1 the output is the same as when piped, except for the fourth line, where validation was switched off and the escape codes are always present.

The same variables make a colour policy for your own programs: honour NO_COLOR (any non-empty value disables colours, see https://no-color.org), use colour only on a TTY, and offer --color or --no-color flags that map to FORCE_COLOR. Hex colours ('#ff8800') in styleText() are available since v26.1.0 only; the 'none' format, a no-op that is convenient for conditional styling, since v24.2.0 and v22.17.0.

Columns and cursor control: a progress bar

A progress bar shows all of the above in use. It draws only on a terminal, sizes itself from stdout.columns, redraws in place with cursorTo(0) and clearLine(1) (erase to the right of the cursor), and falls back to a few plain lines when output goes to a file, where carriage returns and escape codes would only litter a log.

A progress bar that degrades to plain lines
import { stdout } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';

const total = 20;
function render(done) {
  const width = Math.max(10, Math.min(40, (stdout.columns ?? 80) - 12));
  const filled = Math.round((done / total) * width);
  return `[${'#'.repeat(filled)}${'.'.repeat(width - filled)}] ${String(Math.round((done / total) * 100)).padStart(3)}%`;
}
for (let i = 0; i <= total; i++) {
  if (stdout.isTTY) {
    stdout.cursorTo(0);        // return to column 0 and redraw in place
    stdout.write(render(i));
    stdout.clearLine(1);       // erase to the right of the cursor
  } else if (i % 5 === 0) {
    stdout.write(`progress ${i}/${total}\n`);   // plain lines for logs and pipes
  }
  await sleep(10);
}
if (stdout.isTTY) stdout.write('\n');
Output at the end of the run in a terminal, and when piped (identical on Node.js 26, 24 and 22)
[########################################] 100%

progress 0/20
progress 5/20
progress 10/20
progress 15/20
progress 20/20

The 'resize' event on process.stdout fires when the terminal size changes (the property stdout.columns is already updated when it does), so long-running full-screen programs recompute their layout in a handler for it.

Putting it together: a well-behaved prompt

The pieces combine into one rule set for a command-line program: results go to stdout so they can be redirected or piped; prompts, progress and diagnostics go to stderr; colour and interactivity depend on the stream being a TTY; an unrecoverable problem sets process.exitCode and lets the loop finish. The Bookshelf "add a book" command below prompts on stderr, so that node add.mjs > book.json captures only the JSON, validates each answer, colours the error message only if stderr is a terminal, and works the same with piped answers.

The Bookshelf add command
import { createInterface } from 'node:readline/promises';
import process, { stdin, stdout, stderr } from 'node:process';
import { styleText } from 'node:util';

// Prompts and diagnostics go to stderr, the result goes to stdout: `add.mjs > book.json` stays clean.
const rl = createInterface({ input: stdin, output: stderr });
const lines = rl[Symbol.asyncIterator]();

async function ask(query, validate) {
  for (;;) {
    stderr.write(query);
    const { value, done } = await lines.next();
    if (done) throw new Error('input ended early');
    const problem = validate(value.trim());
    if (!problem) return value.trim();
    stderr.write(styleText('red', `  ${problem}`, { stream: stderr }) + '\n');
  }
}

try {
  const title = await ask('Title: ', (v) => (v ? '' : 'a title is required'));
  const year = Number(await ask('Year: ', (v) => (/^\d{4}$/.test(v) ? '' : 'use four digits')));
  stdout.write(JSON.stringify({ title, year }) + '\n');
} catch (err) {
  stderr.write(`${err.message}\n`);
  process.exitCode = 1;
} finally {
  rl.close();
}
Runs with piped answers (identical on Node.js 26, 24 and 22)
printf 'Dune\n65\n1965\n' | node add.mjs 2>/dev/null     # stdout only
printf 'Dune\n' | node add.mjs; echo "exit code $?"        # input ends early
{"title":"Dune","year":1965}
Title: Year: input ended early
exit code 1

The complete tool, with argument parsing, --help, exit codes and packaging, is the subject of Building CLI Tools.

What changed recently

Table 6. Changes relevant to terminal I/O, newest first
Version Change

v26.8.0

tty.ReadStream setRawMode() accepts 'raw' and 'io'; new readStream.rawMode property.

v26.1.0

util.styleText() supports hexadecimal colours.

v24.10.0

new Console({ inspectOptions }) accepts a Map from stream to options.

v24.2.0, v22.17.0

util.styleText() accepts the 'none' format.

v24.0.0, v22.17.0

The node:readline/promises Promises API is marked stable.

v23.10.0, v22.15.0

rl[Symbol.dispose]() (alias of rl.close()), so using and await using work with interfaces.

v23.5.0, v22.13.0

util.styleText() is stable.

v22.8.0, v20.18.0

util.styleText() respects isTTY, NO_COLOR, NODE_DISABLE_COLORS and FORCE_COLOR.

v18.11.0

filehandle.readLines() added.

v17.0.0

node:readline/promises added.

Nothing in this area was removed or changed incompatibly between Node.js 22 and 26 in the documentation reviewed on 2026-10-03; everything that is new is additive.

Where the books are outdated

Node.js Projects (Jonathan Wexler, O’Reilly, 2025) builds its first command-line application on node:readline, wrapping the callback rl.question() in a hand-written promise or in util.promisify() so that it can be used with await. That was necessary before Node.js v17.0.0; today node:readline/promises provides it directly, with AbortSignal support the wrappers lack. The book’s prompts also share the weakness shown above: sequential questions read from piped input can lose lines, which an application that is meant to be scripted should avoid with the single-iterator pattern. Its imports omit the node: prefix, which still works but is the older spelling. Efficient Node.js (Samer Buna, O’Reilly, 2025) uses console.time()/console.timeEnd() and process.stdin/process.stdout piping accurately to illustrate asynchronous behaviour and streams; the points to add are the pipeline() function over bare pipe() calls (so that errors propagate and streams are cleaned up) and the TTY-versus-pipe differences described on this page, which the examples do not need but real tools do. Neither book covers util.styleText() (stable since v23.5.0), so colour is something to take from this page rather than from a third-party package.