Skip to content

ljust and %-8s pad by code points, not columns

Level: 101 · anyone printing a table to a terminal

One line: ljust(8) and format("%-8s") count code points, so a decomposed café comes out one column short while 緑茶 and 🍵 — two terminal columns per character — push the bar to the right; String has no display-width method, and the example borrows the one inside Reline, irb's line editor.

Verified output of padding_counts_characters_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

ljust(8):
  tea     |3
  café    |4
  café   |4
  緑茶      |5
  🍵       |6

format("%-8s|%d"):
  tea     |3
  café    |4
  café   |4
  緑茶      |5
  🍵       |6

Three counts of the same five names:
  tea      length 3, grapheme_clusters 3, terminal columns 3
  café     length 4, grapheme_clusters 4, terminal columns 4
  café     length 5, grapheme_clusters 4, terminal columns 4
  緑茶     length 2, grapheme_clusters 2, terminal columns 4
  🍵       length 1, grapheme_clusters 1, terminal columns 2

Padding by columns instead:
  tea     |3
  café    |4
  café    |4
  緑茶    |5
  🍵      |6

Whether the blocks on this page line up depends on the fonts your browser picks for the Japanese and the emoji. A terminal is the reference: there, the first two tables are ragged and the last one is straight.

Reading the output

  • The first two tables pad by code points. ljust and format agree with each other, and both are wrong for three of the five rows.
  • Three counts, side by side. The decomposed café is 5 code points and 4 grapheme clusters in 4 columns. 緑茶 is 2 code points and 2 grapheme clusters in 4 columns. 🍵 is 1 of each in 2 columns. Grapheme clusters fix the combining accent and nothing else; only the column count predicts alignment.
  • Padding by columns lines up. The last table computes 8 - width spaces per name.

What to do

For text that ends up in a terminal table, pad by display width. Reline::Unicode.calculate_width, used above, is internal to Reline rather than a public API; the unicode-display_width gem is the usual choice in code you ship. (Not machine-checked here.)

If you are coming from another language

Python's format spec pads by code points too:

Verified output of padding_counts_characters_py.py — regenerated by tools/run_examples.py, never hand-typed.

f'{name:<8}|{price}':
  tea     |3
  café    |4
  café   |4
  緑茶      |5
  🍵       |6

The Python library's The format mini-language ↗ and the Rust library's The format mini-language ↗ take apart the width field in their own languages.