When a CLI mixes data output and status messages on stdout, any pipeline that consumes the output breaks silently. Running mytool list-users | jq '.[].email' will fail or produce corrupt results if progress text like "Fetching 42 users..." lands in the same stream as the JSON. Docker, xargs, tee, and CI log parsers all assume stdout carries only data. Sending error messages to stdout also defeats shell && semantics: a caller checking exit code sees 0 but its stdin feed contains garbage. This is classified as a critical reliability failure under ISO 25010:2011 usability.operability because the entire composability contract of the Unix tool model breaks at this seam.
Critical because stdout/stderr mixing silently corrupts downstream pipeline consumers and CI log parsers — fixing exit codes alone does not restore composability.
Route all data to stdout and all messages (progress, warnings, errors) to stderr. Test the separation by redirecting each stream independently:
// Node.js — data to stdout, everything else to stderr
async function listUsers(format: string) {
console.error('Fetching users...') // status → stderr
const users = await fetchUsers()
process.stdout.write(JSON.stringify(users, null, 2) + '\n') // data → stdout
console.error(`Found ${users.length} users`) // status → stderr
}
# Python — click.echo(err=True) for all non-data output
@cli.command()
def list_users():
click.echo('Fetching users...', err=True)
users = fetch_users()
for u in users:
click.echo(f'{u.name} <{u.email}>') # data → stdout
click.echo(f'Found {len(users)} users', err=True)
Verify with: mytool list-users > data.txt — the terminal should show status lines while data.txt contains only clean output.
ID: cli-quality.io-behavior.stdout-stderr-separation
Severity: critical
What to look for: Count all output statements in the CLI code (console.log, process.stdout, process.stderr, console.error). For each, classify whether it writes to stdout or stderr. examine every output statement in the codebase. In Node.js, check for console.log() vs console.error() and process.stdout.write() vs process.stderr.write(). In Python, check for print() vs print(..., file=sys.stderr) and click.echo() vs click.echo(..., err=True). In Go, check for fmt.Println() / fmt.Fprintf(os.Stdout, ...) vs fmt.Fprintf(os.Stderr, ...). In Rust, check for println!() vs eprintln!(). The rule: data output (the thing a pipeline would consume) goes to stdout. Progress messages, warnings, status updates, and error messages go to stderr.
Pass criteria: Command output data (results, lists, generated content) is written to stdout. Informational messages (progress, warnings, status) are written to stderr. No mixing — commands that produce data output send all non-data messages to stderr — 100% of error and diagnostic messages must go to stderr, not stdout. Report: "X output statements found, Y correctly separate stdout/stderr."
Fail criteria: Error messages or progress text written to stdout (mixing with data output), or data output written to stderr, or all output going to console.log/print() without stream separation.
Skip (N/A) when: The CLI produces no data output — it only performs side effects (e.g., a deployment tool that only shows status). All checks skip when no CLI entry point is detected.
Do NOT pass when: Error messages are written to stdout instead of stderr, even if the exit code is correct.
Detail on fail: "All output uses console.log() — error messages, progress updates, and data output all go to stdout. Pipeline users will get status messages mixed into their data" or "Spinner output written to stdout via ora() default — will corrupt piped output"
Remediation: This is the most important CLI convention for composability. Separate your streams:
// Node.js — data to stdout, everything else to stderr
function listUsers(format: string) {
console.error('Fetching users...') // status → stderr
const users = await fetchUsers()
if (format === 'json') {
process.stdout.write(JSON.stringify(users, null, 2) + '\n') // data → stdout
} else {
users.forEach(u => console.log(`${u.name} <${u.email}>`)) // data → stdout
}
console.error(`Found ${users.length} users`) // status → stderr
}
# Python — click.echo(err=True) for messages
@cli.command()
def list_users():
click.echo('Fetching users...', err=True) # status → stderr
users = fetch_users()
for u in users:
click.echo(f'{u.name} <{u.email}>') # data → stdout
click.echo(f'Found {len(users)} users', err=True) # status → stderr
// Go — os.Stdout for data, os.Stderr for messages
fmt.Fprintf(os.Stderr, "Fetching users...\n")
for _, u := range users {
fmt.Fprintf(os.Stdout, "%s <%s>\n", u.Name, u.Email)
}
Test your separation: mytool list-users > output.txt should produce a clean file with only user data, while progress messages appear in the terminal.