Colour makes a CLI's output faster to scan: green for healthy, red for failed, dim for less important detail. It also causes some of the most irritating bugs in command-line tools — escape codes like \x1b[32m sprinkled through files, grep results, CI logs and data piped into other programs — and it ignores users who simply do not want it, whether for accessibility, readability on their terminal theme, or preference. Two community conventions exist precisely to settle who decides: NO_COLOR (colour off, from any tool) and FORCE_COLOR (colour on, even when the output is not a terminal). This guide implements colour handling that respects both, adds a --color flag that beats them, routes everything through one Rich console so nothing bypasses the decision, and tests every combination. It belongs to the cross-platform terminal compatibility topic.
Prerequisites
- A CLI that uses Rich for styled output (Typer includes it), or Click's
style/secho. - A habit of never writing raw ANSI escape sequences yourself — if you have them, this guide replaces them.
Who decides whether to colour
The decision follows the same precedence as every other setting — explicit command-line choice first, then the environment, then automatic detection:
--color=always|never|autoon the command line. The user said exactly what they want for this invocation.NO_COLOR: if set to any non-empty value, do not emit colour. It is how users opt out globally, across every tool that follows the convention.FORCE_COLOR: emit colour even though output is not a terminal. CI systems and log viewers that render ANSI codes set it so logs keep their colour.- Automatic: colour only when writing to a terminal whose
TERMis notdumb.
Note the subtlety in NO_COLOR's definition: it asks tools not to add colour; bold and underline are still allowed. Rich follows that reading, which is why NO_COLOR output may still contain bold escape codes when it goes to a terminal — and why a separate --color never that disables all styling is still worth offering.
Who already does this for you
Rich's Console implements most of the decision automatically: it detects whether its file is a terminal, respects TERM=dumb, strips colour when NO_COLOR is set, and forces styling when FORCE_COLOR is set. Click's echo strips ANSI codes when the output is not a terminal. The recurring bug is code that bypasses these: hand-written escape strings, print() of pre-styled text, or a second Console created somewhere with different settings. The fix is structural — make one console, from one decision, and use it everywhere.
The recipe
# src/mytool/color.py
from __future__ import annotations
from enum import Enum
from rich.console import Console
class ColorMode(str, Enum):
auto = "auto"
always = "always"
never = "never"
def make_console(mode: ColorMode = ColorMode.auto, *, stderr: bool = False) -> Console:
"""One place that decides styling.
auto -> Rich decides: TTY and TERM, then NO_COLOR (strip colour) and FORCE_COLOR (force it)
always -> styled output even into pipes and files (the flag beats the environment)
never -> no escape codes at all
"""
if mode is ColorMode.always:
return Console(stderr=stderr, force_terminal=True)
if mode is ColorMode.never:
return Console(stderr=stderr, color_system=None, force_terminal=False)
return Console(stderr=stderr)
# src/mytool/cli.py
from __future__ import annotations
from typing import Annotated
import typer
from mytool.color import ColorMode, make_console
app = typer.Typer()
STATUS = [("web", "healthy"), ("api", "degraded"), ("billing", "down")]
STYLE = {"healthy": "green", "degraded": "yellow", "down": "bold red"}
MARK = {"healthy": "✓", "degraded": "!", "down": "✗"}
@app.callback()
def main(
ctx: typer.Context,
color: Annotated[ColorMode, typer.Option("--color", envvar="MYTOOL_COLOR",
help="Colour output: auto, always or never.")] = ColorMode.auto,
) -> None:
"""Service status tool."""
ctx.obj = make_console(color)
@app.command()
def status(ctx: typer.Context) -> None:
"""Show service health."""
console = ctx.obj
for name, state in STATUS:
console.print(f"{MARK[state]} {name:<8} [{STYLE[state]}]{state}[/]", highlight=False)
if __name__ == "__main__":
app()
How it fits together:
- The flag has three values —
auto,always,never— the same trio used bygit,grep,lsand many others.autois the default. autodelegates to Rich, which applies TTY detection,TERM=dumb,NO_COLORandFORCE_COLOR. There is no reason to reimplement that logic.alwaysusesforce_terminal=True, producing styled output even into a pipe — useful formytool status --color always | less -R.neverusescolor_system=None, which disables all styling escape codes, including bold — stricter thanNO_COLOR, for users and scripts that want guaranteed plain text.envvar="MYTOOL_COLOR"lets users set a tool-specific default in their shell profile, which still loses to an explicit flag.- One console, created in the callback and passed down on the context. Commands never construct their own.
- Colour never carries meaning alone. Each status has a symbol and a word; colour only reinforces them. With
NO_COLOR, output loses decoration, not information.
For narration on stderr — progress, warnings — create a second console with stderr=True from the same mode, so both streams follow the same rule. Styling conventions for the palette itself are covered in theming Rich output consistently.
UX considerations
- Plain output in pipes by default.
mytool status | grep downshould match plain text, not text wrapped in escape codes. Rich's automatic detection does this; code that forces styling does not. - Keep colour off stdout data. Machine output (
--json) should never be styled, whatever the colour mode; styling is for human views. - Choose theme-safe colours. Use named ANSI colours (
red,green,yellow) for status, which terminal themes remap to readable shades, rather than fixed RGB values that disappear on light or dark backgrounds. - Document the controls. One line in
--helpand a sentence in the docs aboutNO_COLOR,FORCE_COLORandMYTOOL_COLORsaves users hunting for how to turn colour off.
Testing the behaviour
CliRunner output is not a terminal, which makes it perfect for checking the pipe case, and its env= parameter reaches every environment combination. Testing for the escape-sequence prefix \x1b[ is enough to tell styled from plain output:
# tests/test_color.py
import pytest
from typer.testing import CliRunner
from mytool.cli import app
runner = CliRunner()
ESC = "\x1b["
def run(*args, env=None):
base = {"NO_COLOR": "", "FORCE_COLOR": "", "TERM": "xterm-256color"}
return runner.invoke(app, [*args, "status"], env={**base, **(env or {})})
def test_auto_into_a_pipe_is_plain():
assert ESC not in run().output
def test_always_beats_no_color():
assert ESC in run("--color", "always", env={"NO_COLOR": "1"}).output
def test_never_beats_force_color():
assert ESC not in run("--color", "never", env={"FORCE_COLOR": "1"}).output
def test_force_color_applies_in_auto_mode():
assert ESC in run(env={"FORCE_COLOR": "1"}).output
def test_env_var_can_set_the_mode():
assert ESC in run(env={"MYTOOL_COLOR": "always"}).output
@pytest.mark.parametrize("mode", ["auto", "always", "never"])
def test_meaning_survives_without_colour(mode):
out = run("--color", mode).output
assert "down" in out and "✗" in out
The base environment clears NO_COLOR and FORCE_COLOR so the developer's own settings — or the CI system's — cannot leak into the results. The last test is the one that protects accessibility: in every mode, the words and symbols that carry meaning are present.
Conclusion
Respecting colour conventions is mostly a matter of not fighting the tools that already implement them. Offer --color auto|always|never (with an environment variable for a personal default), let Rich apply NO_COLOR, FORCE_COLOR, TTY and TERM detection in auto mode, force or disable styling explicitly for the other two, and route every styled line through one console created from that decision. Make sure meaning never depends on colour, keep machine output unstyled, and test each mode with CliRunner.
Frequently asked questions
Should NO_COLOR also disable bold and emoji?
The convention covers colour only, so bold is allowed. If you want a mode with no styling at all, --color never provides it. Emoji are a separate question of font support, discussed in the topic overview.
What about CLICOLOR and CLICOLOR_FORCE?
These older BSD-era variables have similar meanings (CLICOLOR=0 off, CLICOLOR_FORCE=1 on). Supporting them costs a couple of lines in the callback — map them to the corresponding mode when neither the flag nor NO_COLOR/FORCE_COLOR is set — and pleases users of BSD and macOS tools.
What about colour on older Windows consoles?
Windows 10 and later understand ANSI escape sequences once virtual-terminal processing is enabled for the console, which Rich does automatically, and Windows Terminal supports truecolour. On the rare legacy console that cannot, Rich falls back to the Windows console API or to plain text. Either way the same make_console decision applies, and the --color never escape hatch works everywhere.
Does GitHub Actions show colour?
The Actions log viewer renders ANSI colours, but the job's stdout is not a TTY, so tools default to plain output. Setting FORCE_COLOR=1 in the workflow environment restores colour for Rich-based tools, which many teams do for readability.
Should Typer's own help and error output follow the flag?
Typer renders help and errors with Rich, which follows NO_COLOR and TTY detection. The global --color flag cannot affect help printed before it is parsed, but it rarely needs to: help goes to a terminal in interactive use and is plain in pipes already.