Skip to content

Fix: Rich Not Working — Live Display Issues, Color in CI, and Console Configuration

FixDevs ·

Quick Answer

How to fix Rich errors — colors not appearing in CI logs, Live display flickering, progress bar not updating, table column overflow, traceback install conflicts, and Console redirect issues.

The Error

You write Rich output and it looks great locally but breaks in CI:

from rich.console import Console
console = Console()
console.print("[bold red]Error![/]")
# Locally: Bold red text
# In CI logs: [bold red]Error![/]

Or a Live display flickers wildly:

from rich.live import Live
with Live() as live:
    for i in range(100):
        live.update(...)   # Screen flickers and re-draws constantly

Or progress bars print on every iteration instead of updating in place:

from rich.progress import track
for i in track(range(100)):
    process(i)
# Output:
# ⠋ Working...   1%
# ⠙ Working...   2%
# ⠹ Working...   3%   # New line every update — not in place

Or table columns truncate or wrap badly:

from rich.table import Table
table = Table("ID", "Name", "Description")
# Description column overflows; "ID" column too wide

Or install_rich_traceback() conflicts with another logging system:

from rich.traceback import install
install()
# Tracebacks look good, but sentry/loguru/structlog now break

Rich is the dominant library for terminal output in Python — pretty tables, progress bars, syntax-highlighted code, rich tracebacks. Used by Typer, Textual, pip, and most modern CLIs. It works beautifully in interactive terminals but produces specific failures in CI logs, redirected output, and limited-width environments. This guide covers each.

Why This Happens

Rich auto-detects terminal capabilities (color support, width, cursor positioning) via the Console. In an interactive terminal, all features work. When stdout is redirected (CI logs, piped to a file, captured by a tool), Rich falls back — strips colors, disables Live updates, and prints plain text. The behavior differs subtly across environments.

Live displays use cursor-control ANSI codes to redraw the same region. In an environment without cursor support (a CI log that just appends lines), each “update” creates a new line. Detecting and adapting to this is what console.is_terminal and force_terminal parameters control.

Fix 1: Color Output in CI

from rich.console import Console
console = Console()
console.print("[bold red]Error![/]")
# Local: red bold output
# CI: plain text [bold red]Error![/]

Rich strips formatting when it detects a non-interactive stdout — which is what CI logs look like. Force color on:

console = Console(force_terminal=True)

Or via environment variable (no code change needed):

FORCE_COLOR=1 python my_script.py

Detect what Rich sees:

console = Console()
print(f"is_terminal: {console.is_terminal}")
print(f"is_jupyter: {console.is_jupyter}")
print(f"color_system: {console.color_system}")   # 'truecolor', '256', 'standard', or None
print(f"width: {console.width}")

GitHub Actions specifically — set FORCE_COLOR=1 in the workflow:

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      FORCE_COLOR: "1"
    steps:
      - run: python my_rich_script.py

GitHub Actions logs support ANSI colors but doesn’t advertise as a TTY.

Common Mistake: Forcing color always (force_terminal=True) even when output is piped to a file or processed by another tool. ANSI codes in a file look like garbage:

mycli output > log.txt
# log.txt contains ^[[1;31mError!^[[0m instead of "Error!"

Better: only force color when you know the destination supports it (CI, specific env vars). Default Console() does the right thing for piped output.

Fix 2: Live Display Configuration

from rich.live import Live
from rich.table import Table
import time

table = Table("Iteration", "Status")
table.add_row("1", "starting")

with Live(table, refresh_per_second=4) as live:
    for i in range(10):
        time.sleep(1)
        # Update the table
        new_table = Table("Iteration", "Status")
        new_table.add_row(str(i), "running")
        live.update(new_table)

Key parameters:

ParameterMeaning
refresh_per_secondMax refresh rate (default 4); higher = smoother but more CPU
vertical_overflow"crop", "ellipsis", "visible" — how to handle content taller than terminal
auto_refreshIf True, Rich refreshes on schedule; if False, only live.refresh() triggers redraw
transientClear display when context exits (useful for one-shot status)

Flickering — usually means refresh_per_second is too high or the renderable is being rebuilt unnecessarily:

# WRONG — rebuilds table every iteration, then refreshes
with Live(refresh_per_second=20) as live:
    for i in range(1000):
        table = Table("ID", "Value")
        for j in range(i):
            table.add_row(str(j), str(j*2))
        live.update(table)
# Flickers because the table grows each frame

# CORRECT — update an existing table, low refresh rate
table = Table("ID", "Value")
with Live(table, refresh_per_second=4) as live:
    for i in range(1000):
        table.add_row(str(i), str(i*2))
        # Don't call live.update — auto-refresh picks up the change

No-flicker pattern for terminal dashboards:

from rich.live import Live
from rich.layout import Layout

layout = Layout()
layout.split_column(
    Layout(name="header", size=3),
    Layout(name="main"),
    Layout(name="footer", size=3),
)

with Live(layout, refresh_per_second=10, screen=True) as live:
    while True:
        layout["header"].update(get_header())
        layout["main"].update(get_main_content())
        layout["footer"].update(get_footer())
        time.sleep(0.1)

screen=True uses the alternate screen buffer (like vim or less) — restores the previous terminal contents on exit.

Fix 3: Progress Bars

from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeRemainingColumn

with Progress(
    SpinnerColumn(),
    TextColumn("[progress.description]{task.description}"),
    BarColumn(),
    TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
    TimeRemainingColumn(),
) as progress:
    task1 = progress.add_task("Downloading...", total=100)
    task2 = progress.add_task("Processing...", total=50)

    while not progress.finished:
        progress.update(task1, advance=0.5)
        progress.update(task2, advance=0.25)
        time.sleep(0.02)

Simple iteration helper:

from rich.progress import track

for item in track(items, description="Processing..."):
    do_work(item)

Why progress bars print new lines instead of updating in placetrack and Progress need cursor control. When stdout isn’t a TTY (CI, piped output), Rich falls back to line-by-line output.

For CI environments where you want minimal output:

from rich.progress import Progress, BarColumn

with Progress(BarColumn(), TextColumn("{task.percentage:>3.0f}%"),
              disable=not sys.stdout.isatty()) as progress:
    task = progress.add_task("Loading", total=100)
    for i in range(100):
        progress.update(task, advance=1)

disable=True entirely suppresses the progress bar — useful when piping to a log file.

Pro Tip: Always use track() for simple iteration over a fixed-length iterable. Reach for Progress(...) only when you have multiple parallel tasks, custom columns, or need to update progress descriptions mid-iteration. The simpler API covers 90% of cases.

Fix 4: Tables — Column Widths and Overflow

from rich.table import Table
from rich import box

table = Table(
    "ID", "Name", "Description",
    box=box.ROUNDED,
    show_lines=True,
)

table.add_row("1", "Alice", "A very long description that might overflow the column width")

Control column behavior:

from rich.table import Table, Column

table = Table(
    Column("ID", style="cyan", width=4),
    Column("Name", style="bold", min_width=10),
    Column("Description", overflow="fold"),   # fold | crop | ellipsis
    expand=True,   # Expand to full terminal width
)

Overflow options:

OptionBehavior
"fold"Wrap to next line
"crop"Cut off (default)
"ellipsis"Show … for cut content
"ignore"Allow column to extend beyond width

Sortable columns for interactive analysis:

from rich.table import Table

def make_table(rows, sort_by="value"):
    table = Table("Name", "Value", "Status")
    sorted_rows = sorted(rows, key=lambda r: r[sort_by])
    for row in sorted_rows:
        table.add_row(row["name"], str(row["value"]), row["status"])
    return table

Convert pandas DataFrame to Rich table:

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

def df_to_table(df, title=None):
    table = Table(title=title)
    for col in df.columns:
        table.add_column(str(col))
    for _, row in df.iterrows():
        table.add_row(*[str(v) for v in row])
    return table

console = Console()
console.print(df_to_table(df, title="Sales by Region"))

Fix 5: Syntax Highlighting

from rich.console import Console
from rich.syntax import Syntax

console = Console()

code = '''
def hello(name):
    return f"Hello, {name}"
'''

syntax = Syntax(code, "python", theme="monokai", line_numbers=True)
console.print(syntax)

Themes — bundled options:

Syntax(code, "python", theme="monokai")
Syntax(code, "python", theme="dracula")
Syntax(code, "python", theme="github-dark")
Syntax(code, "python", theme="solarized-dark")
Syntax(code, "python", theme="vs")   # Light theme

Full Pygments theme list works since Rich uses Pygments under the hood.

Highlight specific lines:

Syntax(
    code, "python",
    line_numbers=True,
    highlight_lines={3, 5, 7},   # Highlight rows 3, 5, 7
    word_wrap=True,
)

From a file:

syntax = Syntax.from_path("example.py", line_numbers=True)
console.print(syntax)

Fix 6: Pretty Printing and Inspect

from rich import print as rprint
from rich.pretty import pprint

data = {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "count": 2}

# Plain print — single line, hard to read
print(data)

# Rich print — colored, multi-line, syntax-aware
rprint(data)

# Pretty print with indentation control
pprint(data, indent_guides=True, max_string=80)

inspect() for objects — see methods, attributes, docstrings:

from rich import inspect

import requests
inspect(requests, methods=True, help=True)
# Beautiful table of all attributes, methods, and docs

inspect() is incredibly useful for exploring unfamiliar libraries — much faster than dir() + help().

Customize the global Rich print:

from rich import print
from rich.console import Console

console = Console(width=120, color_system="truecolor")
print = console.print   # Override built-in print globally (use with care)

Fix 7: Rich Traceback

from rich.traceback import install

install(show_locals=True)   # Replace default traceback handler

# Any unhandled exception now uses Rich's formatter
raise ValueError("something broke")

show_locals=True displays variable values at each stack frame — amazing for debugging but DON’T USE in production: it leaks sensitive data into logs:

install(
    show_locals=False,         # Don't show variables (safer)
    suppress=["sqlalchemy", "click"],   # Hide frames from these libraries
    max_frames=10,              # Limit traceback depth
)

Common Mistake: Calling rich.traceback.install() without show_locals=False in a server or CLI that runs in production. If an exception occurs, all local variables — including DB connection strings, API keys, raw passwords from request bodies — get logged. Either disable show_locals or only install Rich’s traceback during development.

Per-environment setup:

import os
from rich.traceback import install

if os.environ.get("ENV") != "production":
    install(show_locals=True)   # Dev only

Conflict with Loguru’s diagnose=True — both want to format exceptions. Pick one:

# If using Loguru with diagnose=True, skip rich.traceback.install()
from loguru import logger
logger.add(sys.stderr, backtrace=True, diagnose=True)

# Or use Rich and turn off Loguru's diagnose
import rich.traceback
rich.traceback.install()
logger.add(sys.stderr, diagnose=False, backtrace=False)

For Loguru traceback configuration, see Loguru not working.

Fix 8: Markdown, JSON, and Logging Integration

Render Markdown in terminal:

from rich.console import Console
from rich.markdown import Markdown

console = Console()
md = """
# Hello World

This is **bold** and *italic*.

- Item 1
- Item 2

```python
print("code block")

""" console.print(Markdown(md))


**Pretty-print JSON:**

```python
from rich.console import Console
from rich.json import JSON

console = Console()
console.print(JSON('{"name": "Alice", "age": 30}'))
console.print_json(data={"name": "Alice", "age": 30})

Logging handler:

import logging
from rich.logging import RichHandler

logging.basicConfig(
    level=logging.INFO,
    format="%(message)s",
    datefmt="[%X]",
    handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)

log = logging.getLogger("rich")
log.info("Hello, [bold green]World[/]!")
log.error("Something went wrong")

try:
    1 / 0
except ZeroDivisionError:
    log.exception("Math is broken")   # Rich traceback in log

Mixing Rich and Loguru — use Rich’s handler with stdlib logging, then intercept stdlib to Loguru:

from rich.logging import RichHandler
from loguru import logger
import logging
import sys

# Rich for development pretty output
if sys.stderr.isatty():
    handler = RichHandler()
    logging.basicConfig(handlers=[handler], level=logging.INFO, format="%(message)s")
else:
    # CI / file output — plain text via loguru
    logger.add(sys.stderr, format="{time} | {level} | {message}")

Still Not Working?

Rich vs Plain Text Output for CI

For tools that run in both interactive and CI contexts, conditional output is cleaner than always using Rich:

import sys
from rich.console import Console

if sys.stdout.isatty():
    console = Console()
    console.print("[bold green]Success![/]")
else:
    print("Success!")

Or use a single Console with smart fallback:

console = Console(force_terminal=False, no_color=not sys.stdout.isatty())

Rich and Click/Typer Integration

Typer uses Rich for help output and tracebacks by default. For Typer-specific patterns that interact with Rich’s behavior, see Typer not working.

To customize Click’s output similarly:

import click
from rich.console import Console

console = Console()

@click.command()
def greet(name):
    console.print(f"[bold]Hello[/], [cyan]{name}[/]")

Width Detection in Containers

Docker and Kubernetes containers often report no width (returns 80 by default). Force a wider console:

console = Console(width=160)   # Force fixed width for containers

Or detect via env var:

import os
console = Console(width=int(os.environ.get("COLUMNS", 120)))

Performance with Large Tables

Rich tables build all rows in memory before rendering. For tables with 100k+ rows, this is slow and uses huge memory. Alternative: paginate the rendering:

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

def stream_table(rows, page_size=50):
    console = Console()
    for i in range(0, len(rows), page_size):
        table = Table("ID", "Name")
        for row in rows[i:i + page_size]:
            table.add_row(str(row["id"]), row["name"])
        console.print(table)
        if i + page_size < len(rows):
            input("Press enter for next page...")

For testing patterns with Rich-based output capture, see pytest fixture not found. For pre-commit hooks that validate Rich-formatted help text, see pre-commit not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles