Building a Progress Bar in Python from Scratch (Without tqdm)

Build a progress bar in Python without tqdm, step by step: carriage return basics, ETA calculation, ANSI colors, and multi-bar support.

Why Build Your Own Progress Bar?

tqdm is the go-to library for progress bars in Python, but building one yourself has real advantages:

  • Zero external dependencies (no pip install needed)
  • You learn how terminal control actually works
  • Complete freedom to customize the display format

In this article, we will build a progress bar step by step, starting from carriage return basics and working up to ETA display, ANSI colors, and multi-bar support.

Carriage Return Basics

The core trick behind a progress bar is overwriting the same line in the terminal repeatedly. This is done with the carriage return character \r.

\r moves the cursor back to the beginning of the current line. By printing without a newline, we overwrite the previous output.

import time

for i in range(101):
    print(f"\r Processing... {i}%", end="", flush=True)
    time.sleep(0.05)
print()  # final newline

Three key points:

  • Place \r at the start of the string
  • Use end="" to suppress the newline
  • Use flush=True to force the buffer to flush immediately (without this, the display may not update)

A Basic Progress Bar

A plain percentage is boring. Let us draw an actual bar in the format [████████░░░░░░░░] 50% (50/100).

import sys
import time

def progress_bar(current, total, bar_length=30):
    fraction = current / total
    filled = int(bar_length * fraction)
    bar = "█" * filled + "░" * (bar_length - filled)
    percent = fraction * 100
    sys.stdout.write(f"\r[{bar}] {percent:5.1f}% ({current}/{total})")
    sys.stdout.flush()

# Usage
total = 100
for i in range(total + 1):
    progress_bar(i, total)
    time.sleep(0.03)
print()

Running this produces a live-updating display:

[█████████████████░░░░░░░░░░░░░] 56.0% (56/100)

We use sys.stdout.write instead of print because it does not add any extra newlines or spaces.

Adding ETA Calculation

For long-running tasks, knowing how much time is left makes a big difference. We can estimate the remaining time from the elapsed time and current progress.

import sys
import time

def format_time(seconds):
    """Format seconds as HH:MM:SS."""
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    return f"{h:02d}:{m:02d}:{s:02d}"

def progress_bar_eta(current, total, start_time, bar_length=30):
    fraction = current / total
    filled = int(bar_length * fraction)
    bar = "█" * filled + "░" * (bar_length - filled)
    percent = fraction * 100

    elapsed = time.time() - start_time
    if current > 0:
        rate = current / elapsed          # iterations per second
        remaining = (total - current) / rate
        eta_str = format_time(remaining)
        rate_str = f"{rate:.1f} it/s"
    else:
        eta_str = "--:--:--"
        rate_str = "-- it/s"

    sys.stdout.write(
        f"\r[{bar}] {percent:5.1f}% | ETA: {eta_str} | {rate_str}"
    )
    sys.stdout.flush()

# Usage
total = 200
start = time.time()
for i in range(total + 1):
    progress_bar_eta(i, total, start)
    time.sleep(0.02)
print()

Sample output:

[███████████████░░░░░░░░░░░░░░░] 50.0% | ETA: 00:00:02 | 49.8 it/s

When current == 0, we show a placeholder to avoid division by zero.

ANSI Color Support

Terminals support ANSI escape sequences for coloring text. Let us change the bar color based on progress.

Here are the main color codes:

CodeColor
\033[91mBright red
\033[93mBright yellow
\033[92mBright green
\033[0mReset
import os
import sys
import time

def get_color(fraction):
    """Return a color code based on progress (red -> yellow -> green)."""
    if fraction < 0.33:
        return "\033[91m"  # red
    elif fraction < 0.66:
        return "\033[93m"  # yellow
    else:
        return "\033[92m"  # green

RESET = "\033[0m"

def progress_bar_color(current, total, start_time):
    try:
        terminal_width = os.get_terminal_size().columns
    except OSError:
        terminal_width = 80

    # Estimate non-bar characters and compute bar length
    suffix = f" {current/total*100:5.1f}% | ETA: 00:00:00 | 000.0 it/s"
    bar_length = max(10, terminal_width - len(suffix) - 4)  # account for []

    fraction = current / total
    filled = int(bar_length * fraction)
    color = get_color(fraction)
    bar = color + "█" * filled + RESET + "░" * (bar_length - filled)

    elapsed = time.time() - start_time
    if current > 0:
        rate = current / elapsed
        remaining = (total - current) / rate
        eta_str = format_time(remaining)
        rate_str = f"{rate:.1f} it/s"
    else:
        eta_str = "--:--:--"
        rate_str = "-- it/s"

    percent = fraction * 100
    sys.stdout.write(f"\r[{bar}] {percent:5.1f}% | ETA: {eta_str} | {rate_str}")
    sys.stdout.flush()

# format_time is the same as in the previous section

total = 150
start = time.time()
for i in range(total + 1):
    progress_bar_color(i, total, start)
    time.sleep(0.02)
print()

os.get_terminal_size() returns the current terminal width, so the bar adapts dynamically. Resizing the window will not break the layout.

Multi-Bar Implementation

When running multiple tasks in parallel, you may want to stack progress bars vertically. This requires ANSI cursor movement:

  • \033[{n}A : move cursor up n lines
  • \033[{n}B : move cursor down n lines

The idea is simple: print multiple lines, then move the cursor back up and overwrite them.

import sys
import time
import random

def format_time(seconds):
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    return f"{h:02d}:{m:02d}:{s:02d}"

def get_color(fraction):
    if fraction < 0.33:
        return "\033[91m"
    elif fraction < 0.66:
        return "\033[93m"
    else:
        return "\033[92m"

RESET = "\033[0m"

def render_bar(label, current, total, start_time, bar_length=25):
    """Generate the string for a single progress bar."""
    fraction = current / total if total > 0 else 0
    filled = int(bar_length * fraction)
    color = get_color(fraction)
    bar = color + "█" * filled + RESET + "░" * (bar_length - filled)

    elapsed = time.time() - start_time
    if current > 0:
        rate = current / elapsed
        remaining = (total - current) / rate
        eta_str = format_time(remaining)
    else:
        eta_str = "--:--:--"

    percent = fraction * 100
    return f"{label}: [{bar}] {percent:5.1f}% ({current}/{total}) ETA: {eta_str}"

def multi_progress(tasks):
    """Display progress bars for multiple tasks."""
    n = len(tasks)
    start_times = [time.time() for _ in range(n)]
    progress = [0] * n
    totals = [t["total"] for t in tasks]
    labels = [t["label"] for t in tasks]

    # Initial render: reserve lines
    for i in range(n):
        print(render_bar(labels[i], 0, totals[i], start_times[i]))

    while any(progress[i] < totals[i] for i in range(n)):
        # Move cursor up n lines
        sys.stdout.write(f"\033[{n}A")

        for i in range(n):
            if progress[i] < totals[i]:
                step = random.randint(1, 3)
                progress[i] = min(progress[i] + step, totals[i])
            line = render_bar(labels[i], progress[i], totals[i], start_times[i])
            # Clear to end of line, then newline
            sys.stdout.write(f"\r{line}\033[K\n")

        sys.stdout.flush()
        time.sleep(0.1)

# Usage
tasks = [
    {"label": "Download ", "total": 100},
    {"label": "Extract  ", "total": 80},
    {"label": "Install  ", "total": 120},
]
multi_progress(tasks)

When running, the terminal displays something like this:

Multi-progress bar display example

\033[K clears from the cursor to the end of the line. This prevents leftover characters when the bar gets shorter than the previous render.

Putting It Together: ProgressBar Class

Here is a complete class that combines everything we have built.

import os
import sys
import time

class ProgressBar:
    def __init__(self, total, label="Progress", bar_length=None, color=True):
        self.total = total
        self.label = label
        self.color = color
        self.current = 0
        self.start_time = None

        if bar_length is None:
            try:
                self.bar_length = max(10, os.get_terminal_size().columns - 60)
            except OSError:
                self.bar_length = 30
        else:
            self.bar_length = bar_length

    def _get_color(self, fraction):
        if not self.color:
            return ""
        if fraction < 0.33:
            return "\033[91m"
        elif fraction < 0.66:
            return "\033[93m"
        return "\033[92m"

    def _format_time(self, seconds):
        h = int(seconds // 3600)
        m = int((seconds % 3600) // 60)
        s = int(seconds % 60)
        return f"{h:02d}:{m:02d}:{s:02d}"

    def update(self, n=1):
        if self.start_time is None:
            self.start_time = time.time()

        self.current = min(self.current + n, self.total)
        fraction = self.current / self.total
        filled = int(self.bar_length * fraction)
        color = self._get_color(fraction)
        reset = "\033[0m" if self.color else ""
        bar = color + "█" * filled + reset + "░" * (self.bar_length - filled)

        elapsed = time.time() - self.start_time
        if self.current > 0:
            rate = self.current / elapsed
            remaining = (self.total - self.current) / rate
            eta_str = self._format_time(remaining)
            rate_str = f"{rate:.1f} it/s"
        else:
            eta_str = "--:--:--"
            rate_str = "-- it/s"

        percent = fraction * 100
        line = f"\r{self.label}: [{bar}] {percent:5.1f}% | ETA: {eta_str} | {rate_str}"
        sys.stdout.write(line + "\033[K")
        sys.stdout.flush()

    def finish(self):
        self.update(0)
        print()

# Usage
bar = ProgressBar(total=100, label="Training")
for i in range(100):
    time.sleep(0.03)
    bar.update()
bar.finish()

Sample output:

Training: [██████████████████████████████] 100.0% | ETA: 00:00:00 | 32.8 it/s

This class is about 60 lines and covers the core functionality of tqdm. You can extend it further by adding a context manager (__enter__ / __exit__) or an iterable wrapper for even more convenience.

References