# Stdout/stderr separation

- **Pattern:** `ab-000645` (`cli-quality.io-behavior.stdout-stderr-separation`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000645
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000645)

## Why it matters

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.

## Severity rationale

Critical because stdout/stderr mixing silently corrupts downstream pipeline consumers and CI log parsers — fixing exit codes alone does not restore composability.

## Remediation

Route all data to stdout and all messages (progress, warnings, errors) to stderr. Test the separation by redirecting each stream independently:

```ts
// 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
# 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.

## Detection

- **ID:** `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:

  ```ts
  // 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
  # 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
  // 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.

## External references

- iso-25010 usability.operability

Taxons: user-experience, code-quality

HTML version: https://auditbuffet.com/patterns/ab-000645
