Terminal Output
Format human-facing CLI output with progress bars, spinners, and color while keeping stdout pipeable.
Recipe
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
fn run(items: &[String]) -> anyhow::Result<()> {
let bar = ProgressBar::new(items.len() as u64);
bar.set_style(ProgressStyle::with_template(
"{spinner:.green} [{bar:30}] {pos}/{len} {msg}",
)?);
for item in items {
bar.set_message(item.clone());
// work...
bar.inc(1);
}
bar.finish_with_message("done".green().to_string());
Ok(())
}When to reach for this: Long-running batch operations where users need feedback without polluting machine-readable stdout.
Working Example
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::time::Duration;
fn main() -> anyhow::Result<()> {
let multi = MultiProgress::new();
let overall = multi.add(ProgressBar::new(3));
overall.set_style(ProgressStyle::with_template(
"[{bar:40.cyan/blue}] {pos}/{len} batches",
)?);
for batch in 0..3 {
let sub = multi.add(ProgressBar::new(50));
sub.set_style(ProgressStyle::default_bar());
for _ in 0..50 {
sub.inc(1);
std::thread::sleep(Duration::from_millis(10));
}
sub.finish_and_clear();
overall.inc(1);
}
overall.finish_with_message("all batches complete");
Ok(())
}What this demonstrates:
MultiProgressrenders nested bars without garbled output- Progress writes to stderr by default
- Spinners suit unknown-duration tasks
finish_and_clearremoves completed bars cleanly
Deep Dive
stdout vs stderr
| Stream | Use for |
|---|---|
| stdout | Data users pipe to other commands |
| stderr | Logs, progress, warnings, errors |
Color Crates
| Crate | Notes |
|---|---|
owo-colors | Lightweight, respects NO_COLOR |
colored | Simple API, widely used |
console | Used by indicatif internally |
TTY Detection
use std::io::IsTerminal;
let use_color = std::io::stderr().is_terminal();Disable color and progress animations when output is not a terminal.
Gotchas
- Progress on stdout - Breaks
mytool | jq. Fix: Always attach progress to stderr. - Ignoring
NO_COLOR- Breaks CI and user preference. Fix: Check env var before styling. - Unicode spinner glitches - Some terminals render spinners poorly. Fix: Offer
--plainmode. - Log + progress collision - Tracing logs interleave with bars. Fix: Use
indicatif-log-bridgeor suspend bars while logging. - Stale terminal after panic - Progress bar may leave cursor hidden. Fix: Call
finish()in a guard orDropimpl.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Plain eprintln! | Scripts and CI-only tools | Interactive long jobs |
tracing subscriber | Server apps with structured logs | Simple one-shot CLIs |
ratatui full TUI | Interactive dashboards | Batch scripts in pipelines |
FAQs
indicatif vs console?
indicatif handles progress rendering. console provides lower-level terminal control. They complement each other.
How do I add ETA?
ProgressStyle templates support {eta} when total length is known.
Quiet mode?
Skip progress bar creation when --quiet is set or stderr is not a TTY.
Windows terminal support?
Modern Windows Terminal works well. Test on older cmd.exe if you support it.
JSON output mode?
When --json is active, suppress all styling and progress. Emit only JSON on stdout.
Tables in the terminal?
Use comfy-table or tabled for aligned column output on stdout.
Spinner for unknown totals?
Use ProgressBar::new_spinner() instead of a sized bar.
Concurrent progress bars?
MultiProgress coordinates drawing. One parent bar plus per-task children is the usual pattern.
Logging levels to stderr?
Map -v / -vv to tracing levels. Keep progress separate from log lines.
Testing terminal output?
Factor rendering behind functions and assert on strings in unit tests. Full TTY tests are rarely worth it.
Related
- CLI Basics - stdout/stderr conventions
- TUIs with ratatui - full-screen interfaces
- Error Reporting - stderr error formatting
- CLI Best Practices - UX rules
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.