When silence kills trust
You are building a tool that processes a directory of images. It takes forty seconds. The user runs the command, stares at a blank terminal, taps the escape key, and assumes the process died. They kill it. You just lost a user because the terminal didn't speak back.
Progress bars fix that. They turn "is this thing working?" into "yes, and here's how far along we are." They give the user a reason to wait. In CLI tools, feedback is not a luxury. It is the difference between a tool that feels responsive and a tool that feels broken.
Feedback prevents panic. Add the bar.
How progress bars fight the terminal
Terminals are designed to scroll. Every print statement pushes the view down. A progress bar stays on one line, overwriting itself as data changes. It uses escape sequences to move the cursor back, erase the line, and write the new state.
indicatif handles all that cursor gymnastics. You don't manage ANSI codes manually. You tell the library the total work and how much you have done. The library calculates the width of the terminal, draws the initial bar, and updates the frame on every change. It buffers the output so the redraw happens instantly.
Think of a car dashboard versus a scrolling log. The log records every event. The dashboard shows the current speed. A progress bar is the speedometer. It shows the state right now, not the history of every step.
The library handles the cursor. You handle the logic.
The basics
Start with the indicatif crate. Add it to your dependencies.
[dependencies]
indicatif = "0.17"
Create a ProgressBar, loop over your work, increment the counter, and finish.
use indicatif::ProgressBar;
fn main() {
// Create a bar for 100 units of work.
let pb = ProgressBar::new(100);
// Simulate work.
for i in 0..100 {
// Increment the counter by one.
pb.inc(1);
// Sleep to make the progress visible.
std::thread::sleep(std::time::Duration::from_millis(50));
}
// Mark the bar as finished to print a newline and clear the line.
pb.finish();
}
When you call ProgressBar::new(100), the library draws the bar with the length set to one hundred. Each call to pb.inc(1) updates the internal counter and redraws the line. The redraw uses a carriage return to overwrite the previous frame. When pb.finish() runs, the library prints a newline so the cursor moves to the next line. This prevents your next output from smashing into the bar.
Convention aside: always call finish() or finish_with_message(). If you drop the ProgressBar without finishing, the line stays on the screen and the cursor remains stuck at the end of the bar. The user sees a half-finished bar and thinks the tool crashed.
Call finish(). Always call finish().
Real-world usage
Real tools process files, fetch data, or run computations. You often want to show the current item and a custom style. indicatif lets you customize the template and update messages dynamically.
use indicatif::{ProgressBar, ProgressStyle};
use std::fs;
use std::path::Path;
/// Process files in a directory with a progress bar.
fn process_directory(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// Collect files first to know the total count.
let files: Vec<_> = fs::read_dir(path)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.collect();
// Create the bar with the exact file count.
let pb = ProgressBar::new(files.len() as u64);
// Customize the template to show a spinner, elapsed time, bar, and message.
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")?
.progress_chars("#>-"));
for entry in files {
// Set the message to the current filename.
pb.set_message(entry.file_name().to_string_lossy());
// Simulate processing work.
std::thread::sleep(std::time::Duration::from_millis(100));
// Advance the progress.
pb.inc(1);
}
// Finish with a success message.
pb.finish_with_message("All files processed.");
Ok(())
}
The template string controls what appears on the line. {spinner} adds a rotating animation. {bar} draws the filled and empty segments. {pos}/{len} shows the numeric count. {msg} displays the dynamic message. The progress_chars method sets the characters for the filled bar, the current position, and the empty space.
Convention aside: use set_message for dynamic information like filenames. Use set_prefix for static labels that don't change. The community reserves set_message for the part of the line that updates every iteration.
Customize the template. Users trust tools that look polished.
Pitfalls and patterns
Progress bars introduce state. That state can clash with Rust's ownership rules or terminal behavior.
Threading requires sharing
ProgressBar holds mutable state. If you spawn threads, you cannot share the bar directly. The compiler rejects this with E0502 (cannot borrow as mutable because it is also borrowed as immutable) or E0382 (use of moved value) depending on how you structure the closure. You need Arc<ProgressBar> to share the bar across threads.
use std::sync::Arc;
use std::thread;
use indicatif::ProgressBar;
fn main() {
// Wrap the bar in an Arc for shared ownership.
let pb = Arc::new(ProgressBar::new(100));
let mut handles = vec![];
for _ in 0..4 {
// Clone the Arc, not the bar.
let pb_clone = Arc::clone(&pb);
let handle = thread::spawn(move || {
for _ in 0..25 {
// Increment through the Arc.
pb_clone.inc(1);
thread::sleep(std::time::Duration::from_millis(10));
}
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
pb.finish();
}
Convention aside: use Arc::clone(&pb) instead of pb.clone(). Both compile and both work. The explicit form signals to readers that you are cloning the reference count, not deep-copying the bar. pb.clone() looks like it might duplicate the state, which is confusing.
Piping breaks the display
When you pipe output to a file, the terminal detection fails. The bar might still try to draw, corrupting the file with escape sequences. indicatif provides ProgressBar::hidden() to create a bar that does nothing when the output is not a TTY. You can also check is_tty manually.
use indicatif::ProgressBar;
fn main() {
// Create a hidden bar if stdout is not a terminal.
let pb = ProgressBar::hidden();
// Or use ProgressBar::new(100) and check pb.is_hidden() later.
}
Check is_tty before drawing. A progress bar in a log file is noise.
Choosing the right approach
Pick the constructor and wrapper that matches your workload.
Use ProgressBar::new(total) when you know the exact count of items upfront. This gives the user a percentage and a filled bar.
Use ProgressBar::new(0) when the total is unknown, and call set_length once the count becomes available. The bar shows pos/??? until you set the length.
Use Arc<ProgressBar> when multiple threads need to update the same bar. The Arc provides thread-safe shared ownership.
Use ProgressBar::hidden() when you need to support piping to files without breaking the output stream. The bar becomes a no-op when the output is not a terminal.
Use MultiProgress when you have independent tasks running in parallel, each needing its own bar. MultiProgress manages multiple bars on separate lines.
Match the bar to the workload. The interface should reflect the work.