Python Progress Bars: tqdm.tqdm, tqdm.notebook, tqdm.auto, tqdm.pandas, and a From-Scratch Implementation

Python progress-bar libraries compared (tqdm vs rich.progress vs alive-progress vs from-scratch) with Python implementations. Uses tqdm.tqdm, tqdm.notebook, tqdm.auto, tqdm.pandas, tqdm.contrib.concurrent.process_map, and concurrent.futures.ProcessPoolExecutor to cover carriage-return basics, ETA computation, ANSI colors, multi-bar output, custom `bar_format`, nested bars (`leave=False` / `position`), `df.progress_apply`, parallel-processing progress bars, and long-running computations (Bayesian optimization, GA, SA, MCMC).

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.

Practical tqdm Variants: tqdm.tqdm, tqdm.notebook, tqdm.auto

Building your own bar is great for learning terminal control, but in production code reaching for tqdm is almost always the better call. tqdm ships several implementations tuned for different execution environments.

# Standard terminal implementation
from tqdm import tqdm
for x in tqdm(range(1000), desc="train"):
    ...

# Jupyter Notebook variant (HTML-based widget)
from tqdm.notebook import tqdm as tqdm_nb
for x in tqdm_nb(range(1000), desc="epoch"):
    ...

# Auto-detect: works whether you run in a CLI, Notebook, or IPython
from tqdm.auto import tqdm

If you are writing a library or shared script, prefer from tqdm.auto import tqdm. Under Jupyter it transparently delegates to tqdm.notebook; in a terminal it falls back to the plain implementation. No more “the bar renders as 50 carriage-return lines in my notebook” bug reports.

Custom Output with bar_format

The bar_format argument uses Python str.format syntax to give you full control over the display template. This is especially handy when you want to log structured training metrics inline.

from tqdm import tqdm

fmt = "{l_bar}{bar:30}{r_bar} | loss={postfix[0]:.4f}"
with tqdm(range(200), bar_format=fmt, postfix=[0.0]) as pbar:
    for step in pbar:
        loss = 1.0 / (step + 1)
        pbar.postfix[0] = loss
        pbar.update(0)  # refresh display without advancing

Useful placeholders:

PlaceholderMeaning
{l_bar}Description plus percentage
{bar:N}The bar body, width N
{r_bar}Count plus elapsed, ETA, and rate
{n_fmt} / {total_fmt}Current / total (auto K/M formatting)
{rate_fmt}“12.5it/s” or “800ms/it” depending on speed
{elapsed} / {remaining}Elapsed / remaining wall-clock time
{postfix}Whatever you set via set_postfix(loss=0.12, ...)

set_postfix is the idiomatic way to surface live training metrics next to the bar:

for x in (pbar := tqdm(range(N))):
    pbar.set_postfix(loss=f"{loss:.3f}", acc=f"{acc:.3f}")

Nested Bars with leave=False and position

For nested loops (outer epoch, inner batch) the canonical pattern combines position and leave=False.

from tqdm import tqdm

for epoch in tqdm(range(EPOCHS), desc="epoch", position=0):
    for batch in tqdm(range(N_BATCH), desc="batch",
                      position=1, leave=False):
        ...

leave=False on the inner bar means that line disappears once the inner loop finishes, keeping the screen tidy. If you spawn workers, share a lock via tqdm.set_lock(RLock()) and assign distinct position values so concurrent processes do not clobber each other’s rows.

tqdm.contrib.concurrent.process_map: A Direct Win for Parallel Work

Parallel computation is exactly where progress visibility matters most, which lines up with the other long-running workloads on this blog: Bayesian optimization, genetic algorithms, simulated annealing, and Monte Carlo simulations. tqdm.contrib.concurrent provides high-level wrappers around ProcessPoolExecutor and ThreadPoolExecutor with a built-in bar.

from tqdm.contrib.concurrent import process_map, thread_map

def heavy(seed):
    # one expensive trial (e.g., a Monte Carlo sample)
    ...
    return result

# CPU-bound: process pool
results = process_map(heavy, range(10_000), max_workers=8, chunksize=20)

# I/O-bound: thread pool
results = thread_map(fetch, urls, max_workers=32)

chunksize=1 gives fine-grained progress at the cost of per-task overhead. For ten-thousand-item workloads, chunksize=20 to 100 strikes a reasonable balance.

pandas Integration: df.progress_apply

A single call to tqdm.pandas() patches DataFrame.apply, groupby.apply, and Series.map to add progress_apply / progress_map siblings.

import pandas as pd
from tqdm import tqdm
tqdm.pandas(desc="feature")

df["price_log"] = df["price"].progress_apply(lambda x: math.log1p(x))

df.groupby("user_id").progress_apply(extract_features)

This single line removes the “is the kernel still alive?” anxiety from feature engineering scripts. I keep it in the default imports of every notebook.

tqdm vs rich.progress vs alive-progress

The rich ecosystem has produced credible alternatives in recent years. Here is a side-by-side comparison.

Aspecttqdmrich.progressalive-progress
Dependency footprintTiny (pure Python, no deps)Medium (whole rich package)Small
Multi-bar supportManual position managementNative via Progress contextSpinner-pair friendly
ColoringRaw ANSI, full controlStyle DSL ([bold red])Rich animations
Jupyter supporttqdm.notebook (HTML widget)rich.jupyter integrationLimited (CLI focused)
pandas integrationtqdm.pandas() one-linerNone (you must wrap manually)None
Parallel-work helperstqdm.contrib.concurrentManual Progress.add_taskNone
Coexisting with log outputTricky (interleaved lines)Smooth via Console.logAuxiliary print helper
Sweet spotML / data pipelines, default choiceBuilding polished TUIs and CLIsPersonal projects, playful CLI feedback

Rules of thumb:

  • Large ETL pipelines or ML training loops -> tqdm.auto + tqdm.pandas + process_map.
  • Distribution-grade CLI tools -> rich.progress for cohesive styling.
  • Hobby scripts where you want delight -> alive-progress.

Measuring the Real Overhead

Everything above was a feature comparison. Few people have actually measured how much slower a progress bar makes your code. Using time.perf_counter(), we ran a trivial 1,000,000-iteration loop (accumulating (i * i) % 97) with no progress bar, with tqdm, with rich.progress, and with alive-progress, and measured wall-clock time for each.

Test environment: Apple M1, Python 3.14.6, tqdm 4.69.0, rich 15.0.0, alive-progress 3.3.0. Output was written to a redirected plain file rather than an interactive terminal (the same “non-TTY” situation covered in Edge Case 2 below). Each configuration ran 5 times; we report the median. Timing benchmarks are inherently machine-dependent, so treat these as relative trends rather than universal constants.

import time

N = 1_000_000

def trivial_step(i, total):
    return total + (i * i) % 97

def baseline(n):
    total = 0
    for i in range(n):
        total = trivial_step(i, total)
    return total

t0 = time.perf_counter()
baseline(N)
t1 = time.perf_counter()
print(f"baseline: {t1 - t0:.3f}s")  # -> baseline: 0.065s

For tqdm we compared default settings (for i in tqdm(range(n))), a forced synchronous-refresh mode (mininterval=0, miniters=1, refreshing and flushing on every single call), and a manually throttled mode (calling pbar.update(1000) once every 1,000 iterations). For rich.progress we compared the default (refresh_per_second=10) against a near-per-iteration refresh rate (refresh_per_second=1000). alive-progress used its default settings (force_tty=True so the animation stays enabled even without an attached tty). As a reference point we also re-measured the hand-rolled DIY bar (sys.stdout.write + flush()) from earlier in this article under the same conditions.

ConfigurationTime for 1M iterationsvs. baseline
baseline (no progress bar)0.065 s
DIY bar: throttled (write+flush every 1000)0.083 s+27.8%
tqdm: manual throttle (update() every 1000)0.098 s+50.9%
tqdm: default settings0.135 s+108.2%
alive-progress: default0.713 s+997.6%
rich.progress: default (10 Hz)0.729 s+1022.0%
rich.progress: refresh_per_second=10000.733 s+1027.1%
DIY bar: naive (write+flush every iteration)2.157 s+3218.7%
tqdm: mininterval=0 (synchronous flush every iter)18.99 s+29116.0%

Progress-bar overhead comparison across 1,000,000 iterations (log scale)

A few findings stand out:

  1. tqdm’s default is remarkably cheap (+108%, about 0.07 microseconds/iteration). This is because tqdm internally skips a refresh unless at least mininterval (default 0.1s) has elapsed since the last one (dynamic_miniters).
  2. Setting mininterval=0 makes tqdm roughly 140x slower (0.135s -> 18.99s). This is the measured cost of the common “copy-pasted-from-a-forum” anti-pattern of setting mininterval=0 to “make the bar feel smoother.”
  3. rich.progress and alive-progress barely change when refresh_per_second goes from 10 to 1000 (0.729s -> 0.733s). This reflects an important architectural difference, explained below.
  4. Manual throttling (updating once per 1,000 iterations) recovers almost all of tqdm’s lost performance (+50.9%, about 0.033 microseconds/iteration).

Why Frequent Updates Are Slow: I/O, Flush Cost, and Architecture

The 140x slowdown from mininterval=0 comes from the fact that every single update() call now synchronously performs:

  • Recomputing elapsed time, rate, and ETA (string formatting inside format_meter)
  • Rebuilding the bar string (expanding {bar}, {postfix}, and other template placeholders)
  • Calling sys.stdout.write() (a system call)
  • Calling sys.stdout.flush() (forcing the buffer out to the OS immediately)

flush() is the most expensive of these. Ordinarily, print/write calls are buffered and batched before reaching the OS, but flush() forces a kernel write on every call, so you pay the full system-call overhead (including a context switch) a million times over. Indeed, the hand-rolled “flush on every iteration” DIY bar from earlier in this article – which does almost no string formatting – was already about 33x slower than baseline (2.157s). tqdm piles formatting cost on top of that, landing at roughly 292x slower.

By contrast, rich.progress and alive-progress barely reacted to the refresh_per_second change because they decouple “advance the progress state” from “repaint the screen” at the architecture level. Progress.update() merely increments an internal counter – cheap, in-process work – while the actual repaint is handled by a background thread on a fixed refresh_per_second cadence. So calling update() a million times only triggers, at most, roughly elapsed_seconds x refresh_per_second actual system-call-bearing redraws. tqdm, in contrast, synchronously decides “should I redraw right now?” inside the update() call itself, and if so, performs the write+flush on the spot. That means tqdm requires the caller to throttle explicitly, while rich/alive-progress get some protection from their design by default (though the pure per-call Python overhead of a million calls is still there, which is why both sit at roughly +1000% over baseline regardless of refresh rate).

Measuring how much throttling recovers. We swept tqdm’s update batch size (how many iterations pass between calls to update()) from 1 to 50,000 and measured total time for 1,000,000 iterations.

from tqdm import tqdm

def run(n, batch):
    total = 0
    with tqdm(total=n, mininterval=0, miniters=1) as pbar:
        count = 0
        for i in range(n):
            total += (i * i) % 97
            count += 1
            if count >= batch:
                pbar.update(count)
                count = 0
        if count:
            pbar.update(count)
    return total

tqdm update batch size vs. total wall-clock time (log-log)

At batch=1 (updating every iteration) the loop took 18.6 seconds. At batch=100 (still redrawing 10,000 times) it dropped to 0.24 seconds, at batch=1000 to 0.08 seconds, and from batch=5000 onward it becomes indistinguishable from baseline (0.065s). In practice, the default (mininterval=0.1) is usually good enough, but if you want both visual smoothness and speed, set miniters explicitly to something like total // 100, or relax mininterval to 0.1-0.5 seconds.

Edge Case 1: Progress Bars Across Multiple Processes

If you spawn several worker processes that each run their own tqdm instance without using a helper like tqdm.contrib.concurrent.process_map, naive usage breaks the display. The cause is simple: without an explicit position, every bar defaults to position=0 – the same terminal row – and they all fight over it.

import multiprocessing as mp
import time
from tqdm import tqdm

def worker_naive(worker_id, n=40):
    # BUG: every worker stays at the default position=0 -> they all
    # contend for the same row.
    for i in tqdm(range(n), desc=f"worker-{worker_id}", position=0, mininterval=0, miniters=1):
        time.sleep(0.005)

if __name__ == "__main__":
    procs = [mp.Process(target=worker_naive, args=(i,)) for i in range(4)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

Inspecting the raw bytes actually written shows all four workers racing to return to the start of the same line via \r (measured, excerpt):

\rworker-0:   0%|          | 0/40 [00:00<?, ?it/s]\rworker-2:   0%|          | 0/40 [00:00<?, ?it/s]\rworker-1:   0%|          | 0/40 [00:00<?, ?it/s]\rworker-3:   0%|          | 0/40 [00:00<?, ?it/s]\rworker-0:   2%|...

Every write starts with \r and there is no cursor movement (no \033[nA-style row targeting) at all, so on a real terminal all four bars keep stomping over each other on the same row – the visible result is a broken display where only whichever process wrote most recently briefly shows through.

The standard fix is to assign each worker a distinct position and share a lock via tqdm.set_lock():

import multiprocessing as mp
import time
from tqdm import tqdm

def worker_fixed(worker_id, lock, n=40):
    # FIX: position=worker_id reserves a dedicated row, and the lock
    # (propagated from the parent process) is shared across workers.
    tqdm.set_lock(lock)
    for i in tqdm(
        range(n), desc=f"worker-{worker_id}", position=worker_id,
        mininterval=0, miniters=1,
    ):
        time.sleep(0.005)

if __name__ == "__main__":
    lock = mp.RLock()
    procs = [mp.Process(target=worker_fixed, args=(i, lock)) for i in range(4)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

The raw bytes after the fix show \x1b[A (cursor up one line) escape sequences and \n characters inserted around each update, moving to the worker’s own row before writing (excerpt):

\rworker-0:   0%|          | 0/40 [00:00<?, ?it/s]\n\rworker-1:   0%|          | 0/40 [00:00<?, ?it/s]\x1b[A\n\n\n\rworker-3: ...

tqdm.contrib.concurrent.process_map (covered earlier in this article) manages this position bookkeeping for you internally, so prefer it over manual position assignment whenever your parallel workload fits its shape. Reach for the “position + set_lock” pattern above only when you must manage a Pool/Process setup by hand.

Edge Case 2: Piping to a Non-TTY (Redirection, CI Logs)

When stdout is redirected to a file (python script.py > out.log) or run non-interactively as in a CI job log, the three libraries behave very differently by default. We ran a 30-iteration loop with output redirected to a plain file (instead of a terminal) and inspected the bytes written.

LibraryDefault behaviorBytes written\r count
tqdm (no options)Keeps writing \r-based updates regardless (spams)285 bytes5
rich.progress (no options)Detects non-terminal, prints only the final state once140 bytes0
alive-progress (no options)Detects non-tty, disables all animation, final receipt only155 bytes0

Unless you explicitly pass disable, tqdm diligently keeps sending \r-based updates regardless of whether the output is a terminal. If you later cat that file, you get an unreadable line where many progress states are concatenated via \r. rich.progress and alive-progress, on the other hand, auto-detect that the destination isn’t a terminal (via internal Console.is_terminal / force_tty checks) and default to the conservative choice of skipping intermediate frames and printing only the final state.

To detect and control this explicitly:

import sys
from tqdm import tqdm

# tqdm does not auto-detect this, so check isatty() yourself and disable explicitly
is_interactive = sys.stdout.isatty()

for x in tqdm(range(1000), disable=not is_interactive, mininterval=0.5):
    ...

# or pass disable=None to auto-disable whenever output is redirected
for x in tqdm(range(1000), disable=None):
    ...

Passing disable=None makes tqdm check file.isatty() and automatically suppress the bar when it isn’t a terminal (note that disable’s actual default is False, so with no argument the bar always shows). If you want to keep CI logs clean, use disable=None or check sys.stdout.isatty() yourself. rich/alive-progress make this decision for you by default, but if you specifically want animation even when redirected, you can force it with force_terminal=True (rich) or force_tty=True (alive-progress).

Recent Developments (2024-2025)

  • tqdm: In 2024, a vulnerability was reported (CVE-2024-34062) where the CLI’s type conversion for optional arguments relied on eval(), allowing code execution via crafted non-boolean CLI arguments (e.g. --delim). It was fixed in v4.66.3, which replaced the eval()-based conversion with explicit, safe type handling. If you invoke tqdm as a CLI tool (via python -m tqdm), make sure you’re on 4.66.3 or later; ordinary library usage (from tqdm import tqdm) is unaffected.
  • alive-progress: Development remains active, with v3.1.5 released in October 2024 and v3.2.0 in July 2025.
  • tqdm.rich (the bundled rich-integration submodule shipped with tqdm) remains experimental and has not fully kept up with changes to rich’s own API. If you want to combine the two, using rich.progress directly – as this article does – is more stable than going through tqdm.rich.

The core APIs of all three libraries (tqdm(iterable), Progress(), alive_bar(total)) remain stable, so the code in this article should keep working going forward. Just double-check the version if you’re invoking tqdm as a standalone CLI tool.

References