When the terminal goes silent
You run a Rust script that processes a thousand files. The terminal stays completely blank for forty seconds. Did it crash? Is it stuck on a network request? Or is it just working hard? You have no idea. Users hate uncertainty. A progress bar turns a black box into a predictable process. It tells the user exactly how far along the job is and gives a rough estimate of time remaining.
How terminals actually draw progress
Terminals are line-based devices. They print text from left to right, then drop down to the next line. A progress bar fights this default behavior. It keeps the cursor on the same line, overwrites the old content, and redraws the bar. Under the hood, this relies on ANSI escape codes. These are special character sequences that tell the terminal emulator to move the cursor, clear the line, or change colors.
The sequence usually starts with a carriage return (\r) to jump back to the beginning of the current line. Then comes an escape sequence like \033[K to clear everything from the cursor to the end of the line. The new bar string gets printed, and the cursor sits at the end, waiting for the next update. When the work finishes, a newline character pushes the cursor down so the next shell prompt does not overwrite your completed bar.
indicatif handles all the escape code math for you. You just tell it the total number of steps and how many you have completed. It calculates the percentage, draws the block characters, and manages the cursor position. The crate also detects whether your program is running in an interactive terminal. If you pipe the output to a file or run it in a CI pipeline, the bar automatically disables itself. You do not need to write extra logic to detect TTYs. Trust the default behavior.
The minimal setup
Add indicatif to your project. The crate is the standard choice in the Rust ecosystem. It handles cross-platform terminal quirks, falls back gracefully when output is redirected, and provides a clean API.
use indicatif::ProgressBar;
fn main() {
// Create a bar that expects 100 total steps
let pb = ProgressBar::new(100);
for i in 0..100 {
// Simulate work with a short sleep
std::thread::sleep(std::time::Duration::from_millis(10));
// Advance the bar by one discrete step
pb.inc(1);
}
// Mark the bar as complete and print a newline
pb.finish();
}
What happens under the hood
When ProgressBar::new(100) runs, the crate allocates a state object and writes an initial empty bar to standard error. Standard error is important here. By printing to stderr, the bar stays visible even if the user pipes your program's standard output to a file. The loop calls pb.inc(1). Each call updates the internal counter, calculates the new percentage, formats a string like [======> ] 45%, sends an escape sequence to move the cursor back to the start of the line, prints the new string, and returns the cursor to its original position.
The crate also tracks timestamps for every update. It uses those timestamps to calculate a moving average of how fast your work is completing. That average feeds into the ETA display. If your work speed fluctuates, the ETA will wobble. That is expected behavior. The moving average smooths out sudden spikes but cannot predict future slowdowns.
When pb.finish() runs, the crate prints a final newline so the next terminal prompt does not overwrite your completed bar. You can swap pb.inc(1) for pb.tick() when the work does not have a fixed step size. tick() advances the bar by a small fraction and is useful for continuous operations like downloading a stream or waiting for a server response. If you know the exact position at any moment, pb.set_position(current) skips the math entirely.
Keep the bar on stderr and let the moving average do the heavy lifting. Do not try to calculate ETA yourself.
Realistic workflow with messages and suspension
Real programs rarely just count to a hundred. They process items, handle errors, and need to communicate status. indicatif lets you attach messages, change the bar style, and handle completion states cleanly.
use indicatif::ProgressBar;
use std::fs;
use std::path::Path;
/// Process a list of files and report progress to the user
fn process_files(paths: &[&Path]) {
// Initialize with the exact number of items
let pb = ProgressBar::new(paths.len() as u64);
// Set a default label that appears next to the bar
pb.set_message("Processing assets");
for path in paths {
// Update the message to show the current file name
pb.set_message(format!("Working on {}", path.file_name().unwrap().to_string_lossy()));
// Simulate file reading and transformation
let _content = fs::read_to_string(path).unwrap_or_default();
// Advance progress by one completed item
pb.inc(1);
}
// Finish with a final status line and a clean newline
pb.finish_with_message("All files processed");
}
The community standard for set_message is to keep it under thirty characters. Long messages push the percentage and ETA off the right edge of narrow terminals. If you need to display dynamic data, truncate it or rotate through short labels. Always call finish() or finish_with_message(). Leaving a bar in a running state when the program exits leaves a half-drawn line and a misplaced cursor in the user's terminal.
Sometimes you need to print debug information or error messages while the bar is active. If you call println!() while the bar is drawing, the newline breaks the terminal's line buffer. The bar gets pushed down, the escape codes misalign, and you get a garbled mess of overlapping text. The fix is to use pb.suspend(). This method temporarily hides the bar, lets you write to stdout, and restores the bar when the closure finishes.
// Pause the bar, print to stdout, then restore it
pb.suspend(|| {
println!("Debug: encountered a large file, skipping optimization");
});
Treat suspend as a transaction for your terminal output. Keep the closure short and avoid spawning blocking I/O inside it.
Common traps and how to avoid them
The most common mistake is mixing standard output prints with an active progress bar. The terminal treats stdout and stderr as separate streams, but they share the same physical line buffer. A println!() to stdout inserts a newline that breaks the bar's overwrite cycle. Use eprintln!() for status messages, or wrap stdout prints in pb.suspend().
Another trap is updating the bar too fast. Terminals have a refresh rate. If you call inc() in a tight loop with no actual work, the bar will flicker or freeze because the terminal cannot redraw faster than sixty times per second. indicatif includes a built-in tick rate limiter, but it is still best practice to only update the bar when meaningful work has completed. Batching updates or adding a small sleep between iterations prevents terminal thrashing.
Thread safety is the third hurdle. ProgressBar is not designed to be mutated from multiple threads simultaneously. If you spawn a thread pool to process items, you cannot pass the ProgressBar directly to each worker. Wrap it in std::sync::Arc and call pb.inc(1) from each thread. The crate uses internal atomic counters to handle concurrent updates safely. If you try to mutate it without Arc, the compiler will reject you with E0382 (use of moved value) or borrow checker errors when you try to share ownership across thread boundaries.
use indicatif::ProgressBar;
use std::sync::Arc;
use std::thread;
fn main() {
// Share the progress bar across threads safely
let pb = Arc::new(ProgressBar::new(100));
let handles: Vec<_> = (0..4)
.map(|_| {
let pb_clone = Arc::clone(&pb);
// Spawn a worker that advances the shared bar
thread::spawn(move || {
for _ in 0..25 {
std::thread::sleep(std::time::Duration::from_millis(5));
// Atomic increment handles concurrent updates
pb_clone.inc(1);
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
pb.finish();
}
The Arc wrapper adds a tiny amount of overhead, but it is negligible compared to the actual work your threads are doing. If you need mutable state alongside the bar, wrap both in a Mutex or use Arc<Mutex<YourState>>. Do not try to bypass the borrow checker with raw pointers here. The atomic counters inside indicatif are already optimized for lock-free reads.
Picking the right bar for your workload
Use ProgressBar::new(total) when you know the exact number of steps before the work begins. Use ProgressBar::new_spinner() when the task has no fixed length and you just need to show that the program is alive. Use ProgressBar::hidden() when you want to conditionally disable the bar based on a flag or when output is redirected to a file. Use pb.inc(n) when each loop iteration represents a discrete, measurable unit of work. Use pb.tick() when the work is continuous or asynchronous and you just need to show activity. Use pb.set_position(pos) when you can calculate the exact progress at any moment, such as during a file download where you know the bytes received and total bytes. Use pb.suspend(|| { ... }) when you must write to standard output without breaking the terminal layout. Use Arc<ProgressBar> when multiple threads need to update the same bar concurrently.
Stick to the public API and let the crate handle terminal state. Do not write your own escape sequences unless you are building a terminal multiplexer.