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.
Table of Contents
- 1. Why a custom progress bar is worth the effort
- 2. Core principle: carriage return instead of new lines
- 3. The first simple progress bar
- 4. Percent calculation without external tools
- 5. Dynamic terminal width with tput
- 6. ETA estimation: calculating remaining time
- 7. Adding colors and visual states
- 8. Edge cases: pipes, log files and interrupt handling with trap
- 9. Custom build compared to extra tools
- 10. Summary
- 11. FAQ
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.