Building Progress Bars in Bash From Scratch
AI generated
$_
#!/
Bash · Progress Bar · CLI UX · Linux
Building Progress Bars in Bash From Scratch
no external library, just built-in tools

A progress bar makes a long batch script visible instead of feeling frozen. This article shows how a progress bar emerges with pure Bash, from a simple percent display through carriage return and ANSI colors to dynamic terminal width and ETA estimation, with no extra package at all.

18 min read carriage return · ANSI · tput · ETA Bash 4.x · 5.x · Linux · macOS

1. Why a custom progress bar is worth the effort

A batch script that runs for minutes without output feels frozen to the user, even if everything is proceeding as planned in the background. A progress bar solves exactly this perception problem by continuously showing how much work is already done and how much remains. For scripts that process hundreds or thousands of files, for example during backups, migrations or batch conversions, this is not a cosmetic detail but a tangible trust factor.

External tools like pv or libraries in other languages provide ready-made progress bars, but are not installed everywhere and bring additional dependencies. A self built progress bar in pure Bash, on the other hand, runs on any system that has Bash itself, without a single extra package. This article shows step by step how a simple percent display grows into a full featured progress bar with ETA estimation and dynamic width.

2. Core principle: carriage return instead of new lines

The technical foundation of every progress bar in the shell is the carriage return character \r. Unlike \n, which moves the cursor one line down, \r resets the cursor to the beginning of the current line without starting a new one. If the next output is written right after, it completely overwrites the previous line instead of appearing below it. This exact effect creates the illusion of an updating display.

For this to work, echo or printf must be called without a trailing newline, so with printf and no \n at the end, or with echo -n. A common beginner mistake is not fully overwriting the old line when the new output is shorter than the previous one. Remnants of the old line then stay visible. The fix is to always pad the line to a fixed width or to explicitly clear it with spaces before the new output.


#!/usr/bin/env bash
set -euo pipefail

# Carriage return demo — \r resets cursor to line start, no newline
for i in {1..5}; do
  printf "\rStep %d of 5..." "$i"
  sleep 0.5
done
printf "\n"

3. The first simple progress bar

From the carriage return principle, a first working progress bar can be built with just a few lines. The bar itself consists of a fixed number of characters, typically hashes or blocks, where the number of filled characters grows proportionally to the current progress. The remaining characters up to the total width stay empty or are shown with a different character such as a dot, to visually separate the unfinished part.

The core function takes two parameters: the current position and the total number of steps. From these two values it computes both the percentage and the number of characters to fill. It is important to encapsulate this calculation as its own reusable function instead of scattering the logic directly inside the processing loop. That makes the progress bar easy to transfer to other scripts later.


#!/usr/bin/env bash
set -euo pipefail

draw_progress_bar() {
  local current=$1
  local total=$2
  local width=40
  local percent=$(( current * 100 / total ))
  local filled=$(( current * width / total ))
  local empty=$(( width - filled ))

  local bar
  bar=$(printf '%*s' "$filled" '' | tr ' ' '#')
  bar+=$(printf '%*s' "$empty" '' | tr ' ' '-')

  printf "\r[%s] %3d%% (%d/%d)" "$bar" "$percent" "$current" "$total"
}

total_files=25
for ((i = 1; i <= total_files; i++)); do
  draw_progress_bar "$i" "$total_files"
  sleep 0.1
done
printf "\n"

4. Percent calculation without external tools

Bash only supports integer arithmetic, which can lead to rounding issues when calculating percentages for a progress bar. The division current * 100 / total always rounds down, because fractional parts are discarded in integer division. For the pure percent display this is usually harmless, but when computing bar length it can cause the bar to appear not quite full at 100 percent if rounding errors accumulate.

A robust solution explicitly rounds the last iteration up to the full width once current equals total, instead of relying on the formula. Anyone who needs actual decimal places, for example a display with one decimal digit, has to fall back to awk or bc, since Bash itself has no floating point arithmetic. For most progress bars, though, simple integer calculation is entirely sufficient as long as the edge case of the final step is handled explicitly.


#!/usr/bin/env bash
set -euo pipefail

calculate_percent() {
  local current=$1
  local total=$2

  # Guard against division by zero
  if (( total == 0 )); then
    echo 0
    return
  fi

  # Force exactly 100% on the final iteration, avoiding rounding artifacts
  if (( current >= total )); then
    echo 100
    return
  fi

  echo $(( current * 100 / total ))
}

echo "$(calculate_percent 33 100)%"   # 33%
echo "$(calculate_percent 100 100)%"  # 100% — no rounding artifact

5. Dynamic terminal width with tput

A hard-coded bar of 40 characters looks narrow on a wide terminal and may wrap on a narrow one, which breaks the carriage return trick. The command tput cols returns the current terminal width in columns and lets you adjust the bar width dynamically. A progress bar that reacts to terminal width stays readable even if the window is resized.

It is important to subtract space for the percent display, brackets and any additional text from the detected width, instead of using the full terminal width for the bar itself. If the script is not running in a real terminal, for example redirected into a log file, tput cols often returns an empty or wrong value. That is why a sensible default should always be defined as a fallback in case detection fails.


#!/usr/bin/env bash
set -euo pipefail

get_bar_width() {
  local reserved=20  # space for percent, brackets and counter text
  local cols

  cols=$(tput cols 2>/dev/null) || cols=80
  [[ -z "$cols" ]] && cols=80

  local width=$(( cols - reserved ))
  (( width < 10 )) && width=10  # never go below a usable minimum

  echo "$width"
}

bar_width=$(get_bar_width)
echo "Bar width for this terminal: $bar_width characters"

6. ETA estimation: calculating remaining time

A pure percent display does not answer the question users care about most in practice: how much longer will it take? An ETA estimate, that is the estimated remaining time, can be projected from the time elapsed so far and the progress made so far. The formula is simple: elapsed time divided by the number of steps already completed gives the average time per step, multiplied by the remaining steps gives the estimated remaining time.

This estimate is only an approximation and becomes inaccurate when processing speed fluctuates strongly, for example when early files are small and later files are large. For most practical use cases, where processing time per element is relatively constant, the simple linear projection still delivers a useful reference value. The start time is captured once before the loop with $(date +%s) as a Unix timestamp and compared to the current time on every iteration.


#!/usr/bin/env bash
set -euo pipefail

start_time=$(date +%s)
total=20

for ((i = 1; i <= total; i++)); do
  sleep 0.2  # simulate work

  now=$(date +%s)
  elapsed=$(( now - start_time ))

  if (( i > 0 && elapsed > 0 )); then
    remaining=$(( elapsed * (total - i) / i ))
  else
    remaining=0
  fi

  printf "\r[%d/%d] Elapsed: %ds, ETA: ~%ds  " "$i" "$total" "$elapsed" "$remaining"
done
printf "\n"

7. Adding colors and visual states

A monochrome progress bar can easily be extended with ANSI escape codes to carry additional information. Green for ongoing progress, yellow past a certain error rate and red for critical states are a proven pattern that shows the user at a glance whether a run is proceeding normally. The codes themselves are short escape sequences like \e[32m for green, followed by \e[0m to reset formatting.

It is important not to hard-wire color codes but to encapsulate them in variables and make their use dependent on the availability of a terminal. If a script runs in an environment without terminal support, for example a CI pipeline with plain log output, escape codes should be disabled, otherwise they show up as cryptic character sequences in the log instead of rendering correctly.

8. Edge cases: pipes, log files and interrupt handling with trap

A progress bar that works with carriage return behaves differently in a log file than in a terminal: instead of an updating line, a long chain of \r characters results, which many editors do not render cleanly. The robust approach checks with [[ -t 1 ]] whether stdout is connected to a real terminal, and switches to simpler, line-by-line output when redirected to a file, for example only every ten percent.

A second important aspect is behavior on interruption. If the user aborts the script with Ctrl-C in the middle of the bar display, the cursor may end up stuck mid-line, and the next terminal output appears in an awkward position. A trap on INT and TERM that prints a final newline before exiting ensures that the terminal is left in a clean state, regardless of when the interruption happens.


#!/usr/bin/env bash
set -euo pipefail

# Ensure a clean terminal state even if the user interrupts mid-bar
trap 'printf "\n"; echo "Aborted." >&2; exit 130' INT TERM

is_tty=0
[[ -t 1 ]] && is_tty=1

for ((i = 1; i <= 50; i++)); do
  if (( is_tty )); then
    printf "\r[%3d%%]" "$i"
  elif (( i % 10 == 0 )); then
    echo "Progress: ${i}%"
  fi
  sleep 0.05
done
(( is_tty )) && printf "\n"

9. Custom build compared to extra tools

A self built progress bar is not always the best choice. For simple file transfers, pv offers a mature, ready-to-use solution that additionally shows throughput in megabytes per second. Building your own pays off especially where progress logic is tightly interwoven with your own processing logic and an external tool cannot be integrated without detours.

Approach Dependency Flexibility Best use case
Custom build (printf + \r) None Fully customizable Custom batch processing with ETA and colors
pv External (package manager) Only for data streams File transfers, pipes with throughput display
gum spin External (install separately) Only indeterminate progress Single command without a known endpoint
Simple echo per step None Low, confusing with many lines Very short scripts with few steps

For most internal maintenance scripts, building it yourself is the most pragmatic solution, because it requires no extra installation and can be adapted exactly to your own processing logic. As soon as pure data streams like file copies are the focus, though, pv is the faster and more robust alternative, because it already comes with throughput and time estimation built in.

Mironsoft

Shell automation and CLI tooling for development teams

Long batch scripts that feel frozen?

We build robust progress indicators with ETA estimation and dynamic terminal width directly into your Bash scripts, with no extra dependencies and clean behavior in log files.

Progress indicators

Building progress bars with ETA and dynamic width into batch scripts

Log-friendly behavior

Output that stays readable even when redirected into log files

Batch processing

Robust processing scripts for large volumes of files and records

10. Summary

A progress bar in pure Bash is based on a single technical principle: the carriage return character \r, which resets the cursor to the start of the line and thereby creates an updating display. Building on this foundation, a full featured tool emerges step by step, with percent calculation via integer arithmetic, dynamic width via tput cols, and an ETA estimate from elapsed time and progress made so far.

What matters for production use is the edge cases: only use color when a real terminal is present, switch to simpler line-by-line output when redirected to log files, and use trap to ensure an interruption in the middle of the display does not leave the terminal in a messy state. Anyone who accounts for these details gets a progress bar that behaves just as reliably in any environment as a mature external library, with no extra dependency at all.

Progress bars in Bash — The essentials at a glance

Core principle

printf "\r..." without a trailing newline overwrites the current line on every call.

Dynamic width

tput cols with a fallback to 80, subtracting reserved space for percent display and brackets.

ETA estimation

Elapsed time divided by completed steps, multiplied by remaining steps, as a linear approximation.

Robustness

[[ -t 1 ]] for terminal detection, trap for clean behavior on Ctrl-C interruption.

11. FAQ: Progress Bars in Bash

1How does the bar work technically?
Via \r, which resets the cursor to the line start. The next output overwrites the previous line.
2Remnants of old line visible?
New output too short. Pad to a fixed width or clear with spaces beforehand.
3Percentages without floats?
current * 100 / total rounds down. Force 100 percent explicitly for the final step to avoid artifacts.
4Adjusting to terminal width?
Read tput cols, subtract reserved space, fall back to 80 when no terminal is present.
5Calculating an ETA?
Elapsed time divided by completed steps, times remaining steps. Linear approximation.
6Output redirected to log file?
Check [[ -t 1 ]], switch to simple line output instead of carriage return when redirected.
7Adding colors?
ANSI codes like \e[32m, reset with \e[0m. Only use when a terminal is detected.
8Interrupt mid-bar?
trap on INT and TERM with a final newline keeps the terminal clean.
9pv instead of custom build?
For pure data stream operations yes, thanks to built-in throughput. Otherwise custom build is more flexible.
10Floating point arithmetic needed?
Usually not, integer arithmetic is enough. Only decimal places require awk or bc.