DEV Community

k38f
k38f

Posted on

How I built a progress bar library in 300 lines of Python

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)
Enter fullscreen mode Exit fullscreen mode

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}%"
Enter fullscreen mode Exit fullscreen mode

(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")
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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): Bar class, track() generator, themes, colors, time formatting
  • spinner.py (~60 lines): Spinner class 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)