Skip to content

The format mini-language

Level: 201 · working knowledge

One line: {:>8.3} is not Rust syntax — it is a small language of its own, parsed by std::fmt, whose entire grammar fits in nine lines and whose one real surprise is that a type is allowed to ignore any of it.

let name = "ab";
println!("[{name:*^8}]");        // [***ab***]
println!("[{:>8.3}]", 3.14159);  // [   3.142]

The whole grammar

std states it in nine lines, and there is nothing else to learn:

format       := '{' [ argument ] [ ':' format_spec ] [ ws ] * '}'
argument     := integer | identifier

format_spec  := [[fill]align][sign]['#']['0'][width]['.' precision][type]
fill         := character
align        := '<' | '^' | '>'
sign         := '+' | '-'
width        := count
precision    := count | '*'
type         := '?' | 'x?' | 'X?' | 'o' | 'x' | 'X' | 'p' | 'b' | 'e' | 'E'
count        := parameter | integer
parameter    := argument '$'

Every spec on this page is that line read left to right. Two things it settles that people guess at: fill only exists attached to an align, so {:*8} is not a *-filled field — it is an error; and '0' is a separate flag rather than a fill character, which is why {:010} of -42 is -000000042 and not 000000-42.

{{ and }} are the escapes, and whitespace before the closing brace is allowed and meaningless.

The verified output

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

1. fill, align, width — and the defaults that differ by type
   [****mid****]  fill '*', centre, width 11
   [left-------]  fill '-', left
   [......right]  fill '.', right
   [      42]  a number with no align defaults RIGHT
   [ab      ]  a string with no align defaults LEFT

2. sign, #, and the zero that is not a fill character
   {:+}       +42 and -42
   {:-}       42   <- accepted, and does nothing at all
   {:#x}      0xff     {:#b} 0b101     {:#o} 0o10
   {:#010x}   0x000000ff   <- the 0x counts toward the 10
   {:010}     -000000042   <- the sign goes BEFORE the zeros
   {:08.3}    0003.142

3. precision means two different things
   on a float, digits after the point:  {:.3} of 3.14159 = 3.142
   on a string, a MAXIMUM LENGTH:       {:.3} of "hello" = hel
   and they compose:                    {:>8.3}          = [     hel]
   A spec that truncates text is easy to write by accident when you
   meant to pad it. `{:8}` pads; `{:.8}` cuts.

4. THE SURPRISE: the spec is state, and an impl has to ask for it
   Display str    [      ab]   <- honours the width
   Debug   str    ["ab"]   <- ignores it entirely
   Debug   char   ['a']   <- ignores it too
   Debug   i32    [      42]   <- honours it
   Debug   bool   [    true]   <- honours it
   Debug   Vec    [[       1,        2]]
   Debug   Option [Some(       1)]
   The last two are the ones to look at twice. A container does not
   pad ITSELF — it hands your formatter down to each element, so the
   width lands inside the brackets, once per item.

5. The type characters
   {}      Display     1
   {:?}    Debug       1.0   <- a float keeps its .0 here
   {:x?}   Debug, hex  [ff, 1]
   {:02x?} width rides down to the elements  [ff, 01]
   {:e}    1.2345e3    <- no '+', no padded exponent, unlike C and Python
   {:#?}   pretty Debug, one field per line:
[
    (
        1,
        "a",
    ),
]

6. Rounding is half-to-even, and the exceptions are not exceptions
   {:.0} of 0.5 1.5 2.5 3.5 = 0 2 2 4   <- ties go to the EVEN digit
   {:.2} of 1.005 = 1.00, which looks wrong until you look at the value:
      1.005 is stored as 1.00499999999999989342
      2.675 is stored as 2.67499999999999982236
   Neither is a tie, so neither is a rounding decision. `fmt` rounded
   the number it was given correctly; the literal was never that number.
      2.5 really is stored as 2.50000000000000000000

7. Width and precision computed at run time
   {:>width$.precision$} -> [     3.14]
   {:>1$} with a positional -> [    ab]
   {:.*} takes the precision first -> [3.142]
   A column sized from the data itself (widest = 10):
      alpha      |     3.50
      b          |    12.25
      gamma_long |     0.12

8. write! into one buffer, format! into a new one
   write!:  capacity 64 -> 64 (unchanged: true)
            0:alpha;1:b;2:gamma_long;
   format!: identical text (true) — and one temporary String per row,
            allocated and dropped, which is why write! belongs in a loop.
   Both need `use std::fmt::Write`; writing to a String cannot fail,
   so the fmt::Result it hands back is always Ok(()).

The pieces

spec
{:8} width a minimum — it never truncates
{:<8} {:^8} {:>8} align the default differs by type: numbers right, everything else left
{:*^8} fill + align fill is only legal with an align
{:+} sign forces + on positives; - is accepted and does nothing
{:#x} {:#b} {:#?} alternate 0x/0b prefixes, and pretty-printed Debug
{:08} zero-pad pads after the sign, not before it
{:.3} precision two different meanings — see below
{:width$} {:.prec$} dynamic takes the count from a name or a position

Precision means two different things. On a number it is digits after the point. On a string it is a maximum length{:.3} of "hello" is hel. So {:8} pads and {:.8} cuts, and typing the dot when you meant the width silently truncates every long value in the column.

That truncation counts chars, as does the width, so neither can split a character in half the way &s[..3] can. What they cannot do is count columns on a screen: an emoji is one char and two cells wide, so a padded table of arbitrary text still comes out ragged. That is not a gap in fmt — display width is a property of the font, not the string. Four lengths is the longer version of that argument.

The spec is a request, not a command

This is the part nobody guesses, and it explains a bug you will otherwise write twice:

println!("[{:>8}]", "ab");    // [      ab]  — padded
println!("[{:>8?}]", "ab");   // ["ab"]      — not padded

A format spec is state on the Formatter, not an instruction the machinery applies to whatever an impl returns. Every Display and Debug impl is a function handed that Formatter, and it decides for itself whether to consult the width. Three groups, all in the run above:

  • Numbers, boolDebug honours the width, because those impls go through the padding helpers.
  • str, String, charDebug ignores it completely. It writes quotes and escapes straight out.
  • ContainersVec, Option, tuples hand your formatter down to each element. So {:>8?} on vec![1, 2] pads neither the brackets nor the whole value: it pads 1 and 2, inside them.

The fix when you actually want a padded Debug column is to format first and pad second, because Display for String does honour the width:

println!("[{:<12}]", format!("{:?}", name));   // ["main.rs"   ]

Rounding, and the two numbers that look like bugs

{:.0} of 0.5, 1.5, 2.5, 3.5 gives 0 2 2 4 — ties go to the even digit, the same rule Python and IEEE 754 use.

{:.2} of 1.005 gives 1.00, which looks like the rule failing. It is not a tie:

1.005 is stored as 1.00499999999999989342
2.675 is stored as 2.67499999999999982236
2.5   is stored as 2.50000000000000000000

The first two are below the halfway point, so rounding down is the only correct answer; only the third is a tie at all. fmt rounded the number it was given — the literal was never the number you wrote. What a float actually stores is why.

Width from the data

width$ and .precision$ read a count from a name in scope or a positional argument, which is how a column gets sized by the data rather than by a guess:

let widest = rows.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
for (name, value) in rows {
    println!("{name:<widest$} | {value:>8.2}");
}

{:.*} is the third form, and the odd one: it takes two arguments, precision first, then the value.

Named arguments are captured from scope since 1.58, so format!("{name}") needs no argument list at all. Only a bare identifier works, and the compiler says so precisely: {point.x} is "invalid format string: field access isn't supported", {v[0]} is "expected }, found [". The fix for both is a let above the call.

write! and format!

format! allocates a fresh String every call. write! appends into one you already have — the same macro that writes to a file, reaching a String through fmt::Write:

use std::fmt::Write;
let mut buffer = String::with_capacity(64);
for (i, name) in rows.iter().enumerate() {
    write!(buffer, "{i}:{name};").unwrap();   // capacity never moves
}

In a loop that is one buffer against one temporary per iteration; Building a String is where that comparison lives in full. The unwrap is noise you have to type: writing to a String cannot fail, so the fmt::Result is always Ok(()).

If you are coming from another language

Python. This is Python's mini-language, borrowed almost intact — which makes the four places it differs the ones worth memorising.

Python Rust
f"{x:*^11}", {:>8}, {:.3}, {:#x} identical, character for character same
f"{x:>{w}}" dynamic width in braces {x:>w$} — a $, not a nested brace
f"{s!r:>8}" → padded repr pads {s:>8?}not padded
f"{n:,}"1,234,567 thousands separators built in nothing — fmt has no locale and no grouping
f"{x:e}"1.234500e+03 C's exponent format {x:e}1.2345e3 — no +, no padding
format(2.5, '.0f')2 half-to-even same, for the same reason

The !r row is the one to carry: the two languages look identical there and behave differently, because Python's conversion produces a string that the spec is then applied to, while Rust hands the spec to the Debug impl and str's ignores it. Everything else on this page follows from that one design difference.

ABAP (Not machine-checked — CI cannot run ABAP.) Output formatting has always been attached to the statement rather than to the string.

ABAP Rust
WRITE lv_n LEFT-JUSTIFIED. alignment is a clause on WRITE {:<} — part of the string
WRITE lv_p DECIMALS 2. a keyword {:.2}
lv_out = \|{ lv_n WIDTH = 8 ALIGN = RIGHT }\| string templates, since 7.40 {:>8} — the same idea, shorter
thousands separator from the user's settings locale-aware, automatically no locale at all, ever

What changes is the last row, and it changes in Rust's disfavour for reports. ABAP formats numbers the way the logged-in user expects because the output is a business document; std::fmt has no locale and will never grow one, so a decimal comma or a grouped thousand is your job — a crate like num-format or icu, or hand-written. What you gain is that a formatted string is the same on every machine, which is why the answer keys in this library can exist at all.


Practice

A table that lines up, from data you do not control. Take four rows of (name, bytes, note), compute the name and size column widths from the data itself with width$, and print the table with names left-aligned, sizes right-aligned, a {:-<n$} rule, and a total. No width may be written as a literal.

Then quote the names with {:?} and pad them to a column. Explain why nothing moves, and fix it without giving up the quotes.

Then truncate a long note with precision plus an ellipsis, and say what precision counts — bytes or chars — using a name that makes the two differ.

Finish with the alignment fmt cannot fix: pad "ab", "żó" and two emoji to the same width, show that all three are correct, and say what a terminal is doing differently.

Solution

the_format_language_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: a table whose columns are computed from the data — then the
//! three ways the mini-language stops doing what the column needed.
//!
//!   rustc --edition 2024 the_format_language_kata.rs -o /tmp/tflk && /tmp/tflk

struct Entry {
    name: &'static str,
    bytes: u64,
    note: &'static str,
}

const ENTRIES: [Entry; 4] = [
    Entry { name: "main.rs", bytes: 1204, note: "entry point" },
    Entry { name: "lib.rs", bytes: 88213, note: "everything else, and then some more of it" },
    Entry { name: "build.rs", bytes: 96, note: "" },
    Entry { name: "\u{17c}\u{f3}\u{142}w.txt", bytes: 7, note: "four chars, seven bytes" },
];

fn main() {
    println!("Round 1 -- widths computed from the data");
    let name_width = ENTRIES.iter().map(|e| e.name.chars().count()).max().unwrap_or(0);
    let size_width = ENTRIES
        .iter()
        .map(|e| e.bytes.to_string().len())
        .max()
        .unwrap_or(0);
    println!("   name column {name_width}, size column {size_width}");
    for e in &ENTRIES {
        println!("   {:<name_width$}  {:>size_width$}", e.name, e.bytes);
    }
    println!("   {:-<width$}", "", width = name_width + size_width + 2);
    let total: u64 = ENTRIES.iter().map(|e| e.bytes).sum();
    println!("   {:<name_width$}  {:>size_width$}", "total", total);
    println!("   `{{:<name_width$}}` reads the width from a binding in scope, so the");
    println!("   table resizes itself and no number is written down twice.");

    println!("\nRound 2 -- the column that refuses to line up");
    println!("   Quoting the names with {{:?}} and padding them:");
    for e in ENTRIES.iter().take(2) {
        println!("      [{:<12?}]", e.name);
    }
    println!("   Nothing padded. Debug for str never consults the width -- the spec");
    println!("   is state on the Formatter and each impl decides whether to read it.");
    println!("   Format FIRST, pad the result second:");
    for e in ENTRIES.iter().take(2) {
        println!("      [{:<12}]", format!("{:?}", e.name));
    }
    println!("   That is the general fix: `format!(\"{{x:?}}\")` produces a String,");
    println!("   and Display for String does honour the width.");

    println!("\nRound 3 -- truncating a cell, and what precision counts");
    let note_width = 18;
    for e in &ENTRIES {
        let note = if e.note.chars().count() > note_width {
            format!("{:.*}\u{2026}", note_width - 1, e.note)
        } else {
            e.note.to_string()
        };
        println!("   {:<name_width$}  {note}", e.name);
    }
    println!("   Precision on a string is a MAXIMUM LENGTH, and it counts chars:");
    let polish = "\u{17c}\u{f3}\u{142}w";
    println!("      {polish:?} is {} bytes, {} chars", polish.len(), polish.chars().count());
    println!("      {{:.3}} gives [{:.3}] -- three chars, not three bytes", polish);
    println!("      so it can never split a character in half, unlike &s[..3].");

    println!("\nRound 4 -- the alignment fmt cannot fix");
    for label in ["ab", "\u{17c}\u{f3}", "\u{1f600}\u{1f600}"] {
        println!(
            "      [{:<6}] {} chars, {} bytes",
            label,
            label.chars().count(),
            label.len()
        );
    }
    println!("   All three were padded to six, and all three are correct: fmt counts");
    println!("   CHARS. A terminal draws CELLS, and an emoji takes two of them, so");
    println!("   the last row is two columns wider on screen than the first. std has");
    println!("   no notion of display width and cannot -- it is a property of the");
    println!("   font and the terminal, not of the string. A table of arbitrary text");
    println!("   needs a width crate; a table of ASCII does not.");
}

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

Round 1 -- widths computed from the data
   name column 8, size column 5
   main.rs    1204
   lib.rs    88213
   build.rs     96
   żółw.txt      7
   ---------------
   total     89520
   `{:<name_width$}` reads the width from a binding in scope, so the
   table resizes itself and no number is written down twice.

Round 2 -- the column that refuses to line up
   Quoting the names with {:?} and padding them:
      ["main.rs"]
      ["lib.rs"]
   Nothing padded. Debug for str never consults the width -- the spec
   is state on the Formatter and each impl decides whether to read it.
   Format FIRST, pad the result second:
      ["main.rs"   ]
      ["lib.rs"    ]
   That is the general fix: `format!("{x:?}")` produces a String,
   and Display for String does honour the width.

Round 3 -- truncating a cell, and what precision counts
   main.rs   entry point
   lib.rs    everything else, …
   build.rs  
   żółw.txt  four chars, seven…
   Precision on a string is a MAXIMUM LENGTH, and it counts chars:
      "żółw" is 7 bytes, 4 chars
      {:.3} gives [żół] -- three chars, not three bytes
      so it can never split a character in half, unlike &s[..3].

Round 4 -- the alignment fmt cannot fix
      [ab    ] 2 chars, 2 bytes
      [żó    ] 2 chars, 4 bytes
      [😀😀    ] 2 chars, 8 bytes
   All three were padded to six, and all three are correct: fmt counts
   CHARS. A terminal draws CELLS, and an emoji takes two of them, so
   the last row is two columns wider on screen than the first. std has
   no notion of display width and cannot -- it is a property of the
   font and the terminal, not of the string. A table of arbitrary text
   needs a width crate; a table of ASCII does not.

Full justification, and the width that is not bytes. Write justify_text(words, max_width): fill each line greedily, share its spare spaces between the words with the leftmost gaps taking any extra, and left-justify the last line. Only that last line can come straight out of the mini-language, so check yours against format!("{:<16}", ...). Then run the function on ["Łódź", "żółw", "mak", "ser"] at width 10 with each word's width taken from len() instead of chars().count(), and count the characters in every line it gives you.

// rustc --edition 2024 --test justify_text.rs -o t && ./t
fn justify_text(words: &[&str], max_width: usize) -> Vec<String> {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_justify_text() {
        let words = ["This", "is", "an", "example", "of", "text", "justification."];
        let result = justify_text(&words, 16);

        assert_eq!(result[0], "This    is    an");
        assert_eq!(result[1], "example  of text");
        assert_eq!(result[2], "justification.  ");
    }
}
Solution

justify_text_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: full justification — and the width that has to be counted
//! in characters, because a byte count breaks the lines in the wrong places.
//!
//!   rustc --edition 2024 justify_text_kata.rs -o /tmp/jtk && /tmp/jtk
//!   rustc --edition 2024 --test justify_text_kata.rs -o /tmp/jtkt && /tmp/jtkt

fn justify_text(words: &[&str], max_width: usize) -> Vec<String> {
    justify_by(words, max_width, |w| w.chars().count())
}

/// Greedy lines, then the spare spaces shared out, leftmost gaps first. The
/// unit of width is a parameter, so the same algorithm can be run with the
/// wrong one.
fn justify_by(words: &[&str], max_width: usize, width: fn(&str) -> usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut i = 0;
    while i < words.len() {
        let mut j = i + 1;
        let mut used = width(words[i]);
        while j < words.len() && used + 1 + width(words[j]) <= max_width {
            used += 1 + width(words[j]);
            j += 1;
        }
        let line = &words[i..j];
        let gaps = line.len() - 1;
        if j == words.len() || gaps == 0 {
            // The last line, or a line of one word: left-justify and pad.
            let text = line.join(" ");
            let pad = max_width.saturating_sub(width(&text));
            lines.push(format!("{text}{}", " ".repeat(pad)));
        } else {
            let letters: usize = line.iter().map(|w| width(w)).sum();
            let spaces = max_width - letters;
            let (each, extra) = (spaces / gaps, spaces % gaps);
            let mut out = String::with_capacity(max_width);
            for (k, w) in line.iter().enumerate() {
                out.push_str(w);
                if k < gaps {
                    out.push_str(&" ".repeat(each + usize::from(k < extra)));
                }
            }
            lines.push(out);
        }
        i = j;
    }
    lines
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_justify_text() {
        let words = ["This", "is", "an", "example", "of", "text", "justification."];
        let result = justify_text(&words, 16);

        assert_eq!(result[0], "This    is    an");
        assert_eq!(result[1], "example  of text");
        assert_eq!(result[2], "justification.  ");
    }
}

fn main() {
    println!("1. Adam's example, 16 wide");
    let words = ["This", "is", "an", "example", "of", "text", "justification."];
    let result = justify_text(&words, 16);
    assert_eq!(result[0], "This    is    an");
    assert_eq!(result[1], "example  of text");
    assert_eq!(result[2], "justification.  ");
    for line in &result {
        println!("   |{line}|");
    }
    println!("   The second line has 3 spare spaces for 2 gaps, so the left gap takes");
    println!("   the extra one.");
    assert_eq!(result[2], format!("{:<16}", "justification."));
    println!("   The last line is exactly what {{:<16}} produces — the one kind of line");
    println!("   the format mini-language can justify on its own.");

    println!();
    println!("2. The same algorithm with the width counted in bytes");
    let polish = ["Łódź", "żółw", "mak", "ser"];
    for (unit, lines) in [
        ("chars", justify_by(&polish, 10, |w| w.chars().count())),
        ("bytes", justify_by(&polish, 10, str::len)),
    ] {
        for line in &lines {
            println!("   {unit}  |{line}|  {} characters wide", line.chars().count());
        }
    }
    println!("   Counted in bytes, Łódź and żółw look 7 wide each, so they no longer");
    println!("   share a line, and the padding comes out 3 characters short.");
}

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

1. Adam's example, 16 wide
   |This    is    an|
   |example  of text|
   |justification.  |
   The second line has 3 spare spaces for 2 gaps, so the left gap takes
   the extra one.
   The last line is exactly what {:<16} produces — the one kind of line
   the format mini-language can justify on its own.

2. The same algorithm with the width counted in bytes
   chars  |Łódź  żółw|  10 characters wide
   chars  |mak ser   |  10 characters wide
   bytes  |Łódź   |  7 characters wide
   bytes  |żółw   |  7 characters wide
   bytes  |mak ser   |  10 characters wide
   Counted in bytes, Łódź and żółw look 7 wide each, so they no longer
   share a line, and the padding comes out 3 characters short.

See also

Po polsku

{:>8.3} to nie składnia Rusta, tylko osobny mały język (format mini-language) parsowany przez std::fmt. Nie da się go wydedukować i nie trzeba — cała gramatyka mieści się w dziewięciu linijkach, a każda specyfikacja to po prostu odczytanie tej jednej linii od lewej: wypełnienie, wyrównanie, znak, #, 0, szerokość, precyzja, typ.

Dwie rzeczy zaskakują nawet po przeczytaniu gramatyki. Po pierwsze precyzja znaczy co innego dla liczby i dla tekstu: dla liczby to cyfry po przecinku, dla łańcucha to maksymalna długość, więc {:.3} z "hello" daje hel. Liczone są znaki (char), nie bajty — {:.3} z "żółw" da żół, a nie kawałek bajtów. Po drugie, i to jest sedno strony: specyfikacja jest prośbą, nie rozkazem. Trafia do implementacji Display/Debug jako stan obiektu Formatter, a ta sama decyduje, czy w ogóle o nią zapyta — dlatego {:>8} dla &str wyrówna tekst, a {:>8?} nie zrobi nic, natomiast dla Vec szerokość zostanie przekazana każdemu elementowi osobno.

Dla polskiego czytelnika najważniejsze jest to, czego tu nie ma: std::fmt nie zna pojęcia lokalizacji. {:.2} zawsze wypisze 3.14 z kropką, nigdy 3,14, i nie istnieje żaden wbudowany separator tysięcy — Python ma {:,}, ABAP bierze format z ustawień użytkownika, Rust nie robi ani jednego, ani drugiego. Polski format liczby trzeba złożyć samodzielnie albo sięgnąć po crate (num-format, icu). Cena jest realna, ale i zysk jest realny: sformatowany łańcuch wygląda identycznie na każdej maszynie, i właśnie dlatego ta biblioteka może w ogóle mieć zapisane wzorce wyjścia.

Szukaj po polsku: formatowanie tekstu w Ruscie · przecinek dziesiętny a formatowanie liczb · szerokość i precyzja · rust std fmt format specifiers · rust format! width precision · rust {:?} nie wyrównuje