Skip to main content
ansicode
Erlang

ANSI escape codes in Erlang — io:format, \e, cf, OTP logger

Erlang string literals accept the widest set of ESC forms of any language documented here — `"\e"` (named ESC), `"\x1b"` (hex), `"\033"` (octal — Erlang's `\NNN` IS octal, same as C), AND `"\^["` (caret-notation control character — `^A` through `^Z` map to ASCII 1..26 and `^[` maps to ASCII 27 = ESC). Strings in Erlang are charlists (lists of integers); binaries `<<"...">>` are first-class too. `io:format("\e[1;31merror:\e[0m ~s~n", [Msg])` is the canonical Erlang one-liner — `~s` interpolates the second-argument list, `~n` is the platform newline, and ANSI bytes pass through `io:format/2` unchanged to the calling process's `group_leader` (which for shell-attached code is the user's terminal). For SGR helpers reach for **`cf`** (project-fifo/cf — "Erlang Colour Format") — extends `io:format`'s `~` directive syntax with `~!r` (red), `~!g` (green), `~!b` (blue), `~!_` (bold), `~!!` (reset), etc. `cf:format("~!rerror:~!! permission denied~n")` reads more concisely than the raw-byte form. For structured logging reach for OTP's stdlib **`logger`** module (built into OTP 21+ — Mar 2018) — `logger:info("started", #{port => 8080})` produces a coloured per-level prefix when the default handler is on a TTY, JSON on redirect. For the older pre-OTP-21 codebases you still see in production reach for **`lager`** (basho's structured logger — the canonical pre-logger choice, ANSI-coloured per-level prefixes, sink-based per-app filtering). **The OTP release-mode caveat** (page's primary SERP differentiator): every Erlang process has a `group_leader` that handles its I/O. In an interactive shell or escript, `group_leader` IS the user's terminal — ANSI passes through. When you build an OTP release (`rebar3 release` → `bin/myapp daemon` or `foreground`), the `group_leader` is reassigned: in `daemon` mode it's a logger process that writes to `log/erlang.log.N`, and ANSI bytes appear **literally** in the log file as `\e[31m...` text. The fix: use the `logger` module's coloured handler (which strips ANSI when its output destination is not a TTY — auto-detection via `io:rows()` returning `{error, enotsup}`), or detect via `application:get_env(kernel, standard_io_handler)` and gate your `io:format` colour calls explicitly. Capability gating: `io:rows()` returns `{ok, Rows}` when stdout is a TTY, `{error, enotsup}` when redirected. Pair with `os:getenv("NO_COLOR")` for the universal opt-out. Modern Erlang shell (`erl`, OTP 25+) parses ANSI in `io:format` output natively on all platforms including Windows (the BEAM emulator handles `SetConsoleMode` on first ANSI byte).

Recommended libraries

  • io / io_lib (stdlib)

    OTP stdlib's printf — `io:format(Format, Args)` writes to the calling process's `group_leader`, `io_lib:format/2` returns a charlist instead. Format directives: `~s` (string/charlist), `~p` (term inspection), `~w` (raw term), `~B` (integer base 10), `~.16B` (hex), `~n` (platform newline). ANSI bytes embedded in the format string pass through unchanged. `io:format("\e[31m~s\e[0m~n", [Msg])` is the canonical Erlang colour one-liner.

  • cf

    "Erlang Colour Format" — extends `io:format`'s `~` directive syntax with `~!r` (red), `~!g` (green), `~!b` (blue), `~!y` (yellow), `~!m` (magenta), `~!c` (cyan), `~!w` (white), `~!_` (bold), `~!!` (reset). `cf:format("~!rerror:~!! ~s~n", [Msg])` reads more concisely than raw escape bytes. `cf:format/1,2` returns the formatted charlist; `cf:print/1,2` writes to standard_io. Auto-disables when stdout is not a TTY.

  • logger (stdlib)

    OTP 21+ stdlib structured logger — replaced `error_logger`. `logger:info("started", #{port => 8080})` emits a structured event; the default handler (`logger_std_h`) formats it with `[INFO]` in green when stdout is a TTY, plain JSON on redirect. Per-module log levels via `application:set_env(kernel, logger_level, debug)`. The right call for any new Erlang code; pre-21 codebases use `lager`.

  • lager

    Basho's structured logger — the pre-OTP-21 canonical Erlang logging library, still widely used in older codebases (Riak, RabbitMQ, lasse, vmq_server). `lager:info("started on port ~p", [Port])` produces a coloured `[info]` prefix when its console backend detects a TTY. Sink-based per-application filtering, async batching, file rotation built-in. Reach for it when you're maintaining a pre-2018 OTP codebase that hasn't migrated to `logger`.

Idiomatic patterns

Direct io:format — \e + \033 octal + \x1b hex + \^[ control-char
%% Erlang accepts FOUR ESC forms in "..." literals.
%% All compile to the same single byte (0x1b = 27).
%%
%%   "\e"     — named ESC escape (most readable)
%%   "\033"   — \NNN is OCTAL (same as C — \033 = decimal 27)
%%   "\x1b"   — hex (exactly 2 digits)
%%   "\^["    — caret-notation control character (^A..^Z map
%%              to ASCII 1..26; ^[ maps to ASCII 27 = ESC)
%%
%% Erlang follows C's octal convention (unlike Nim and OCaml
%% which use decimal). When porting from C, "\033" works
%% unchanged. When porting from Nim/OCaml, "\27" silently
%% mis-compiles to the wrong byte — use "\e" or "\x1b".

-module(ansi_demo).
-export([main/0]).

main() ->
    io:format("\e[1;31merror:\e[0m permission denied~n"),
    io:format("\033[33mwarn:\033[0m deprecated flag~n"),
    io:format("\x1b[32mok:\x1b[0m 142 tests passed~n"),
    io:format("\^[[36minfo:\^[[0m skipping cache~n"),

    %% Truecolor — 38;2;R;G;B
    io:format("\e[38;2;255;128;0morange truecolor\e[0m~n"),

    %% 256-palette — 38;5;n
    io:format("\e[38;5;196mbright red\e[0m~n"),

    %% String interpolation via ~s — passes the list through:
    Msg = "permission denied",
    io:format("\e[1;31merror:\e[0m ~s~n", [Msg]),

    %% io_lib:format returns the formatted charlist
    %% instead of writing — handy for building log lines:
    Line = io_lib:format("\e[32mok:\e[0m ~B tests passed~n", [142]),
    io:put_chars(Line).

%% Reusable helper — pure Erlang:
esc(Sgr, Text) ->
    io_lib:format("\e[~sm~s\e[0m", [Sgr, Text]).
cf — Colour Format extends ~ directive with ~!r ~!g ~!!
%% rebar.config:
%%   {deps, [{cf, "0.3.1"}]}.
%%
%% Then `rebar3 compile` and use:

-module(cf_demo).
-export([main/0]).

main() ->
    %% cf:format extends ~ directives with colour:
    %%   ~!r — red    ~!g — green   ~!b — blue
    %%   ~!y — yellow ~!m — magenta ~!c — cyan
    %%   ~!w — white  ~!_ — bold    ~!! — reset
    cf:print("~!rerror:~!! permission denied~n"),
    cf:print("~!gok:~!! 142 tests passed~n"),
    cf:print("~!yawarn:~!! deprecated flag~n"),

    %% Capital letter = background:
    cf:print("~!_~!Y ~!K~!RCAUTION~!! brakes wet~n"),
    %% ~!_ = bold start; ~!Y = bg yellow; ~!K = fg black; ~!R = fg red

    %% Combine with regular io:format directives:
    Port = 8080,
    cf:print("~!c[INFO]~!! started on port ~p~n", [Port]),

    Tests = 142, Failed = 3,
    cf:print(
        "~!grun:~!! ~B tests, ~!r~B failed~!!~n",
        [Tests, Failed]
    ),

    %% cf:format/1,2 returns the formatted iolist
    %% (use cf:print/1,2 to write to standard_io):
    Line = cf:format("~!_~!cFATAL~!!: ~s~n", ["server crashed"]),
    io:put_chars(Line),

    %% Auto-disables when stdout is not a TTY — try:
    %%   rebar3 shell --script script.escript | cat
    %% cf detects via io:rows() and emits plain text when
    %% the call returns {error, enotsup}.
    ok.
logger — OTP stdlib structured logging with coloured handler
%% OTP 21+ stdlib — no dependency needed.
%% sys.config (or application:set_env at boot):
%%
%%   [{kernel, [
%%       {logger_level, info},
%%       {logger, [
%%           {handler, default, logger_std_h,
%%               #{config => #{type => standard_io},
%%                 formatter => {logger_formatter,
%%                     #{template =>
%%                         [time, " [", level, "] ",
%%                          msg, "\n"],
%%                       single_line => true,
%%                       legacy_header => false}}}}
%%       ]}
%%   ]}].

-module(logger_demo).
-export([main/0]).

main() ->
    %% Plain structured logging — coloured level prefix
    %% on TTY, plain text on redirect, JSON if you swap
    %% the formatter for logger_logstash_formatter:
    logger:debug("trace: entering handler"),
    logger:info("started on port ~p", [8080]),
    logger:warning("slow query: ~p ms", [1240]),
    logger:error("auth failed for user ~p", [42]),

    %% Structured form with a metadata map:
    logger:info("request received",
                #{method => "GET", path => "/users/42"}),

    %% Per-module level — set at runtime:
    logger:set_module_level(?MODULE, debug),
    logger:debug("now visible after set_module_level"),

    %% Per-handler config — change formatting / destination
    %% without touching call sites:
    logger:update_handler_config(default, #{
        formatter => {logger_formatter,
            #{template => [time, " ", level, ": ", msg, "\n"],
              single_line => true}}
    }),

    ok.
Capability gate + OTP release-mode group_leader caveat
%% Capability gating in Erlang:
%%   io:rows()             — {ok, Rows} when TTY,
%%                           {error, enotsup} when redirected
%%   os:getenv("NO_COLOR") — universal opt-out env var
%%   os:type()             — {unix, _} | {win32, _}
%%
%% OTP RELEASE-MODE CAVEAT (page's primary SERP differentiator):
%% Every Erlang process has a group_leader handling its I/O.
%% In a shell or escript, group_leader IS the user's terminal —
%% ANSI passes through. In an OTP release run as a daemon
%% (rebar3 release → bin/myapp daemon), group_leader is
%% reassigned to a logger process writing to log/erlang.log.N,
%% and ANSI bytes appear LITERALLY in the log file as
%% "\e[31m...". The fix: use the OTP logger module with a
%% coloured handler (auto-strips ANSI when not on a TTY), or
%% gate your io:format colour calls explicitly.

-module(cap_demo).
-export([ansi_capable/0, styled/2, main/0]).

ansi_capable() ->
    case os:getenv("NO_COLOR") of
        false ->
            case os:getenv("FORCE_COLOR") of
                false ->
                    %% io:rows() returns {ok, _} only on TTY:
                    case io:rows() of
                        {ok, _}     -> true;
                        {error, _}  -> false
                    end;
                _ -> true
            end;
        _ -> false
    end.

styled(Text, Sgr) ->
    case ansi_capable() of
        true  -> io_lib:format("\e[~sm~s\e[0m", [Sgr, Text]);
        false -> Text
    end.

main() ->
    %% Plain io:format with embedded ANSI works fine
    %% from the shell. In a release daemon, those bytes
    %% would land literally in log/erlang.log.N.
    io:format("~s~n", [styled("OK",   "32")]),
    io:format("~s~n", [styled("FAIL", "1;31")]),

    %% Detect release-mode group_leader explicitly:
    GL = group_leader(),
    case erlang:process_info(GL, registered_name) of
        {registered_name, logger_std_h_default} ->
            io:format("(running under release logger — "
                      "colour calls would land in log file)~n");
        _ ->
            io:format("(running under shell group_leader — "
                      "colour passes through to terminal)~n")
    end,

    %% Force-emit via 'standard_error' which retains the
    %% terminal even under some release configurations:
    io:format(standard_error, "~s ~s~n",
              [styled("DEBUG", "36"), "checkpoint hit"]).

Related sequences

Other languages