The terminal doesn't care about your feelings
You build a command-line tool. It parses arguments, runs a task, and prints Processing... Done. to the screen. It works exactly as intended. Then you run it alongside a dozen other tools, and your output blends into a wall of monospaced gray. Users miss the warnings. They skip the success messages. They assume your tool failed because they cannot distinguish your status updates from the surrounding noise.
Adding color is not about making your interface pretty. It is about visual hierarchy. The terminal has supported colored text for decades. Go makes it straightforward to tap into that capability without writing raw escape sequences by hand. You just need to understand how the bytes travel from your program to the screen.
What ANSI escape codes actually do
Terminals do not understand the concept of red or blue. They understand byte sequences. When a program writes \033[31m to standard output, the terminal interprets those bytes as a command: switch the foreground color register to red. The sequence ends with \033[0m, which resets all formatting back to default. These are ANSI escape codes. They travel through the exact same pipe as your regular text.
The terminal emulator maintains a small state machine. It reads bytes one at a time. When it encounters the escape character, it switches into command-parsing mode. It reads the following characters until it hits a letter that terminates the sequence. It updates its internal color registers, then returns to normal text rendering. Your program never sees the rendered pixels. It only sees bytes flowing out to os.Stdout.
You do not need to memorize the hex values for every shade. You just need a reliable way to inject those sequences at the right moments and strip them when the environment does not support them.
A minimal colored print
The most common approach is to use a wrapper library that handles the byte construction and environment detection for you. The github.com/fatih/color package is the standard choice for simple CLI output. It exposes functions that map directly to terminal capabilities.
package main
import (
"fmt"
"github.com/fatih/color"
)
// PrintStatus outputs a message with a color that matches its severity.
// We separate formatting from logic so the core function stays clean.
func PrintStatus(severity string, message string) {
// The library maps severity strings to predefined ANSI codes.
// It automatically wraps the message with the start and reset sequences.
switch severity {
case "error":
// Red draws immediate attention to failures.
color.Red(message)
case "warning":
// Yellow signals caution without stopping execution.
color.Yellow(message)
default:
// Fallback to standard output when no styling is needed.
fmt.Println(message)
}
}
func main() {
// Demonstrate how different severity levels render in the terminal.
PrintStatus("info", "Starting build process")
PrintStatus("warning", "Cache directory is missing")
PrintStatus("error", "Failed to connect to database")
}
Run this with go run main.go. The terminal receives the escape sequences, updates its color registers, and prints the text. The reset code ensures the next line returns to default formatting. If you forget to import the package, the compiler rejects the program with undefined: color. If you pass a non-string argument to color.Red, you get cannot use ... as string value in argument. The type system catches these mistakes before runtime.
Goroutines are cheap. Color codes are just bytes.
How the library handles the plumbing
When color.Red("text") executes, it does not blindly inject escape sequences. It checks the environment first. The library reads the TERM environment variable. If it matches known capable terminals like xterm-256color or screen, it assumes full 256-color support. If TERM is unset or set to a minimal value like dumb, it strips the codes entirely.
On Windows, older console hosts do not understand ANSI sequences natively. The library detects the Windows environment and falls back to the legacy Windows API for color changes, or disables coloring if the API is unavailable. Modern Windows Terminal handles ANSI codes correctly, so the fallback path rarely triggers on recent systems.
The function constructs the byte slice \x1b[31mtext\x1b[0m and writes it to os.Stdout. The operating system passes those bytes to the terminal emulator. The emulator's rendering engine parses the escape sequence, updates its color register, prints the characters, then resets the register. Your program never touches the display driver. It only manages a stream of bytes.
This design follows a core Go convention: accept interfaces, return structs. Most advanced CLI libraries do not hardcode fmt.Println. They accept an io.Writer parameter. This lets you redirect output to a file, a buffer, or a test harness without changing the formatting logic. You pass os.Stdout during normal execution and &bytes.Buffer{} during testing. The same code path handles both.
Context is plumbing. Run it through every long-lived call site.
Real-world CLI output
Production tools need to respect user preferences and automated environments. Continuous integration servers capture terminal output as plain text logs. Injecting escape sequences into a log file creates garbage characters that break parsers. The NO_COLOR environment variable is an open standard. When it is set to any non-empty value, CLI tools must disable color output.
You also need a command-line flag for users who prefer monochrome terminals. The flag package parses arguments before your main logic runs. This keeps configuration separate from execution.
package main
import (
"flag"
"fmt"
"os"
"github.com/fatih/color"
)
// Run executes the core logic of the CLI with configurable output formatting.
// We separate flag parsing from execution to keep the function testable.
func Run(disableColor bool) {
// Disable color globally if the flag is set or NO_COLOR is present.
// This respects user preference and CI environment standards.
if disableColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
// Use color.New to create a reusable writer for consistent formatting.
// This avoids repeating the color function call for every line.
success := color.New(color.FgGreen, color.Bold)
success.Println("Build completed successfully")
// Fallback to standard output when no styling is needed.
fmt.Println("Output written to build.log")
}
func main() {
// The flag package parses command-line arguments before main logic runs.
// This keeps configuration separate from execution.
noColor := flag.Bool("no-color", false, "disable terminal colors")
flag.Parse()
Run(*noColor)
}
The color.NoColor = true assignment is a global toggle. It affects every subsequent call in the process. This is acceptable for CLI tools that run a single task and exit. Long-running daemons should avoid global state. They should pass a configured writer or a context value down the call chain.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Color handling is similar. Explicit toggles make the formatting behavior obvious to anyone reading the code.
When colors break and how to fix them
Color output fails in predictable ways. Piping to a file is the most common culprit. When you run mytool > output.txt, the operating system redirects os.Stdout to a file descriptor. The terminal emulator is no longer in the pipeline. The file receives the raw escape sequences. You end up with \x1b[31mError\x1b[0m in your text file instead of readable words.
You can detect this condition by checking if os.Stdout is connected to a terminal. The golang.org/x/term package provides IsTerminal(fd int) bool. Pass int(os.Stdout.Fd()) to the function. If it returns false, disable coloring automatically. This handles piping, redirection, and CI capture without requiring manual flags.
Legacy Windows consoles sometimes render escape sequences as visible question marks or garbage characters. This happens when the console host does not have virtual terminal processing enabled. The fatih/color library handles this gracefully by falling back to the Windows API or stripping codes. If you write raw ANSI sequences yourself, you must implement the fallback logic.
Another subtle issue is contrast accessibility. Default terminal themes vary wildly. A bright yellow warning on a light gray background becomes invisible. Stick to high-contrast combinations. Use bold modifiers sparingly. Test your output against both dark and light terminal themes before shipping.
The worst goroutine bug is the one that never logs. The worst color bug is the one that breaks your logs.
Picking your coloring strategy
Use github.com/fatih/color when you need quick, reliable ANSI formatting for a standard CLI tool. Use raw ANSI escape sequences when you want zero dependencies and full control over the exact byte output. Use charmbracelet/lipgloss when you are building a rich terminal UI with borders, padding, and layout grids. Use plain text when your tool runs exclusively in automated pipelines or targets embedded environments with minimal terminal support.