The blinking cursor problem
You ship a command-line tool that processes a directory of images. A user runs it on a folder with five thousand files. The terminal prints a prompt, then goes completely silent. Thirty seconds pass. The user assumes the program hung, hits Ctrl-C, and runs it again. It finishes instantly. The tool was not broken. It was just quiet.
Silent programs erode trust. Users need to know work is happening, how far along it is, and whether they should wait or move on. A progress bar bridges that gap. It turns a black box into a predictable process. Go does not ship with a built-in progress bar, which means you pull in a third-party package. The ecosystem standard is github.com/schollz/progressbar/v3. It handles terminal detection, width calculation, and smooth rendering without blocking your main loop.
How progress bars actually work
Terminals are fundamentally simple. They accept a stream of bytes and render them left to right, top to bottom. When you print a newline, the cursor moves down. When you print a carriage return, the cursor jumps back to the start of the current line without moving down. A progress bar exploits that carriage return behavior. It prints a percentage and a block of characters, sends \r, then prints the updated percentage and blocks over the top. The terminal driver handles the visual update.
Modern terminals also understand ANSI escape codes. These are control sequences that change text color, clear lines, or move the cursor programmatically. The progress bar library translates your numeric updates into the exact byte sequence the terminal expects. It also throttles updates so you do not flood the terminal driver with hundreds of redraws per second. Smooth animation requires roughly fifteen updates per second. Anything faster just burns CPU cycles and makes the bar look jittery.
The library abstracts away the math and the escape codes. You give it a total count. It calculates the width, formats the percentage, and manages the refresh interval. You only track how much work you have finished.
Visual feedback does not speed up computation. It only prevents premature cancellation.
Your first progress bar
Here is the simplest way to render a bar that counts from zero to one hundred. The example uses a tight loop with a sleep to simulate work.
package main
import (
"time"
"github.com/schollz/progressbar/v3"
)
func main() {
// Total count sets the scale. The library calculates terminal width automatically.
bar := progressbar.NewOptions(100, progressbar.OptionSetDescription("Processing..."))
for i := 0; i < 100; i++ {
time.Sleep(50 * time.Millisecond) // simulate I/O wait
if err := bar.Add(1); err != nil {
// Terminal closed or write failed. Exit cleanly.
return
}
}
}
Run the program and watch the bar fill from left to right. The NewOptions call creates the renderer. The first argument sets the maximum value. The second argument is a functional option that sets the text label. The loop calls bar.Add(1) on each iteration. The library tracks the internal state, calculates the percentage, and writes the updated line to os.Stdout.
When the loop finishes, the bar stays on screen. You can call bar.Finish() to append a newline and leave the bar in place, or let the program exit and let the shell reclaim the prompt. The library also handles early termination. If the user presses Ctrl-C, the signal interrupts the program. You can catch the signal and call bar.Finish() to clean up the terminal state before exiting.
Progress bars are visual feedback. They do not speed up your code.
Making it behave in the real world
Real programs rarely count to one hundred in a tight loop. They process files, fetch network requests, or transform data streams. The work usually happens in a separate function or a batch processor. You need to keep the progress bar responsive while the actual work runs.
Here is a realistic pattern. It processes a slice of items, respects context cancellation, and follows Go error handling conventions. The worker function lives in its own block to keep the example readable.
// ProcessItems handles batch work with cancellation support.
func ProcessItems(ctx context.Context, items []string, bar *progressbar.ProgressBar) error {
for _, item := range items {
// Check cancellation before starting expensive work
select {
case <-ctx.Done():
return ctx.Err()
default:
}
time.Sleep(20 * time.Millisecond) // simulate network or disk I/O
if err := bar.Add(1); err != nil {
// Wrap the error to preserve the call stack
return fmt.Errorf("progress update failed: %w", err)
}
}
return nil
}
The ProcessItems function takes a context.Context as its first parameter. That is the standard Go convention for functions that may block or run for a long time. The context carries cancellation signals and deadlines. The function checks ctx.Done() before each iteration. If the context cancels, the loop exits immediately and returns the context error.
The if err != nil block after bar.Add follows Go's explicit error handling style. The boilerplate makes the failure path visible. You do not hide it behind a defer or a panic. Run gofmt on your files before committing. The tool enforces consistent indentation and import grouping. Most editors run it automatically on save, so you never argue about whitespace.
Here is the entry point that wires everything together.
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // ensure resources clean up on exit
items := make([]string, 50)
bar := progressbar.NewOptions(len(items), progressbar.OptionSetDescription("Syncing..."))
if err := ProcessItems(ctx, items, bar); err != nil {
bar.Finish() // flush the final state before exiting
fmt.Println("Stopped:", err)
return
}
bar.Finish()
}
The progress bar pointer passes through the call stack. The library is not thread-safe by default. If you call bar.Add from multiple goroutines simultaneously, the internal counter races and the output corrupts. Keep updates in a single goroutine, or wrap the calls in a mutex.
Context is plumbing. Run it through every long-lived call site.
Where things go sideways
Progress bars fail silently in environments that do not support them. CI/CD pipelines, SSH sessions without a pseudo-terminal, and redirected output streams all lack the interactive terminal driver. When you pipe stdout to a file, the carriage returns and ANSI codes end up in the file as literal text. The bar looks like garbage.
The library detects this automatically. progressbar.NewOptions checks os.Stdout for a TTY. If it finds none, it disables the visual bar and falls back to a simple text logger. You do not need to write your own detection logic. If you force the bar on a non-TTY stream, the output becomes unreadable.
Compiler errors usually stem from type mismatches or missing imports. If you forget to import the package, the compiler rejects the program with undefined: progressbar. If you pass a float to NewOptions instead of an integer, you get cannot use 100.5 (untyped float constant) as int value in argument. The type system catches these mistakes before runtime.
Race conditions are the most common runtime bug. Multiple goroutines calling bar.Add without synchronization will corrupt the internal state. The library documentation explicitly states that concurrent updates require external locking. Wrap the update call in a sync.Mutex if your workers run in parallel. Alternatively, send progress updates through a channel and have a single goroutine drive the bar.
Another subtle issue is terminal resizing. If the user drags the window narrower than the bar, the rendering wraps to the next line and breaks the layout. The library recalculates width on each update, but rapid resizing can still cause visual glitches. This is a terminal driver limitation, not a Go bug. You cannot fix it from user space.
The worst goroutine bug is the one that never logs.
Pick the right tool
Use progressbar/v3 when you need a standard, dependency-light bar for batch operations. Use a TUI framework like tview or termui when you need multiple widgets, interactive menus, or real-time dashboards. Use plain log lines when running in CI/CD or when users pipe output to other tools. Use a channel-driven updater when multiple goroutines produce work and you need thread-safe progress tracking. Use a simple counter with fmt.Printf when the operation finishes in under two seconds and a bar adds visual noise.