How to add colored output to CLI

Cli
Enable colored CLI output in Rust by setting RUSTC_COLOR=always or using the --color=always flag with cargo and rustc commands.

When gray text isn't enough

You just finished your first Rust CLI tool. It parses arguments, does the work, and prints the result. The logic is solid. The output is a block of monochrome text that blends into the terminal background. You want errors to scream red and success to glow green. You found a mention of RUSTC_COLOR in the docs and tried setting it, but your tool ignores the variable completely. That variable controls the compiler, not your code. Your tool needs to emit ANSI escape sequences, and Rust gives you a few ways to do that without drowning in hex codes.

How terminals handle color

Terminals don't have a "color" button. They have a stream of bytes. When the terminal sees a specific sequence starting with the escape character, it switches modes. It changes the foreground color, the background, or the boldness. Then it prints the following characters in that style. You need to send the style change, print the text, and send a reset command so the rest of the output doesn't stay colored. Rust strings are just UTF-8 bytes, so you can embed these sequences directly. The problem is readability. Writing "\x1b[31mError\x1b[0m" works, but it turns your source code into a puzzle. Crates exist to wrap these sequences in readable methods.

The raw mechanism

You can color output without any dependencies. The escape character is byte 27, often written as \x1b in Rust strings. The sequence [31m sets the foreground to red. The sequence [0m resets all styles.

fn main() {
    // ANSI escape code: ESC[31m sets red foreground.
    // ESC[0m resets all styles to default.
    println!("\x1b[31mThis text is red.\x1b[0m");
    
    // The reset code ensures this line returns to normal.
    println!("This text is normal.");
}

When you run this, the runtime writes the bytes to standard output. The terminal emulator reads the escape character. It sees [31m and updates its internal state to "red foreground". It prints "This text is red." Then it sees [0m and resets the state. The next println uses the default color. If you forget the reset, every line after the error turns red. That's a classic bug. Always close your color sequence.

Using a crate for readability

Raw codes work, but they make code reviews painful. The ecosystem provides crates that add methods to strings or types, hiding the escape codes. The most common choice is colored. It adds a Colorize trait with methods like .red(), .green(), and .bold().

Add colored to your Cargo.toml:

[dependencies]
colored = "2"

Then use it in your code:

use colored::Colorize;

/// Prints a styled status message to stdout.
fn print_status() {
    let status = "Build successful";
    // Colorize adds methods to types that implement Display.
    // The crate generates the ANSI codes behind the scenes.
    println!("{}", status.green().bold());
    
    let error = "File not found";
    // Chaining works. Red background, white text.
    // The reset code is appended automatically.
    println!("{}", error.red().on_white());
}

fn main() {
    print_status();
}

The colored crate handles the reset code for you. It also checks if the terminal supports color and can suppress output if needed. The community convention for colored is to import Colorize and use the method chain. It reads like natural language.

A convention aside: colored is the historical default for easy coloring. owo-colors is the modern performance choice. If you're building a tool where startup time matters or you're printing millions of lines, owo-colors avoids the overhead of trait lookups and allocations. For most CLIs, colored is fine. The community is shifting toward owo-colors for new high-performance projects.

Robust coloring with termcolor

Some tools need more control. termcolor provides a writer-based API that integrates with std::io::Write. It's more verbose but handles edge cases better, like terminals that don't support colors at all. It's often used in logging frameworks.

use std::io::{self, Write};
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};

/// Writes a colored error message using termcolor's writer API.
fn write_error() {
    // StandardStream wraps stdout and adds color methods.
    // ColorChoice::Auto detects if the terminal supports color.
    let mut stdout = StandardStream::stdout(ColorChoice::Auto);
    
    // Create a spec for the style.
    let mut spec = ColorSpec::new();
    spec.set_fg(Some(Color::Red)).set_bold(true);
    
    // Apply the spec, write text, then reset.
    // termcolor handles the reset when you call reset_color.
    stdout.set_color(&spec).unwrap();
    write!(stdout, "Error: ").unwrap();
    stdout.reset_color().unwrap();
    writeln!(stdout, "Something went wrong.").unwrap();
}

fn main() {
    write_error();
}

termcolor is useful when you're writing to a file or a pipe and want to ensure colors are suppressed automatically. It also provides better support for older Windows terminals that require special handling.

Pitfalls and conventions

Coloring output introduces a few gotchas. Windows is the biggest one. Legacy Windows terminals (cmd.exe before Windows 10, or PowerShell in some configurations) don't understand ANSI codes. They print the garbage sequences instead of changing colors. You need to enable virtual terminal processing. colored provides a function for this.

#[cfg(windows)]
fn enable_ansi_support() {
    // On Windows, legacy terminals need a flag to enable ANSI.
    // This calls the Win32 API to set the console mode.
    colored::control::enable_ansi_support();
}

fn main() {
    #[cfg(windows)]
    enable_ansi_support();
    
    use colored::Colorize;
    println!("{}", "Hello".red());
}

Another issue is piping. If a user runs my_tool | grep error, the color codes pollute the output and break the pipe. You should detect if stdout is a TTY. Rust 1.70 added std::io::IsTerminal.

use std::io::IsTerminal;

fn main() {
    let use_color = std::io::stdout().is_terminal();
    
    if use_color {
        use colored::Colorize;
        println!("{}", "Colored".red());
    } else {
        println!("Plain");
    }
}

There's also the NO_COLOR convention. The NO_COLOR environment variable is a standard across many tools. If it's set, your tool should suppress colors. This respects user preferences and scripts that parse output.

fn should_color() -> bool {
    // NO_COLOR is a standard convention. If present, disable colors.
    // An empty value counts as set.
    std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
}

fn main() {
    if should_color() {
        use colored::Colorize;
        println!("{}", "Colored".red());
    } else {
        println!("Plain");
    }
}

A convention aside: Errors should go to stderr, not stdout. Use eprintln! for errors. This allows users to pipe stdout to a file while still seeing errors on the screen. Colors on stderr follow the same rules.

If you forget to import the trait, the compiler rejects the method call with E0599 (no method named red found). If you try to color a type that doesn't implement Display, you get E0277 (trait bound not satisfied). These errors are straightforward. Fix the import or implement the trait.

Choosing your approach

Use colored when you want the simplest API and are building a standard CLI tool where startup overhead is negligible. Use owo-colors when you need maximum performance, no_std support, or are printing high volumes of text where allocation matters. Use termcolor when you need robust cross-platform handling, including automatic fallback for terminals that don't support colors, and you prefer a writer-based API. Use raw ANSI codes when you are writing a library that must have zero dependencies and you control the exact byte output.

Pick the crate that matches your performance needs, not just the one with the most stars. Respect NO_COLOR. Detect the TTY. Keep your code readable.

Where to go next