I wanted a pretty progress bar for my scripts without pulling in a big library. So I built one from scratch. Here's everything I learned about ANSI escape codes, ETA math, terminal tricks, and threading — all in ~300 lines.
The full source is flashbar on GitHub. You can read the entire thing in one coffee break.
The core trick: carriage return
A progress bar isn't magic. It's just one line that keeps rewriting itself. The secret is \r — carriage return. It moves the cursor back to the start of the line without creating a new one:
import sys
import time
for i in range(101):
sys.stderr.write(f"\r{i}%")
sys.stderr.flush()
time.sleep(0.02)
Run this and you'll see "0%" turn into "1%", then "2%", all on the same line. That's the entire foundation of every terminal progress bar.
Two details matter here:
Why stderr? Because stdout is often piped to files or other programs. If your script does python myscript.py > output.txt, you don't want progress bars in the file. stderr stays on screen.
Why flush()? Python buffers stderr output. Without flush(), your updates might batch together and the bar won't look smooth.
Drawing the bar
Now let's make it look like an actual bar. We need filled and empty characters:
def draw_bar(current, total, width=40):
pct = current / total
filled = int(width * pct)
bar = "█" * filled + "░" * (width - filled)
return f"[{bar}] {int(pct * 100):3d}%"
█ (U+2588) is a full block, ░ (U+2591) is a light shade. Most modern terminals render these fine. The :3d format keeps the percentage right-aligned so "1%" and "100%" don't make the line jump around.
Adding color with ANSI escape codes
Terminals understand special sequences that change text color. They all start with \033[ (the escape character) and end with m:
BLUE = "\033[94m"
GREEN = "\033[92m"
RED = "\033[91m"
RESET = "\033[0m"
# Usage
print(f"{BLUE}This is blue{RESET} and this is normal")
The numbers map to colors: 91=red, 92=green, 93=yellow, 94=blue, 95=magenta, 96=cyan, 97=white. These are "bright" variants — the basic ones (31-37) are dimmer.
For hex colors like #FF5733, terminals support 24-bit color:
def hex_to_ansi(hex_color):
r = int(hex_color[1:3], 16)
g = int(hex_color[3:5], 16)
b = int(hex_color[5:7], 16)
return f"\033[38;2;{r};{g};{b}m"
38;2;R;G;B sets the foreground color to any RGB value. Not every terminal supports this, but most modern ones (Windows Terminal, iTerm2, GNOME Terminal) do.
In flashbar, I wrapped this into a resolve_color() function that handles both named colors and hex:
def resolve_color(color):
if color in NAMED_COLORS:
return NAMED_COLORS[color]
if color.startswith("#") and len(color) == 7:
r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
return f"\033[38;2;{r};{g};{b}m"
return NAMED_COLORS["blue"] # safe fallback
No crash on bad input — just falls back to blue.
Calculating ETA
ETA is surprisingly simple math. If you've done 40 out of 100 items in 8 seconds:
- Speed: 40 items / 8 seconds = 5 items/sec
- Remaining: 100 - 40 = 60 items
- ETA: 60 / 5 = 12 seconds
In code:
elapsed = time.monotonic() - start_time
if current > 0:
eta = (elapsed / current) * (total - current)
Why time.monotonic() instead of time.time()? Because time.time() can jump backward if the system clock adjusts (NTP sync, daylight saving, manual change). time.monotonic() only goes forward, so your ETA will never go negative from a clock adjustment.
Formatting the result:
def _format_time(seconds):
if seconds < 0:
return "--:--"
seconds = int(seconds)
if seconds < 3600:
return f"{seconds // 60:02d}:{seconds % 60:02d}"
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h}:{m:02d}:{s:02d}"
The theme system
Instead of hardcoding characters, I used a dict of themes:
THEMES = {
"default": {"fill": "█", "empty": "░", "color": "\033[94m"},
"retro": {"fill": "#", "empty": ".", "color": "\033[93m"},
"dots": {"fill": "●", "empty": "○", "color": "\033[95m"},
"arrow": {"fill": "▸", "empty": "▹", "color": "\033[94m"},
# ... more themes
}
Then the Bar class picks from this dict and allows overrides:
base = THEMES.get(theme, THEMES["default"])
self.color = resolve_color(color) if color else base["color"]
self.fill = fill or base["fill"]
self.empty = empty or base["empty"]
This means you can use a theme as a starting point and override just the color, or just the characters. Simple but flexible.
Terminal width: don't wrap
If your bar is wider than the terminal, it wraps to the next line and everything breaks. The fix:
import shutil
term_w = shutil.get_terminal_size().columns
Then truncate your output if it's too wide:
import re
visible = re.sub(r"\033\[[0-9;]*m", "", line) # strip ANSI codes
if len(visible) > term_w:
line = line[:term_w]
The regex strips ANSI codes before measuring, because \033[94m is 5 characters but displays as zero width. You need the visible length, not the raw string length.
Also, \033[K at the end of the line clears everything to the right — so if your new line is shorter than the previous one, you won't see leftover characters:
sys.stderr.write(f"\r{line}\033[K")
Building a spinner with threading
A spinner is different from a progress bar — it animates continuously while your code does something else. That means it needs to run in a separate thread:
import threading
class Spinner:
def __init__(self, label, style="dots"):
self.label = label
self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]
self._running = False
self._thread = None
def start(self):
self._running = True
self._thread = threading.Thread(target=self._animate, daemon=True)
self._thread.start()
def stop(self, final_text=None):
self._running = False
self._thread.join()
sys.stderr.write(f"\r\033[K") # clear line
if final_text:
sys.stderr.write(f"{final_text}\n")
def _animate(self):
idx = 0
while self._running:
frame = self.frames[idx % len(self.frames)]
sys.stderr.write(f"\r{frame} {self.label}")
sys.stderr.flush()
idx += 1
time.sleep(0.08)
Key decisions:
daemon=True — if your main program crashes or exits, the spinner thread dies automatically. Without this, the thread would keep your process alive.
self._running as a simple bool — technically, reading a bool from one thread while another writes it is a race condition. In CPython, the GIL makes this safe in practice, but a threading.Event would be more correct.
Adding a context manager makes it clean to use:
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, *_):
if exc_type is None:
self.stop(f"✓ {self.label}")
else:
self.stop(f"✗ {self.label} (failed)")
return False
Now you get ✓ on success and ✗ on exception — automatically.
The track() wrapper
The most popular feature is the simplest. track() wraps any iterable with a progress bar:
def track(iterable, label="Progress", total=None, **kwargs):
if total is None:
if hasattr(iterable, "__len__"):
total = len(iterable)
else:
raise TypeError(
"iterable has no len(). Pass total= explicitly."
)
bar = Bar(total, label=label, **kwargs)
for item in iterable:
yield item
bar.update()
It's a generator — yield item gives each element to the caller, then bar.update() advances the bar. For generators that don't have __len__, you pass total= manually.
The entire function is 12 lines, but it's the one people use 90% of the time.
What I'd do differently
TTY detection. Right now, flashbar always outputs ANSI codes. If someone pipes the output (python script.py 2> log.txt), the log file gets filled with escape characters. The fix is sys.stderr.isatty() — check if we're in an actual terminal, and disable colors/animation if not.
Thread safety in the spinner. The bool flag works, but threading.Event is the proper way. It's atomic and doesn't rely on CPython's GIL.
Time formatting for short tasks. int(0.8) becomes 0, so quick tasks show 00:00 for ETA. Should show 0.8s instead.
The full picture
Here's how it all fits together:
-
bar.py(~170 lines):Barclass,track()generator, themes, colors, time formatting -
spinner.py(~60 lines):Spinnerclass with threading -
__init__.py(~10 lines): exports
That's it. No metaclasses, no abstract base classes, no plugin systems. Just functions, one class per file, and standard library only.
The source is at github.com/k38f/flashbar. Install with pip install flashbar. Read the whole thing in one sitting — I promise it won't take long.
Top comments (0)