Fix: Rich Not Working — Live Display Issues, Color in CI, and Console Configuration
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 constantlyOr 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 placeOr table columns truncate or wrap badly:
from rich.table import Table
table = Table("ID", "Name", "Description")
# Description column overflows; "ID" column too wideOr install_rich_traceback() conflicts with another logging system:
from rich.traceback import install
install()
# Tracebacks look good, but sentry/loguru/structlog now breakRich 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.pyDetect 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.pyGitHub 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:
| Parameter | Meaning |
|---|---|
refresh_per_second | Max refresh rate (default 4); higher = smoother but more CPU |
vertical_overflow | "crop", "ellipsis", "visible" — how to handle content taller than terminal |
auto_refresh | If True, Rich refreshes on schedule; if False, only live.refresh() triggers redraw |
transient | Clear 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 changeNo-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 place — track 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:
| Option | Behavior |
|---|---|
"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 tableConvert 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 themeFull 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 docsinspect() 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 onlyConflict 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 logMixing 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 containersOr 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Click Not Working — Group Setup, Context Passing, and Parameter Type Errors
How to fix Click errors — UsageError missing argument, Group has no command, ctx.obj not passing between commands, ParamType validation failed, BadOptionUsage no such option, pass_context required, and lazy loading groups.
Fix: Typer Not Working — Argument Errors, Autocomplete, and Subcommand Issues
How to fix Typer errors — type annotation required error, Optional argument parsing, boolean flag conventions, autocomplete installation failed, nested commands not found, and rich traceback disable.
Fix: pre-commit Not Working — Hooks Not Running, Install Failures, and CI Issues
How to fix pre-commit errors — hooks not triggering on commit, pre-commit install failed, repo local hook not found, autoupdate not working, CI environment cache issues, and skip specific hooks.
Fix: Ruff Not Working — Configuration Errors, Rule Selection, and Format vs Lint Confusion
How to fix Ruff errors — pyproject.toml configuration not applied, rule code unknown, ruff format vs ruff check confusion, ignore not working, per-file-ignores, line-length conflicts, and migrating from Flake8 Black isort.