PyTorch and tqdm

How to add a tqdm progress bar to a PyTorch training loop

Wrap the epoch loop in trange, wrap the DataLoader in tqdm(..., leave=False), update set_postfix(loss=...) every N steps instead of every step, and pass disable=rank != 0 under DDP. tqdm 4.70.1 adds about 60 ns per iteration, so the bar never slows training; calling loss.item() on every step can.

5 min readML training and GPUs

At a glance

  • leave=False on the batch bar stops one finished bar per epoch from piling up in your terminal.
  • loss.item() forces a CPU-GPU sync, so refresh the postfix every N steps, not every step.
  • Under torchrun, draw bars on rank 0 only with disable=rank != 0.
  • The ETA is the remaining items divided by a moving-average rate (smoothing=0.3), so it jumps after slow batches.

A training loop with an epoch bar and a batch bar

In a plain PyTorch training loop you want two bars: an outer one for epochs and an inner one wrapping your DataLoader for the batches. The current release is tqdm 4.70.1, and its README puts the overhead at about 60 nanoseconds per iteration. Wrap the epoch range with trange and the loader with tqdm, and pass leave=False to the inner bar so finished epochs don't pile up in your terminal.

Python
from tqdm import tqdm, trange

for epoch in trange(num_epochs, desc="epochs"):
    model.train()
    for x, y in tqdm(train_loader, desc=f"epoch {epoch}", leave=False):
        x, y = x.to(device), y.to(device)
        optimizer.zero_grad()
        loss = loss_fn(model(x), y)
        loss.backward()
        optimizer.step()

If you need to log something inside the loop, don't use a plain print(), because it breaks the bar. Use tqdm.write() instead, which prints a line above the bars without mangling them.

Show the loss with set_postfix

To put live metrics on the bar, call set_postfix() on the batch bar object. It adds key=value pairs after the rate, while set_description() changes the label on the left.

There's a hidden cost here. The PyTorch performance tuning guide lists cuda_tensor.item() among the operations that force a CPU-GPU synchronization. If you call loss.item() on every step, the CPU waits for the GPU to finish that batch before it can queue the next one. Accumulate the loss on the GPU and only pull it to the CPU every N steps.

Python
LOG_EVERY = 50

bar = tqdm(train_loader, desc=f"epoch {epoch}", leave=False)
running = torch.zeros((), device=device)

for step, (x, y) in enumerate(bar, start=1):
    x, y = x.to(device), y.to(device)
    optimizer.zero_grad()
    loss = loss_fn(model(x), y)
    loss.backward()
    optimizer.step()
    running += loss.detach()

    if step % LOG_EVERY == 0:
        bar.set_postfix(loss=f"{(running / LOG_EVERY).item():.4f}", lr=optimizer.param_groups[0]["lr"])
        running.zero_()

Nested bars: what position and leave do

By default tqdm uses leave=True and position=None, which means automatic placement. When you nest a batch bar inside an epoch bar, automatic placement puts the inner bar on the line below the outer one, and leave=True keeps a finished bar on screen when its loop ends.

That default is right for the outer epoch bar, since you want it visible when training finishes. On the inner bar, leave=True prints a new permanent line at the end of every epoch, which is why you set leave=False there so the next epoch reuses the same line. If automatic placement ever gets confused in your terminal, pass position=0 on the outer bar and position=1 on the inner one.

The tqdm settings that matter in training

Most of tqdm's arguments can stay at their defaults, but a handful matter for long training jobs or when you log to a file.

ParameterDefaultUse in a training loop
descNoneThe label on the left; change it live with set_description().
totalNoneTaken from len() of your DataLoader. Pass it yourself for iterable datasets to get a percentage and an ETA.
leaveTrueKeep True on the epoch bar, set False on the batch bar.
positionNoneAutomatic stacking, or 0 and 1 for the outer and inner bar.
mininterval0.1Seconds between redraws. Raise it when you write to a log file.
smoothing0.3Weight of the moving average behind the rate and ETA: 0 is the whole-run average, 1 is the latest update.
dynamic_ncolsFalseSet True if you resize the terminal while training runs.
disableFalseTrue on non-zero DDP ranks, or None to switch the bar off when output isn't a terminal.

Multi-GPU and DDP: draw on rank 0 only

When you launch a DistributedDataParallel job, every process runs the training loop. Without disable, you get one bar per GPU fighting over the same terminal.

torchrun sets the environment variables RANK, LOCAL_RANK and WORLD_SIZE for every process. Read RANK and pass disable=rank != 0, so only rank 0 draws while the other processes train silently.

Python
import os

rank = int(os.environ.get("RANK", 0))

for x, y in tqdm(train_loader, desc=f"epoch {epoch}", disable=rank != 0):
    ...

What the ETA actually means

The time block on a tqdm bar reads [elapsed<remaining, rate]. The remaining value is the number of items left divided by the current rate, and the rate is an exponential moving average with smoothing=0.3 by default.

Because of that moving average, the ETA jumps after a slow first batch, during validation or while a checkpoint is written. If the jumps bother you, lower smoothing toward 0 so the rate leans toward the average speed of the whole run.

Lightning and Keras do their own thing

If you use a framework instead of a plain loop, the bars work differently. In PyTorch Lightning 2.6.6, TQDMProgressBar(refresh_rate=1) is the tqdm bar, but RichProgressBar becomes the default when the rich package (10.2.2 or newer) is installed. Trainer(enable_progress_bar=False) turns bars off completely.

Keras doesn't use tqdm at all. It has its own Progbar, and fit(verbose=...) accepts "auto", 0, 1 or 2.

Watch the bar from your phone

The batch bar is the best signal that a run is healthy, so it's the thing you want to see when you're away from the terminal. Runpeek streams standard tqdm bars, including Lightning's TQDMProgressBar in a terminal; Rich bars and the Keras Progbar are not covered.

patch() makes every from tqdm import tqdm bar in the process stream, and start_run() groups them under one link. The rank check keeps a DDP job to a single link. Updates go out at most about every 0.75 seconds from a background thread, and network errors never raise into your training loop.

FAQ

Why does my tqdm bar print a new line on every update?

Usually it's a print() inside the loop, a bar wider than the window, or output that isn't a real terminal. Use tqdm.write() for messages, dynamic_ncols=True for narrow or resized windows, and a larger mininterval when you write to a log file.

Does tqdm slow down PyTorch training?

The bar itself costs about 60 nanoseconds per iteration, which is nothing next to a forward and backward pass. What does slow training is calling loss.item() on every step to update the bar, because it forces the CPU to wait for the GPU. Update the postfix every N steps.

How do I show the loss in a tqdm bar?

Keep a reference to the bar, bar = tqdm(train_loader), and call bar.set_postfix(loss=value) inside the loop. Average the loss on the GPU over the last N steps and convert it once, so you don't add a CPU-GPU sync to every step.

How do I use tqdm with DistributedDataParallel?

Read the RANK environment variable that torchrun sets and pass disable=rank != 0 to every bar. Only the main process draws, and the others run the same loop without output.

Does tqdm work with PyTorch Lightning?

Yes. Lightning's TQDMProgressBar is built on tqdm and accepts refresh_rate and leave. When the rich package is installed, Lightning picks RichProgressBar by default, so pass callbacks=[TQDMProgressBar()] if you want the tqdm one.

Check on long runs from your phone

Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.

Get a live linkpip install tqdm-live

Sources