Articles / Linux

  • linux
  • terminal
  • isatty
  • cli
  • go
  • debugging

Why Your CLI Prints Colour Codes Into a Pipe, and How isatty Fixes It

You run your tool, it looks lovely, then you pipe it into grep or redirect it to a file and find ^[[31m littered through the output. The tool did exactly what you told it to. It just had no idea anyone had moved the goalposts.

Here is the whole problem in one line: a program writes bytes, and the terminal is what turns certain bytes into colour. Nothing else interprets them.

What those codes actually are

"Colour" in a terminal is just text. An ANSI escape sequence is the byte 0x1b (ESC), then [, then some numbers, then a letter. ESC[31m means "switch to red"; ESC[0m means "reset".

The terminal emulator spots these and changes its drawing state. A file, a pipe or a log collector has no drawing state, so it stores the bytes verbatim. You can see them with cat -v:

$ ./mytool | cat -v
^[[31merror^[[0m: disk full

That ^[ is how cat -v shows the ESC byte. If you were expecting to grep '^error', you will get nothing, because the line starts with ESC.

The program can ask where it is writing

Every process has file descriptor 1 for standard output, but it does not know what is on the other end. It can ask. The C library call is isatty(fd), which returns true only if the descriptor refers to a terminal device.

Under the hood it is an ioctl. Run something that calls it through strace and you can watch it happen:

$ strace -e trace=ioctl ls | cat
ioctl(1, TCGETS, 0x7ffd...) = -1 ENOTTY (Inappropriate ioctl for device)

On a real terminal, that TCGETS call succeeds and returns the terminal settings. On a pipe it fails with ENOTTY. That error is the entire mechanism: "this is not a terminal, so keep your escapes to yourself".

Quick detour: why ls already behaves

This is why ls --colour=auto is the usual default. "Auto" literally means "call isatty on stdout and decide". The other two values, always and never, exist as overrides.

Hang on, why would anyone want always? Because less -R and some CI log viewers do render escapes, so you sometimes want colour through a pipe on purpose. A good tool lets you force it.

Doing it in Go

You do not need a dependency for the basic check. A terminal is a character device, so os.File.Stat will tell you:

package main

import (
	"fmt"
	"os"
)

func isTerminal(f *os.File) bool {
	fi, err := f.Stat()
	if err != nil {
		return false
	}
	return fi.Mode()&os.ModeCharDevice != 0
}

func main() {
	red, reset := "", ""
	if isTerminal(os.Stdout) && os.Getenv("TERM") != "dumb" {
		red, reset = "\x1b[31m", "\x1b[0m"
	}
	fmt.Printf("%serror%s: disk full\n", red, reset)
}

Run it plainly and it is red. Run it with | cat -v or > out.txt and the escapes vanish.

The catch: /dev/null is a character device too

This shortcut has a flaw. /dev/null is also a character device, so mytool > /dev/null counts as "a terminal" and you emit escapes into the void. Harmless, but wrong.

If you want the real answer, use golang.org/x/term and call term.IsTerminal(int(os.Stdout.Fd())), which does the same TCGETS ioctl that C does. That is one small dependency, and I would take it for anything you ship.

Decide stdout and stderr separately

Each stream needs its own check. Consider this common case:

$ mytool > results.txt

Stdout is a file, so results stay clean. Stderr is still the terminal, so error messages can safely stay coloured. If you test only stdout and apply the answer everywhere, you either lose colour on errors or pollute the file. Check os.Stderr separately for anything you print there.

A sensible order of precedence

When a user asks for something explicit, believe them. A decent order, highest priority first:

  1. A --colour=always|never|auto flag, if the user passed one.
  2. The widely followed convention of an environment variable whose name starts with NO_: if it is set to anything non-empty, turn colour off.
  3. TERM=dumb: turn colour off.
  4. Otherwise, colour on if and only if the stream is a terminal.

That ordering means a user in CI can force it on, a user with a sensitive eye or screen reader can force it off everywhere, and the default does the right thing without anyone configuring anything.

Why this matters beyond tidiness

Stray escapes are more than ugly. They break grep anchors, corrupt CSV and JSON written from "human" output, and fill log files with noise that is awkward to search. Escape sequences in logs have also been used to hide or rewrite lines when a viewer prints them raw to a terminal, so stripping them at the source is a small hardening step as well.

One last test worth keeping: pipe your tool into cat -v in a smoke test and fail if you see ^[. It takes two lines and catches the regression the first time someone adds a coloured warning and forgets the check.