pretty-table

Print aligned and formatted tables

pretty-table provides two table types plus one owning helper:

  • Table(N) — fixed-size rows and fixed column count, both known at comptime.
  • Table(N).Owned — runtime row collection for Table(N), while keeping the same fixed column count.
  • RuntimeTable — both row count and column count are driven at runtime.

The key constraint is Zig's comptime generics: Table(N) requires N to be a compile-time constant. If the column count comes from runtime data (e.g. parsing a CSV header), RuntimeTable is the only option.

Features

  • Shared across all three APIs: ascii / box / dos borders, optional header/footer rows, per-column alignment, row separators, optional transpose mode, and both render(writer) / "{f}" formatting support.
  • All three APIs can also render styled Cell values.
  • RuntimeTable also supports runtime column counts and terminal-width truncation via max_width.

Cell API

Cell is the shared building block for all three APIs.

  • Cell.init(text) creates a normal cell.
  • Cell.span() creates a placeholder cell consumed by a spanning cell.
  • cell.withBold(), cell.withItalic(), cell.withFg(color), cell.withBg(color) return modified copies.
  • cell.withHspan(n) marks the cell as spanning n columns.

When using withHspan(n) in Table(N) or Table(N).Owned, you must fill the following n - 1 positions in the row with Cell.span() placeholders.

Usage

See pretty-table-demo.zig.

Basic table

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const zigcli = @import("zigcli");
const pt = zigcli.pretty_table;
const Table = pt.Table;
const Cell = pt.Cell;

const t = Table(2){
    .header = [_]Cell{ Cell.init("Language"), Cell.init("Files") },
    .rows = &[_][2]Cell{
        .{ Cell.init("Zig"), Cell.init("3") },
        .{ Cell.init("Python"), Cell.init("2") },
    },
    .footer = [2]Cell{ Cell.init("Total"), Cell.init("5") },
    .mode = .box,
    .padding = 1,
};

const out = std.fs.File.stdout();
var buf: [4096]u8 = undefined;
var writer = out.writer(&buf);
try writer.interface.print("{f}", .{t});
try writer.interface.flush();
┌──────────┬───────┐
│ Language │ Files │
├──────────┼───────┤
│ Zig      │ 3     │
│ Python   │ 2     │
├──────────┼───────┤
│ Total    │ 5     │
└──────────┴───────┘

Right-aligned columns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const t = Table(3){
    .header = [_]Cell{ Cell.init("Name"), Cell.init("Score"), Cell.init("Rank") },
    .rows = &[_][3]Cell{
        .{ Cell.init("Alice"), Cell.init("9800"), Cell.init("1") },
        .{ Cell.init("Bob"), Cell.init("7500"), Cell.init("2") },
    },
    .mode = .box,
    .padding = 1,
    .column_align = .{ .left, .right, .right },
};
┌───────┬───────┬──────┐
│ Name  │ Score │ Rank │
├───────┼───────┼──────┤
│ Alice │  9800 │    1 │
│ Bob   │  7500 │    2 │
└───────┴───────┴──────┘

Row separators

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const t = Table(2){
    .header = [_]Cell{ Cell.init("Step"), Cell.init("Status") },
    .rows = &[_][2]Cell{
        .{ Cell.init("Build"), Cell.init("OK") },
        .{ Cell.init("Test"), Cell.init("OK") },
        .{ Cell.init("Deploy"), Cell.init("PENDING") },
    },
    .padding = 1,
    .row_separator = true,
};

Transpose mode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const t = Table(2){
    .header = [_]Cell{ Cell.init("Name"), Cell.init("Score") },
    .rows = &[_][2]Cell{
        .{ Cell.init("Alice"), Cell.init("10") },
        .{ Cell.init("Bob"), Cell.init("200") },
    },
    .padding = 1,
    .column_align = .{ .left, .right },
    .transpose = true,
};

Per-cell styling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const t = Table(2){
    .header = [_]Cell{
        Cell.init("Service").withFg(.bright_white).withBold(),
        Cell.init("Status").withFg(.bright_white).withBold(),
    },
    .rows = &[_][2]Cell{
        .{ Cell.init("web"), Cell.init("UP").withFg(.green) },
        .{ Cell.init("cache"), Cell.init("DOWN").withFg(.red).withBold() },
    },
    .mode = .box,
    .padding = 1,
};

term.Style.Color supports the standard ANSI colors plus bright variants: black, red, green, yellow, blue, magenta, cyan, white, and bright_* variants such as bright_cyan and bright_white.

Column spanning

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const t = Table(4){
    .header = [_]Cell{
        Cell.init("Name"), Cell.init("Q1"), Cell.init("Q2"), Cell.init("Q3"),
    },
    .rows = &[_][4]Cell{
        .{ Cell.init("Alice"), Cell.init("90"), Cell.init("85"), Cell.init("92") },
        .{
            Cell.init("Bob"),
            Cell.init("N/A (on leave)").withHspan(3),
            Cell.span(),
            Cell.span(),
        },
    },
    .mode = .box,
    .padding = 1,
};
┌───────┬────┬────┬──────┐
│ Name  │ Q1 │ Q2 │ Q3   │
├───────┼────┼────┼──────┤
│ Alice │ 90 │ 85 │ 92   │
│ Bob   │ N/A (on leave) │
└───────┴────┴────┴──────┘

The key rule is that only the first cell in the span carries text and withHspan(...). The remaining covered columns must be explicit Cell.span() placeholders.

Table.Owned (runtime rows)

When the number of rows is not known at comptime, use Table(N).Owned. Column count is still comptime-fixed for type safety, but rows are stored internally in an ArrayList and rendered through the same Table(N) logic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const zigcli = @import("zigcli");
const pt = zigcli.pretty_table;

var table = pt.Table(3).Owned.init(.{
    .mode = .box,
    .padding = 1,
    .column_align = .{ .left, .right, .right },
    .row_separator = false,
});
defer table.deinit(allocator);

table.setHeader(.{ "Name", "Score", "Rank" });
table.setFooter(.{ "Total", "", "—" });

// String literals are auto-wrapped in Cell.init().
try table.addRow(allocator, .{ "Alice", "9800", "1" });
try table.addRow(allocator, .{ "Bob", "7500", "2" });

// Use addRowCells for per-cell styling.
try table.addRowCells(allocator, .{
    pt.Cell.init("Charlie").withBold(),
    pt.Cell.init("9900").withFg(.green),
    pt.Cell.init("1").withFg(.green),
});

try writer.interface.print("{f}", .{table});
try writer.interface.flush();

Table(N).Owned converts plain string tuples to Cell.init(...) automatically in setHeader(), setFooter(), and addRow(). Use addRowCells() when you need styling or spanning.

RuntimeTable (runtime columns)

When the column count is only known at runtime, use RuntimeTable. It provides a builder API with setHeader() / setFooter() (string slices), setHeaderCells() / setFooterCells() (Cell values), addRow() (string slices), and addRowCells() (Cell values, including styling).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const zigcli = @import("zigcli");
const pt = zigcli.pretty_table;

var table = try pt.RuntimeTable.init(allocator, 3);
defer table.deinit();
table.mode = .box;
table.padding = 1;
table.column_align = &.{ .left, .right, .left };
table.row_separator = true;
table.transpose = false;

try table.setHeader(&.{ "Name", "Age", "City" });
try table.addRow(&.{ "Alice", "30", "Beijing" });
try table.addRow(&.{ "Bob", "25", "New York" });
try table.setFooter(&.{ "Total", "2", "" });
try writer.print("{f}", .{table});
┌───────┬─────┬──────────┐
│ Name  │ Age │ City     │
├───────┼─────┼──────────┤
│ Alice │ 30  │ Beijing  │
│ Bob   │ 25  │ New York │
└───────┴─────┴──────────┘

Use setHeaderCells / setFooterCells and addRowCells for per-cell styling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
try table.setHeaderCells(&.{
    pt.Cell.init("Name").withBold(),
    pt.Cell.init("Age").withBold(),
    pt.Cell.init("City").withBold(),
});

try table.addRowCells(&.{
    pt.Cell.init("Charlie").withBold(),
    pt.Cell.init("35"),
    pt.Cell.init("London").withFg(.green),
});

try table.setFooterCells(&.{
    pt.Cell.init("Total").withFg(.yellow),
    pt.Cell.init("3").withFg(.yellow),
    pt.Cell.init("").withFg(.yellow),
});

RuntimeTable now supports header/footer rows, header/footer cell setters, per-column alignment, row separators, and "{f}" formatting. Unlike Table(N), it still does not support hspan.

Truncation with max_width

RuntimeTable auto-detects the terminal width and truncates cells to fit. In most cases no configuration is needed — just leave max_width at its default (0).

To override, set max_width explicitly:

1
table.max_width = 80; // 0 (default) = auto-detect terminal.

Transpose mode

Set table.transpose = true; to render each row as a vertical key-value block. If a header is present, header cells are used as the left-hand keys.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var table = try pt.RuntimeTable.init(allocator, 3);
defer table.deinit();

table.mode = .box;
table.padding = 1;
table.transpose = true;

try table.setHeader(&.{ "Name", "Age", "City" });
try table.addRow(&.{ "Alice", "30", "Beijing" });
try table.addRow(&.{ "Bob", "25", "New York" });
try table.render(writer);
┌──────┬─────────┐
│ Name │ Alice   │
│ Age  │ 30      │
│ City │ Beijing │
└──────┴─────────┘

┌──────┬──────────┐
│ Name │ Bob      │
│ Age  │ 25       │
│ City │ New York │
└──────┴──────────┘

Output APIs

  • Table(N), Table(N).Owned, and RuntimeTable all implement format, so you can print them with "{f}".
  • RuntimeTable also keeps an explicit render(writer) method.

For Zig 0.15's new I/O API, a common pattern is:

1
2
3
4
5
6
const out = std.fs.File.stdout();
var buf: [4096]u8 = undefined;
var writer = out.writer(&buf);

try writer.interface.print("{f}", .{table});
try writer.interface.flush();
Last modified March 22, 2026: chore: bump to 0.5.0 (8c7d27e)