Colored Terminal Output
How to add color and styling to a command-line tool’s output in Rust, the way you would with chalk or picocolors in Node.js, and, importantly, how to make that color disappear cleanly when output is piped to a file or when the user sets NO_COLOR.
Quick Overview
Section titled “Quick Overview”Color in a terminal is just ANSI escape codes: short byte sequences like \x1b[32m (green on) and \x1b[39m (default foreground) wrapped around your text. In Node.js you reach for chalk, picocolors, or kleur; in Rust the go-to crates are owo-colors (zero-cost styling extension methods), anstream (a smart stdout/stderr that strips color when it shouldn’t be there), anstyle (the style vocabulary clap and Cargo speak), and console (batteries-included, auto-detecting). The single most important thing this page teaches: respect NO_COLOR and don’t emit escape codes into pipes. Getting this right is the difference between a polished tool and one that dumps \x1b[32m garbage into log files.
The repository’s pinned verification toolchain uses the 2024 edition; cargo new selects that edition automatically.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In Node.js, chalk is the household name, though the tiny picocolors has largely won on size and speed. Both auto-detect whether output is a terminal and honor the NO_COLOR / FORCE_COLOR conventions for you.
// npm install chalk (chalk v5 is ESM-only)import chalk from "chalk";
// Basic colors and stylesconsole.log(chalk.green("Success!"));console.log(chalk.yellow("Warning!"));console.log(chalk.red.bold("Error!"));console.log(`${chalk.blue("INFO")} Server started`);
// Background colors, combinations, truecolorconsole.log(chalk.white.bgRed.bold("Alert"));console.log(chalk.dim("dimmed note"));console.log(chalk.underline("underlined"));console.log(chalk.rgb(255, 128, 0)("custom rgb"));
// A cargo-style diagnosticfunction report(level: "info" | "warning" | "error", message: string) { const tag = { info: chalk.green.bold("info"), warning: chalk.yellow.bold("warning"), error: chalk.red.bold("error"), }[level]; const line = `${tag}: ${message}`; if (level === "error") console.error(line); else console.log(line);}
report("info", "compiling 12 modules");report("error", "could not build");Chalk decides at runtime whether to emit codes. If you run node app.js | cat, or set NO_COLOR=1, chalk produces plain text automatically. That auto-detection is the behavior we want to reproduce in Rust, and as you’ll see, not every Rust crate does it for you by default.
Rust Equivalent
Section titled “Rust Equivalent”The idiomatic modern stack is owo-colors for the styling syntax plus anstream for a terminal-aware output stream. owo-colors gives you chalk-like extension methods; anstream’s println! is a drop-in that strips the codes when the destination is not a real terminal or when NO_COLOR is set.
Add the dependencies (in a fresh cargo new project):
cargo add owo-colorscargo add anstreamuse anstream::println; // terminal-aware: strips ANSI when not a TTY / NO_COLOR setuse owo_colors::OwoColorize;
fn main() { // Basic colors and styles — same vocabulary as chalk println!("{}", "Success!".green()); println!("{}", "Warning!".yellow()); println!("{}", "Error!".red().bold()); println!("{} {}", "INFO".blue(), "Server started");
// Background colors, combinations, truecolor println!("{}", "Alert".white().on_red().bold()); println!("{}", "dimmed note".dimmed()); println!("{}", "underlined".underline()); println!("{}", "custom rgb".truecolor(255, 128, 0));}Tip: The split is deliberate. owo-colors only describes the style; anstream decides whether to keep it. You can swap either piece independently — e.g. use owo-colors with a plain
std::println!and your own detection, or use anstream withanstyleinstead of owo-colors.
Here is the same report helper, written so color is automatically disabled in pipes and under NO_COLOR:
use anstream::{eprintln, println};use owo_colors::OwoColorize;use std::fmt::Display;
enum Level { Info, Warn, Error,}
impl Level { fn label(&self) -> String { match self { Level::Info => "info".green().bold().to_string(), Level::Warn => "warning".yellow().bold().to_string(), Level::Error => "error".red().bold().to_string(), } }}
fn report(level: Level, message: impl Display) { let line = format!("{}: {}", level.label(), message); match level { Level::Error => eprintln!("{line}"), _ => println!("{line}"), }}
fn main() { report(Level::Info, "compiling 12 crates"); report(Level::Warn, "unused import: `std::env`"); report(Level::Error, "could not compile `app` (bin \"app\")");}Run through a pipe, the ANSI codes are gone:
cargo run -q | catinfo: compiling 12 crateswarning: unused import: `std::env`(The error line goes to stderr, so it doesn’t show in the piped stdout, exactly like Cargo.)
Detailed Explanation
Section titled “Detailed Explanation”owo-colors: zero-cost styling via an extension trait
Section titled “owo-colors: zero-cost styling via an extension trait”use owo_colors::OwoColorize; brings a trait into scope that adds methods like .green(), .bold(), and .on_red() to every type that implements Display (and Debug). This is the same ergonomic trick TypeScript devs know from prototype extension, but checked at compile time.
The key subtlety: .green() does not return a String. It returns a tiny wrapper struct — FgColorDisplay<'_, Red, &str> (here Red is shorthand for owo_colors::colors::Red, the fully-qualified path the compiler prints in the Pitfall 1 error below) — that holds a reference to your value and remembers the style. No allocation, no escape codes are produced until the value is actually formatted. When you eventually println!("{}", x.green()), the wrapper’s Display implementation writes the green escape code (\x1b[32m) before your value and a reset (\x1b[39m) after — the escape codes exist only for the duration of that one format call.
console: chalk-style convenience with auto-detection
Section titled “console: chalk-style convenience with auto-detection”console — the same crate family behind Progress Bars and Spinners with indicatif — takes the chalk approach: console::style(..) returns a StyledObject that auto-detects by default, so you don’t need a separate stream.
cargo add consoleuse console::style;
fn main() { // Auto-detects TTY + NO_COLOR/CLICOLOR; only emits ANSI when appropriate. println!("{}", style("Deploy succeeded").green().bold()); println!("{}", style("Retrying...").yellow());
// Override the decision explicitly when you must: println!("{}", style("always red").red().force_styling(true));}Piped, the auto-detecting styles vanish but the forced one survives (raw bytes):
"Deploy succeeded\nRetrying...\n\x1b[31malways red\x1b[0m\n"anstyle: the shared styling vocabulary
Section titled “anstyle: the shared styling vocabulary”You will rarely reach for anstyle directly — it is the minimal, dependency-free crate the rest of the ecosystem agreed on to describe a style (it is the same vocabulary clap renders its colored --help with — see Parsing Arguments with the clap Derive API), but it’s worth recognizing:
use anstyle::{AnsiColor, Color, Style};
fn main() { let heading = Style::new() .bold() .fg_color(Some(Color::Ansi(AnsiColor::Green)));
// `{heading}` writes the opening sequence; `{heading:#}` writes the reset. println!("{heading}Section{heading:#}"); // Equivalent, spelled out: println!("{}Section{}", heading.render(), heading.render_reset());}Both lines produce the same bytes — \x1b[1m\x1b[32mSection\x1b[0m. anstyle is just the codes; it has no opinion about detection, which is precisely why higher-level crates build on it.
Key Differences
Section titled “Key Differences”| Concern | TypeScript (chalk / picocolors) | Rust |
|---|---|---|
| How styling attaches | Functions wrapping strings | Trait methods (OwoColorize) on any Display type, or style(..) wrappers |
Return value of .green() | A new string | A lazy zero-cost wrapper (owo-colors) or StyledObject (console) — not a String |
| Auto terminal detection | Built in, on by default | owo-colors: off by default; console: on; anstream: on at the stream |
NO_COLOR honored | Yes, automatically | Yes, via anstream, console, or owo-colors’ supports-colors feature; not by raw owo-colors |
| Force color | FORCE_COLOR=1 | CLICOLOR_FORCE=1 (anstream/console), or explicit force_styling(true) / set_override(true) |
| Output target awareness | One console.log | Choose: terminal-aware anstream::println! vs raw std::println! |
The headline conceptual difference: chalk couples styling and detection; the Rust ecosystem deliberately decouples them. owo-colors answers “what does green look like?” while anstream answers “should this stream show green right now?”. This separation is more verbose for a hello-world, but it scales: you set the policy once at the output boundary and never branch on NO_COLOR in business logic. If you want the chalk-style all-in-one feel, reach for console.
Note:
NO_COLOR(see https://no-color.org) is a cross-language convention: if the variable is present and non-empty, applications should not emit color.CLICOLOR_FORCE(non-empty) forces color on even when piped. These are the same conventions Cargo, ripgrep, and most modern CLIs follow.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Treating a styled value like a String
Section titled “Pitfall 1: Treating a styled value like a String”A TypeScript dev expects "error".red() to be a string and tries to +-concatenate it like JavaScript:
use owo_colors::OwoColorize;
fn main() { let msg = "error".red() + ": something broke"; // does not compile (error[E0369]) println!("{}", msg);}The real compiler error:
error[E0369]: cannot add `&str` to `FgColorDisplay<'_, owo_colors::colors::Red, &str>` --> src/main.rs:6:29 |6 | let msg = "error".red() + ": something broke"; | ------------- ^ ------------------- &str | | | FgColorDisplay<'_, owo_colors::colors::Red, &str> |note: the foreign item type `FgColorDisplay<'_, owo_colors::colors::Red, &str>` doesn't implement `Add<&str>`.red() returns a lazy display wrapper, not a String. The fix is to format the pieces together rather than add them:
use owo_colors::OwoColorize;
fn main() { let msg = format!("{}: something broke", "error".red()); println!("{msg}");}Pitfall 2: Using std::println! with owo-colors and leaking codes
Section titled “Pitfall 2: Using std::println! with owo-colors and leaking codes”Because raw owo-colors never detects the terminal, this writes escape bytes into a redirected file:
use owo_colors::OwoColorize;
fn main() { // std::println! does NO detection; owo-colors does NO detection. println!("{}", "result".green()); // leaks \x1b[32m...\x1b[39m into pipes/files}Run cargo run | cat and you’ll see literal \x1b[32m in the output: the exact garbage-in-logs problem. Fix: import anstream::println (or use console::style, or gate with if_supports_color). One-line change:
use anstream::println; // ← decides keep-or-strip per streamuse owo_colors::OwoColorize;
fn main() { println!("{}", "result".green()); // stripped when piped, kept on a TTY}Pitfall 3: Padding a value that already contains ANSI bytes
Section titled “Pitfall 3: Padding a value that already contains ANSI bytes”Alignment specifiers count bytes, and ANSI escape sequences are bytes. owo-colors’ lazy wrapper is smart — it forwards the format spec to the inner text, so {:<10} pads correctly. But the moment you render a styled value into a String (with .to_string() or format!) and then try to pad it, the width counts the invisible escape bytes:
use owo_colors::OwoColorize;
fn main() { // Pre-rendered into a String — width now counts the escape bytes: let pre_rendered = "OK".green().to_string(); println!("[{:<10}]", pre_rendered); // NOT padded to 10 visible columns}Raw bytes: [\x1b[32mOK\x1b[39m] — no padding at all, because the formatter saw a 10-byte-ish string of mostly escape characters. Fix: pad the plain text first, then style the padded result:
use owo_colors::OwoColorize;
fn main() { let padded = format!("{:<10}", "OK"); println!("[{}]", padded.green()); // visible "OK" + 8 spaces, then colored}Pitfall 4: Forgetting to enable the supports-colors feature
Section titled “Pitfall 4: Forgetting to enable the supports-colors feature”If you write use owo_colors::{OwoColorize, Stream}; and call if_supports_color without the feature, you actually get two errors. First, the unresolved import of Stream (E0432) — and this is the one that carries the “configured out / gated behind supports-colors” note. Second, a separate “method not found” (E0599) for if_supports_color, with no note attached:
error[E0432]: unresolved import `owo_colors::Stream` --> src/main.rs:1:31 |1 | use owo_colors::{OwoColorize, Stream}; | ^^^^^^ no `Stream` in the root |note: found an item that was configured out | | --------------------------- the item is gated behind the `supports-colors` feature... | supports_colors::{Stream, SupportsColorsDisplay}, | ^^^^^^
error[E0599]: no method named `if_supports_color` found for reference `&'static str` in the current scope --> src/main.rs:6:23 |6 | "conditional".if_supports_color(Stream::Stdout, |t| t.green()) | ^^^^^^^^^^^^^^^^^ method not found in `&'static str`The lesson: the telltale “gated behind the supports-colors feature” note hangs off the Stream import error, not the method error. Fix: cargo add owo-colors --features supports-colors. (Or just use anstream/console, which need no extra feature flags.)
Best Practices
Section titled “Best Practices”-
Decide color once, at the output boundary. Route all user-facing output through
anstream::stdout()/stderr()(or their macros). Keep your styling code unconditional and free ofif NO_COLORchecks. -
Respect
NO_COLORandCLICOLOR_FORCE. Don’t roll your own env parsing if you can avoid it; anstream, console, and owo-colors’supports-colorsfeature all implement the conventions correctly. The check is:NO_COLORpresent and non-empty disables;CLICOLOR_FORCEpresent and non-empty forces on. -
Send errors and diagnostics to stderr, normal output to stdout, and detect each independently. anstream’s
stdout()andstderr()evaluate their own stream, so a tool whose stdout is piped but whose stderr is a terminal still colorizes errors. -
Prefer named ANSI colors over truecolor for portability.
.green()works on virtually every terminal;.truecolor(...)only renders correctly on 24-bit-capable terminals and silently degrades elsewhere. -
Give users an explicit
--color <auto|always|never>flag, mapping to anstream/owo-colors overrides. This is what Cargo and ripgrep do, and power users expect it. With owo-colors,owo_colors::set_override(true|false)sets a process-wide decision thatif_supports_colorhonors:use owo_colors::{OwoColorize, Stream};fn main() {owo_colors::set_override(false); // e.g. from `--color never`println!("{}","forced off".if_supports_color(Stream::Stdout, |t| t.red())); // prints plain, even on a TTY} -
Don’t hand-write escape codes like
"\x1b[32m". They’re error-prone (easy to forget the reset), don’t get stripped automatically, and break on non-ANSI consoles. Let a crate own the bytes.
Warning: A subtle correctness issue: a manual or pre-rendered ANSI string that you later pass through a width/truncation formatter (
{:.10},{:<10}) will miscount, because escape bytes are counted but not displayed. Style after you’ve finished sizing the plain text. See Pitfall 3.
Real-World Example
Section titled “Real-World Example”A small cargo-style diagnostic reporter — the kind of output a linter or build tool emits — that colorizes on a terminal, goes plain in pipes and under NO_COLOR, and sends errors to stderr. This is compile-verified end to end.
Note: This example uses the unconditional-styling pattern (owo-colors always emits codes;
anstreamstrips them at the boundary). With this pattern the user-facing color knobs are the environment variablesanstreamreads —NO_COLORandCLICOLOR_FORCE— notowo_colors::set_override.set_overrideonly governsif_supports_colorandStyle-based rendering (see Exercise 2), neither of which this code uses, so adding aset_overridebranch here would be a dead no-op. A real--color <auto|always|never>flag must instead be wired intoanstream’sColorChoice(e.g. viaanstream::AutoStreamconstructed with an explicit choice). Exercise 2 shows the override path that does respond toset_override.
Cargo.toml:
[dependencies]anstream = "1.0.0"owo-colors = "4.3.0"src/main.rs:
use anstream::{eprintln, println};use owo_colors::OwoColorize;use std::fmt::Display;
/// A diagnostic severity, like a compiler or linter would emit.enum Level { Info, Warn, Error,}
impl Level { /// The styled label. We always apply the style here; `anstream` strips it /// later if the destination is not a color-capable terminal. fn label(&self) -> String { match self { Level::Info => "info".green().bold().to_string(), Level::Warn => "warning".yellow().bold().to_string(), Level::Error => "error".red().bold().to_string(), } }}
/// Print a `level: message` line, routing errors to stderr.fn report(level: Level, message: impl Display) { let line = format!("{}: {}", level.label(), message); match level { Level::Error => eprintln!("{line}"), _ => println!("{line}"), }}
fn main() { // No per-call color branching: styling is unconditional and `anstream` // strips it when stdout/stderr isn't a color-capable terminal. The user // controls color through the environment `anstream` reads — `NO_COLOR` // disables it, `CLICOLOR_FORCE` forces it on. report(Level::Info, "compiling 12 crates"); report( Level::Warn, format!("unused variable: `{}`", "count".cyan()), ); report(Level::Error, "could not compile `app` (bin \"app\")");}Behavior, verified by inspecting raw bytes:
-
Piped (
cargo run -q | cat): every line is plain text; theerrorline is on stderr.info: compiling 12 crateswarning: unused variable: `count` -
On a real terminal (
NO_COLORunset): labels are colored,countis cyan. Raw stdout bytes:"\x1b[1m\x1b[32minfo\x1b[39m\x1b[0m: compiling 12 crates\n""\x1b[1m\x1b[33mwarning\x1b[39m\x1b[0m: unused variable: `\x1b[36mcount\x1b[39m`\n" -
NO_COLOR=1on a terminal: anstream strips everything; output is identical to the piped case.
This is the whole discipline in one screen: style freely, decide once, route errors correctly, and the conventions take care of themselves.
Further Reading
Section titled “Further Reading”Official Documentation
Section titled “Official Documentation”- owo-colors on docs.rs: the
OwoColorizetrait,if_supports_color,set_override. - anstream on docs.rs: terminal-aware streams and the strip/keep logic.
- anstyle on docs.rs: the shared
Style/Colorvocabulary. - console on docs.rs: auto-detecting
style()and terminal utilities. - supports-color on docs.rs: the detection crate behind the feature.
- The NO_COLOR convention and the CLICOLOR spec.
- std::io::IsTerminal: for rolling your own detection.
Related Sections in This Guide
Section titled “Related Sections in This Guide”- Parsing Arguments with the clap Derive API and Argument Parsing with clap (Builder API):
clapusesanstyleto color its help output; wire a--colorflag here. - Git-like Subcommands with clap: git-like subcommands that share a coloring policy.
- Progress Bars and Spinners with indicatif:
indicatifbuilds onconsolefor spinners and bars. - Terminal UIs with Ratatui: full-screen TUIs with
ratatui(a different rendering model entirely). - Environment Variables: reading
NO_COLOR/CLICOLOR_FORCEand other config via the environment. - Cross-Platform CLI Considerations: Windows console quirks and ANSI support.
- Distributing CLI Tools: shipping the finished tool.
- Foundations: Section 02 — Output and Formatting covers
println!,format!, and format specifiers used throughout this page. New to the toolchain? See Section 01 — Getting Started and Section 00 — Introduction. - Building a colorized WebAssembly logger for the browser console instead of a terminal? See Section 19 — WebAssembly.
Exercises
Section titled “Exercises”Exercise 1: A leak-proof status line
Section titled “Exercise 1: A leak-proof status line”Difficulty: Easy
Objective: Print a green [ OK ] prefix followed by a message, such that the color disappears when output is piped.
Instructions:
- Create a project and
cargo add owo-colors anstream. - Write
status("Database connected")that prints[ OK ] Database connectedwith[ OK ]in bold green. - Verify that
cargo run -q | catshows no escape codes, but a real terminal shows green.
use anstream::println;use owo_colors::OwoColorize;
fn status(message: &str) { // TODO: print a bold-green "[ OK ]" prefix, then the message}
fn main() { status("Database connected");}Solution
use anstream::println;use owo_colors::OwoColorize;
fn status(message: &str) { // Style freely; anstream's println! strips codes when not a TTY / NO_COLOR set. println!("{} {message}", "[ OK ]".green().bold());}
fn main() { status("Database connected");}Piped, this prints exactly [ OK ] Database connected with no escape bytes; on a terminal the prefix is bold green.
Exercise 2: Honor --color=<auto|always|never>
Section titled “Exercise 2: Honor --color=<auto|always|never>”Difficulty: Medium
Objective: Add a color policy flag that overrides auto-detection, mirroring Cargo and ripgrep.
Instructions:
- Read the first CLI argument. Map
--color=always,--color=never, and anything else (auto) to a decision. - Use
owo_colors::set_override(..)foralways/never; leave it untouched for auto. Enable thesupports-colorsfeature. - Print a line styled via
if_supports_colorand confirm:--color=alwayscolors even when piped;--color=neveris plain even on a TTY; with no flag, it follows the terminal andNO_COLOR.
use owo_colors::{OwoColorize, Stream};
fn main() { // TODO: parse args[1], set the override, then print a conditional-styled line}Solution
// Cargo.toml:// owo-colors = { version = "4.3.0", features = ["supports-colors"] }use owo_colors::{OwoColorize, Stream, Style};
fn main() { match std::env::args().nth(1).as_deref() { Some("--color=always") => owo_colors::set_override(true), Some("--color=never") => owo_colors::set_override(false), _ => {} // auto: if_supports_color does TTY + NO_COLOR detection }
// Build the combined style up front so the closure can apply it in one call. // (Chaining two methods inside the closure, like `|t| t.green().bold()`, // fails to compile — the second method would borrow a temporary.) let style = Style::new().green().bold(); println!( "{}", "build finished".if_supports_color(Stream::Stdout, |t| t.style(style)) );}set_override(true) forces the closure to run (color on) regardless of TTY/NO_COLOR; set_override(false) forces it off; with neither, if_supports_color consults the stream and the NO_COLOR/CLICOLOR environment.
Exercise 3: A padded, colorized two-column report
Section titled “Exercise 3: A padded, colorized two-column report”Difficulty: Hard
Objective: Print an aligned two-column table (label left, value right) with colored labels — without the alignment breaking on the escape bytes.
Instructions:
- Given pairs like
("Status", "online")and("Latency", "12ms"), print each aslabelleft-padded to 12 columns, then the value. - Color labels cyan and the
"online"value green. The columns must line up visually. - The catch: you must pad the plain label to width before styling it (recall Pitfall 3). Verify alignment by piping to
cat(codes stripped) — columns should still line up.
use anstream::println;use owo_colors::OwoColorize;
fn row(label: &str, value: &str) { // TODO: left-pad `label` to 12 columns of PLAIN text, then color it, // then print the value (green if "online", else default)}
fn main() { row("Status", "online"); row("Latency", "12ms"); row("Region", "us-east-1");}Solution
use anstream::println;use owo_colors::OwoColorize;
fn row(label: &str, value: &str) { // 1) Size the PLAIN text first so the width counts visible columns only. let padded = format!("{label:<12}"); // 2) Style the already-padded label. let styled_label = padded.cyan(); // 3) Conditionally color the value. if value == "online" { println!("{styled_label}{}", value.green().bold()); } else { println!("{styled_label}{value}"); }}
fn main() { row("Status", "online"); row("Latency", "12ms"); row("Region", "us-east-1");}Piped output (codes stripped by anstream) lines up correctly:
Status onlineLatency 12msRegion us-east-1On a terminal the labels are cyan and online is bold green, with the same alignment. Had we written format!("{:<12}", "Status".cyan()), the lazy owo-colors wrapper would actually still align (it forwards the spec) — but the moment you pre-render with .to_string(), alignment breaks. Padding the plain text first is the reliable habit.