Why Your Progress Bar Keeps Printing New Lines (And How to Fix It)
A deep dive into terminal progress bars
You’ve probably been there: you’re running a long Python script, you’ve added a nice tqdm progress bar, and instead of getting a smooth, updating bar, you get… this:
Processing: 10%|█ | 10/100 [00:01<00:09]
Warning: item_10 returned None
Processing: 12%|█▏ | 12/100 [00:01<00:08]
Warning: item_20 returned None
Processing: 22%|██▏ | 22/100 [00:02<00:07]
Warning: item_30 returned None
Processing: 32%|███▏ | 32/100 [00:03<00:06]A staircase of progress bars, each on its own line. Every warning spawns a new bar below it. Infuriating.
I recently spent two hours debugging this exact issue. I was convinced tqdm was broken. I trawled StackOverflow, and eventually realized the embarrassing truth: I didn’t actually understand how terminals work.
The “bug” wasn’t in the library. It was in my mental model of stdout.
In this post, I’ll walk you through my “aha!” moment, explain the mechanics of terminal cursors, and show you exactly how to fix this—whether you stick with tqdm or upgrade to modern alternatives like Rich.
The Root Cause: How Terminal Progress Bars Actually Work#
Before we fix the problem, we need to understand the mechanics. There’s no magic here, just clever use of ASCII control characters.
The Carriage Return Trick#
Terminal progress bars work by exploiting two ASCII control characters:
| Character | ASCII Code | What It Does |
|---|---|---|
\r (Carriage Return) | 13 | Moves cursor to the start of the current line |
\n (Line Feed) | 10 | Moves cursor to the next line |
The key insight: \r does NOT create a new line. It just moves the cursor back to column 0.
So tqdm does this:
- Print
[████░░░░░░] 40% - Print
\rto return to the start of the line - Print
[█████░░░░░] 50%which overwrites the previous output
The illusion of an updating progress bar is just rapid overwriting.
Why Your Bar Keeps Printing New Lines#
The problem is simple: something else in your code is printing \n.
When you call print("Checkpoint reached!"), Python prints your message AND a newline. That newline pushes tqdm’s bar to a new line. Now tqdm thinks it’s on a fresh line, so it prints a new bar there. Rinse and repeat.
The most common causes:
print()statements in your loop- Logging output (the
loggingmodule adds newlines) - Subprocess output that writes to stdout
- Exceptions or warnings printed to stderr
(Note: tqdm writes to stderr by default, while print() writes to stdout. This stream mismatch is part of why they interfere with each other.)
The Fix: Use tqdm.write()#
The solution is elegant. Instead of print(), use tqdm.write():
from tqdm import tqdm
import time
items = range(100)
for i in tqdm(items, desc="Processing"):
time.sleep(0.01)
if i % 10 == 0:
tqdm.write(f"Checkpoint {i}: processed successfully") # ✅ This works!tqdm.write() is smart: it temporarily clears the progress bar, prints your message, and then redraws the bar. No staircase.
Here is what the clean output looks like:
Checkpoint 0: processed successfully
Checkpoint 10: processed successfully
Checkpoint 20: processed successfully
...
Processing: 100%|██████████████████| 100/100 [00:01<00:00, 99.10it/s]For Logging#
If you’re using the logging module, tqdm provides a redirect helper:
import logging
from tqdm import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm
logging.basicConfig(level=logging.INFO)
with logging_redirect_tqdm():
for i in tqdm(range(100)):
if i % 25 == 0:
logging.info(f"Processed {i} items")For Narrow Terminals#
If your bar is wrapping to multiple lines because the terminal is too narrow:
for i in tqdm(range(100), ncols=80): # Force 80-char width
passFor IDE Output Panels#
VS Code’s “Output” panel and some IDE consoles don’t support \r at all. They’re designed for append-only log output. Use the integrated terminal instead.
For Nested Loops#
When you have multiple progress bars (e.g., epochs and batches), use the position parameter to stack them:
for epoch in tqdm(range(10), position=0, desc="Epochs"):
for batch in tqdm(range(100), position=1, desc="Batches", leave=False):
passposition=0 is the top bar, position=1 is below it. leave=False clears the inner bar when it completes, so you don’t accumulate finished bars.
For Jupyter Notebooks#
tqdm behaves differently in notebooks. Use the notebook-specific import:
from tqdm.notebook import tqdm # Instead of: from tqdm import tqdmThis renders an HTML widget instead of text output.
Beyond tqdm: The Python Progress Bar Landscape#
tqdm is the default, but it’s not the only option. Here’s when to consider alternatives:
Rich: When You Want Beautiful Output#
Rich by Will McGugan is a gorgeous terminal library that includes progress bars:
from rich.progress import track
for item in track(range(100), description="Processing..."):
# do_work()
passWhy Rich handles this better:
Rich doesn’t rely solely on \r. It uses ANSI escape codes to actively manage cursor position:
\x1b[2Kerases the entire current line\x1b[Amoves the cursor up one line
When you print() during a Rich progress bar, Rich detects the interruption, moves the cursor up, erases the bar, lets your print happen, then redraws the bar below. It’s cursor manipulation, not just overwriting.
Here’s the same “broken” logic, but using Rich:
from rich.progress import track
import time
print("\n--- Broken Rich Example ---")
for step in track(range(100), description="Processing..."):
time.sleep(0.01)
if step % 10 == 0 and step > 0:
print(f"Warning: item_{step} returned None")Rich handles it gracefully:
Warning: item_10 returned None
Warning: item_20 returned None
Warning: item_30 returned None
Warning: item_40 returned None
Warning: item_50 returned None
Warning: item_60 returned None
Warning: item_70 returned None
Warning: item_80 returned None
Warning: item_90 returned None
Processing... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:01Warnings print cleanly above, progress bar stays at the bottom. No staircase.
alive-progress: When You Want Eye Candy#
alive-progress is all about visual engagement:
from alive_progress import alive_bar
with alive_bar(100) as bar:
for i in range(100):
# do_work()
bar()The spinner animation speed adjusts based on your actual processing throughput.
alive-progress uses similar ANSI escape code techniques as Rich, so it also handles interleaved output gracefully:
from alive_progress import alive_bar
import time
print("\n--- Broken alive-progress Example ---")
with alive_bar(100) as bar:
for i in range(100):
time.sleep(0.01)
if i % 10 == 0 and i > 0:
print(f"Warning: item_{i} returned None")
bar()Output:
on 10: Warning: item_10 returned None
on 20: Warning: item_20 returned None
on 30: Warning: item_30 returned None
on 40: Warning: item_40 returned None
on 50: Warning: item_50 returned None
on 60: Warning: item_60 returned None
on 70: Warning: item_70 returned None
on 80: Warning: item_80 returned None
on 90: Warning: item_90 returned None
|████████████████████████████████████████| 100/100 [100%] in 1.0s (99.37/s)The on X: prefix shows the progress state when each warning was emitted.
Conclusion#
The “new line bug” in tqdm isn’t a bug at all. It’s a misunderstanding of how terminal output works. Once you understand that progress bars are just clever use of \r, the fix becomes obvious: don’t let anything else print \n while your bar is active.
Quick fixes:
- Replace
print()withtqdm.write() - Use
logging_redirect_tqdmfor logging - Use the integrated terminal, not IDE output panels
When to upgrade:
- Need multiple bars or styling: Rich
- Want animated spinners: alive-progress
Anyways, I’m sick of stairs so I’ll be taking the elevator from now on.
Have a question or correction? Find me on [Twitter/X] or open an issue on [GitHub].