Python: Overwrite Print Output on the Same Line (\r and ANSI Escape Codes)

How to overwrite print output in Python: update a single line with carriage return (\r, end=''), refresh multiple lines with ANSI escape sequences, and build a simple progress display — with copy-paste code examples. Also covers print's internals and buffering modes, measured f-string/.format()/% performance, and fixing UnicodeEncodeError.

Overwriting a Single Line

To overwrite a single line, use \r (carriage return). This control character moves the cursor to the beginning of the current line.

print("\r" + "Overwriting a single line!", end="")

Use end="" to prevent a newline character from being printed, keeping the cursor on the same line.

Countdown Timer Example

Here is a practical use of \r to implement a countdown timer.

import time
import sys

def countdown(seconds):
    for i in range(seconds, 0, -1):
        # \r returns to the start of the line, overwriting previous output
        sys.stdout.write(f"\rTime remaining: {i:3d} seconds")
        sys.stdout.flush()
        time.sleep(1)
    sys.stdout.write("\rDone!                      \n")

countdown(10)

Here we use sys.stdout.write and sys.stdout.flush(). While print internally calls sys.stdout.write, using sys.stdout directly gives more explicit control over buffering. Calling flush() ensures that output is immediately displayed on screen.

Note: If the new string is shorter than the previous one, leftover characters from the previous output will remain visible. In the example above, spaces are added after "Done!" to ensure the previous text is fully cleared.

Spinning Cursor Implementation

A spinning cursor (loading animation) that visually indicates ongoing processing can also be implemented with \r.

import sys
import time
import itertools

def spinning_cursor(duration=5):
    spinner = itertools.cycle(['|', '/', '-', '\\'])
    end_time = time.time() + duration
    while time.time() < end_time:
        sys.stdout.write(f"\rProcessing... {next(spinner)}")
        sys.stdout.flush()
        time.sleep(0.1)
    sys.stdout.write("\rComplete!          \n")

spinning_cursor(3)

Using itertools.cycle allows the spinner characters to repeat indefinitely.

What print() Actually Does, and Buffering

print() is a convenient high-level function, but underneath it is a thin wrapper. Conceptually, CPython’s real behavior can be reproduced like this (this is illustrative pseudo-code, not the actual C implementation):

import sys

def my_print(*objects, sep=" ", end="\n", file=None, flush=False):
    if file is None:
        file = sys.stdout
    file.write(sep.join(str(obj) for obj in objects) + end)
    if flush:
        file.flush()
  • sep: the string inserted between multiple arguments (default: a single space)
  • end: the string appended after everything else (default: a newline). This article passes end="" precisely to suppress that newline so the \r cursor movement has an effect
  • file: the stream to write to. Defaults to sys.stdout, but you can pass sys.stderr or any writable io object
  • flush: if True, forces the stream’s buffer to be flushed immediately after this call

The key point is that print() ultimately just calls file.write() — whether that data actually reaches the screen (or file) right away is governed by a separate “buffering mode,” independent of the write call itself.

Three Buffering Modes

CPython’s io module automatically picks a buffering strategy based on what the stream is connected to.

ModeBehaviorWhen it applies
Line bufferingAutomatically flushed every time a newline \n is writtenDefault for sys.stdout when connected to an interactive terminal (isatty() is True)
Full / block bufferingWrites are held in an internal buffer (default size io.DEFAULT_BUFFER_SIZE bytes) until it fills up, the program exits, or flush()/close() is called explicitlyDefault for sys.stdout when connected to a file or pipe (a non-interactive destination)
UnbufferedWrites are reflected immediatelysys.stderr behaves close to this at all times. To force sys.stdout into this mode, use python -u or the PYTHONUNBUFFERED=1 environment variable

On the Python 3.14.6 install used to verify this article, the default buffer size for full buffering was:

>>> import io
>>> io.DEFAULT_BUFFER_SIZE
131072

That’s 128 KiB — considerably larger than the 8 KiB default that had been in use since the Python 2 era. The increase was tracked and implemented in CPython issue #117151 as a bulk-I/O performance improvement. The larger the buffer, the longer the potential delay before a “burst” of buffered output appears under full buffering.

sys.stderr is designed to never buffer output, so that error messages are never delayed or lost. This is exactly why this article’s countdown timer and spinner examples call sys.stdout.flush() right after every sys.stdout.write() — if sys.stdout is connected to a non-interactive destination (a file redirect, a CI runner, etc.) and flush() is never called, the “overwrite” effect built with \r won’t reach the screen at all; it will all be dumped at once when the process exits.

Experiment: Output Order Differs Between a Terminal and a Redirect

Let’s verify this concretely. The following script alternates between print() (via sys.stdout) and sys.stderr.write().

import sys
import time

for i in range(5):
    print(f"stdout {i}")               # via sys.stdout (buffering mode depends on the destination)
    sys.stderr.write(f"stderr {i}\n")  # sys.stderr is never buffered
    time.sleep(0.15)

Running this script with stdout connected to a pseudo-terminal (pty) versus a plain pipe (the same buffering behavior as a file redirect), and recording arrival timestamps for each line (using the pty module to create a pseudo-terminal and select to time event arrival), gives the following.

Case A: stdout connected to an interactive terminal (pty)

t=0.018s [STDOUT] stdout 0
t=0.018s [STDERR] stderr 0
t=0.169s [STDOUT] stdout 1
t=0.169s [STDERR] stderr 1
t=0.321s [STDOUT] stdout 2
t=0.321s [STDERR] stderr 2
t=0.476s [STDOUT] stdout 3
t=0.476s [STDERR] stderr 3
t=0.631s [STDOUT] stdout 4
t=0.631s [STDERR] stderr 4

stdout and stderr appear at nearly the same time, interleaved as expected in real time (line buffering).

Case B: stdout redirected to a plain pipe/file

t=0.020s [STDERR] stderr 0
t=0.172s [STDERR] stderr 1
t=0.327s [STDERR] stderr 2
t=0.481s [STDERR] stderr 3
t=0.632s [STDERR] stderr 4
t=0.787s [STDOUT] stdout 0
t=0.787s [STDOUT] stdout 1
t=0.787s [STDOUT] stdout 2
t=0.787s [STDOUT] stdout 3
t=0.787s [STDOUT] stdout 4

stderr appears immediately each time, but all five stdout lines arrive only when the process exits. That’s full buffering in action. You can verify this yourself by running python3 script.py > out.txt: stderr streams to the terminal in real time, while out.txt only fills in once the program finishes.

The fix: either of these restores real-time behavior even when stdout is redirected.

# Option 1: pass flush=True to each print() call
print(f"stdout {i}", flush=True)

# Option 2: launch the interpreter with -u (unbuffered)
# $ python3 -u script.py > out.txt

Verifying both in practice confirmed that stdout and stderr interleave again with the same timing pattern as Case A. This flush() mechanism (or its equivalent) is exactly what makes the \r-based real-time overwrite technique in this article work at all.

Overwriting Multiple Lines

To overwrite multiple lines, you can use special ANSI escape codes. The sequence \033[nA moves the cursor up n lines.

import time

print("First line")
print("Second line")
print("Third line")

time.sleep(1) # Wait for a second to see the initial output

# Move cursor up 2 lines (from the current position, which is after "Third line")
# and then overwrite the second and third lines.
print("\033[2A" + "New Second line\n" + "New Third line")

ANSI Escape Sequence Reference

Here is a summary of the main ANSI escape sequences for cursor control and text decoration in the terminal.

Cursor Control

SequenceDescriptionExample
\033[nAMove cursor up n linesprint("\033[2A")
\033[nBMove cursor down n linesprint("\033[1B")
\033[nCMove cursor right n columnsprint("\033[5C")
\033[nDMove cursor left n columnsprint("\033[3D")
\033[2KClear current lineprint("\033[2K", end="")
\033[JClear from cursor to endprint("\033[J", end="")
\033[HMove cursor to top-leftprint("\033[H")
\033[{r};{c}HMove cursor to row r, column cprint("\033[5;10H")

Text Decoration (Colors & Styles)

SequenceDescription
\033[0mReset (clear all)
\033[1mBold
\033[4mUnderline
\033[31mRed text
\033[32mGreen text
\033[33mYellow text
\033[34mBlue text
\033[41mRed background
\033[42mGreen background

For example, colored status messages can be written as follows.

# Display in green for success, red for failure
def print_status(message, success=True):
    color = "\033[32m" if success else "\033[31m"
    reset = "\033[0m"
    print(f"{color}{message}{reset}")

print_status("Test passed", success=True)
print_status("Test failed", success=False)

Download Progress Bar Example

By combining \r and ANSI escape sequences, you can build a practical download progress display.

import sys
import time

def download_progress(total_size, chunk_size=1024):
    """Display download progress with a progress bar"""
    downloaded = 0
    bar_length = 40

    while downloaded < total_size:
        downloaded += chunk_size
        if downloaded > total_size:
            downloaded = total_size

        # Calculate progress
        progress = downloaded / total_size
        filled = int(bar_length * progress)
        bar = "█" * filled + "░" * (bar_length - filled)

        # Display in MB
        dl_mb = downloaded / (1024 * 1024)
        total_mb = total_size / (1024 * 1024)

        # Color-coded progress
        if progress < 0.5:
            color = "\033[33m"  # Yellow
        else:
            color = "\033[32m"  # Green
        reset = "\033[0m"

        sys.stdout.write(
            f"\r{color}[{bar}]{reset} "
            f"{progress:6.1%} "
            f"({dl_mb:.1f}/{total_mb:.1f} MB)"
        )
        sys.stdout.flush()
        time.sleep(0.01)  # Simulate download

    sys.stdout.write("\n")
    print("Download complete!")

# Simulate a 10MB file download
download_progress(10 * 1024 * 1024, chunk_size=100 * 1024)

Multi-Line Real-Time Update Example

By combining ANSI escape sequences, you can update the progress of multiple tasks simultaneously in real time.

import sys
import time
import random

def multi_task_progress():
    """Update progress for multiple tasks simultaneously"""
    tasks = ["Fetching ", "Preprocess", "Training  ", "Evaluation"]
    progress = [0] * len(tasks)

    # Initial display
    for task in tasks:
        print(f"  {task}: [{'░' * 30}]   0%")

    while not all(p >= 100 for p in progress):
        # Randomly advance progress
        for i in range(len(tasks)):
            if progress[i] < 100:
                progress[i] = min(100, progress[i] + random.randint(0, 5))

        # Move cursor up by the number of tasks
        sys.stdout.write(f"\033[{len(tasks)}A")

        for i, task in enumerate(tasks):
            filled = int(30 * progress[i] / 100)
            bar = "█" * filled + "░" * (30 - filled)
            if progress[i] >= 100:
                color = "\033[32m"  # Complete: green
            else:
                color = "\033[33m"  # In progress: yellow
            reset = "\033[0m"
            print(f"  {task}: {color}[{bar}]{reset} {progress[i]:3d}%")

        time.sleep(0.1)

multi_task_progress()

String Formatting Performance: f-string vs .format() vs %

Every progress display example so far builds a string inside a loop (download_progress at a minimum 10ms interval, multi_task_progress on every update across all tasks). “f-strings are always fastest” is a common claim — let’s actually measure it.

import timeit

progress = 0.6428
dl_mb = 6.428
total_mb = 10.0
bar = "#" * 25 + "-" * 15

def with_fstring():
    return f"\r[{bar}] {progress:6.1%} ({dl_mb:.1f}/{total_mb:.1f} MB)"

def with_format():
    return "\r[{}] {:6.1%} ({:.1f}/{:.1f} MB)".format(bar, progress, dl_mb, total_mb)

def with_percent():
    return "\r[%s] %5.1f%% (%.1f/%.1f MB)" % (bar, progress * 100, dl_mb, total_mb)

N = 2_000_000
for name, fn in [("f-string", with_fstring), (".format()", with_format), ("%-formatting", with_percent)]:
    best = min(timeit.repeat(fn, number=N, repeat=5))
    print(f"{name:14s}: {best / N * 1e9:.1f} ns/call")

Measured results on Python 3.14.6 (best of 5 runs, 2,000,000 calls each):

MethodSimple, 2 fields (f"[{name}] step {i}")Progress-bar line (4 fields, width/precision specs)
f-string104.5 ns/call524.4 ns/call
.format()156.8 ns/call490.0 ns/call
%-formatting131.1 ns/call399.0 ns/call

The results do not support “f-strings are always fastest.” For a simple two-field substitution, f-strings do win. But for the progress-bar line’s more complex formatting — four fields including width/precision specs like %6.1f%% — the ranking flips: %-formatting is fastest and f-strings are the slowest of the three. A plausible reason is that f-strings compile each {...} into a separate per-field formatting call, while the % operator collapses the whole thing into a single C-level formatting pass.

Here’s the comparison as a chart:

Benchmark comparing f-string, .format(), and %-formatting. f-string is fastest for a simple 2-field case, but becomes the slowest for a progress-bar-style format with width/precision specs

Takeaway: when building strings with complex format specs at high frequency — as in a progress bar — don’t assume f-strings are fastest; benchmark your actual workload. That said, all three methods here cost only a few hundred nanoseconds per call, so for a progress display that sleeps 10ms (10,000,000 ns) between updates, the choice makes no perceptible difference. Formatting performance only matters in hot loops running hundreds of thousands of times per second or more.

Cross-Platform Considerations

ANSI escape sequences work natively on Linux and macOS terminals, but the traditional Windows Command Prompt (cmd.exe) does not support them.

Handling Windows

Windows Terminal and PowerShell on Windows 10+ do support ANSI escape sequences, but for compatibility with older environments, use the colorama library.

# pip install colorama
from colorama import init, Fore, Style

# Enable ANSI escapes on Windows
init()

# Colored output with colorama
print(Fore.GREEN + "Success" + Style.RESET_ALL)
print(Fore.RED + "Error" + Style.RESET_ALL)

Simply calling colorama.init() makes ANSI escape sequences work correctly on Windows. On Linux/macOS it does nothing (no side effects), making it suitable for cross-platform code.

Branching with os.name

If you prefer not to use colorama, you can branch based on os.name.

import os
import sys

def supports_ansi():
    """Check if the terminal supports ANSI escape sequences"""
    if os.name == "nt":
        # Supported on Windows 10 build 10586+
        return os.environ.get("WT_SESSION") is not None  # Windows Terminal
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()

if supports_ansi():
    GREEN = "\033[32m"
    RESET = "\033[0m"
else:
    GREEN = ""
    RESET = ""

print(f"{GREEN}Status: OK{RESET}")

An Encoding Pitfall: UnicodeEncodeError

If your progress bar uses / block characters, or your status messages use emoji (like ✅), you can run into a UnicodeEncodeError on certain systems. The cause is that sys.stdout’s encoding doesn’t support the characters you’re trying to write — typically on Windows, where the Command Prompt’s default legacy codepage (e.g. cp1252 or cp932, neither of which is UTF-8) can’t represent them.

Here’s a concrete reproduction, forcing sys.stdout’s encoding to cp1252.

import sys

print("stdout encoding:", sys.stdout.encoding)
print(f"Progress 100% done ✅")
$ PYTHONIOENCODING=cp1252 python3 encoding_bug.py
stdout encoding: cp1252
Traceback (most recent call last):
  File "encoding_bug.py", line 4, in <module>
    print(f"Progress 100% done ✅")
UnicodeEncodeError: 'charmap' codec can't encode character '✅' in position 19: character maps to <undefined>

cp1252 cannot represent the emoji, so print()’s internal file.write() call fails to encode it and raises the exception directly.

Fix 1: force UTF-8 explicitly via the PYTHONIOENCODING environment variable.

$ PYTHONIOENCODING=utf-8 python3 encoding_bug.py
stdout encoding: utf-8
Progress 100% done

Fix 2: call sys.stdout.reconfigure() inside the script to force UTF-8 regardless of the runtime locale (Python 3.7+).

import sys

sys.stdout.reconfigure(encoding="utf-8")
print("stdout encoding after reconfigure:", sys.stdout.encoding)
print(f"Progress 100% done ✅")
stdout encoding after reconfigure: utf-8
Progress 100% done ✅

Both fixes were verified directly: with cp1252 forced, the script reliably raises UnicodeEncodeError, and either PYTHONIOENCODING=utf-8 or sys.stdout.reconfigure(encoding="utf-8") resolves it. If you distribute a CLI tool that might run on Windows, calling sys.stdout.reconfigure() at the top of your entry point is a safe defensive measure.

Library Comparison: Manual vs tqdm vs rich

Choose the right approach based on your use case.

FeatureManual (\r / ANSI)tqdmrich
External dependencyNonepip installpip install
Progress barMust build yourselfOne-linerOne-liner
Multiple barsMust build yourselfSupportedSupported
Table displayNot availableNot availableSupported
Colored outputManual ANSI codesLimitedEasy with Rich Markup
Windows supportNeeds coloramaAutomaticAutomatic
CustomizabilityFull controlModerateHigh
Learning curveLow (good for learning)LowModerate
Best forLightweight, learningLoop processingRich CLI applications

For simple overwrite displays, use the techniques from this article. For progress bars in loops, consider tqdm . For rich CLI applications, consider rich .

Modern Python Output Libraries

Beyond overwriting print output, there are libraries that provide rich terminal output.

The rich Library

rich can display colored text, tables, progress bars, and more in the terminal.

from rich.console import Console
from rich.table import Table

console = Console()

# Progress display
from rich.progress import track
import time

for i in track(range(100), description="Processing..."):
    time.sleep(0.01)

# Status display (with spinner)
with console.status("Computing..."):
    time.sleep(2)
    console.print("[bold green]Done![/bold green]")

Progress Bars with tqdm

tqdm provides progress bars with minimal code. It is widely used as an alternative to manual overwrite-based displays.

from tqdm import tqdm
import time

for i in tqdm(range(100)):
    time.sleep(0.01)

Recent CPython Developments Worth Knowing

We checked whether recent CPython versions changed anything about print/sys.stdout buffering itself.

  • Free-threaded (no-GIL) builds: the free-threaded build based on PEP 703 was introduced experimentally in Python 3.13, and became officially supported in Python 3.14 (released October 2025). Single-threaded overhead also dropped from roughly 35-40% in 3.13 to roughly 5-10% in 3.14. However, based on CPython’s official documentation ( Thread Safety Guarantees , Python support for free threading ), neither page documents any change to print()’s or sys.stdout’s buffering/flushing semantics. Free-threading changes how Python bytecode executes concurrently across threads; the per-stream buffering-mode logic described in this article appears unaffected.
  • A larger default buffer size: as noted above, io.DEFAULT_BUFFER_SIZE was 128 KiB (131072 bytes) on the 3.14.6 install used to verify this article, up from the 8 KiB default that had been in place since the Python 2 era. This was tracked and implemented in CPython issue #117151 as a bulk-I/O performance improvement. It means more data can accumulate before a flush under full buffering, but the underlying line-buffering vs. full-buffering mechanism itself is unchanged.

We found no breaking changes to the fundamental print()/\r-overwrite behavior described in this article.