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:
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,
bool—Debughonours the width, because those impls go through the padding helpers. str,String,char—Debugignores it completely. It writes quotes and escapes straight out.- Containers —
Vec,Option, tuples hand your formatter down to each element. So{:>8?}onvec![1, 2]pads neither the brackets nor the whole value: it pads1and2, 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:
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¶
DebugvsDisplay— the two traits every spec on this page is aimed at- Building a
String—write!againstformat!in a loop, measured - Making a
String— implementingDisplay, so your own type takes these specs - Four lengths — why a padded column of non-ASCII text still looks ragged
- Padding is not alignment ↗ — the same grammar in Python, plus the five older methods it absorbed;
str.centerand{:^}disagree by one space there, and Rust has only the one spelling - What a float actually stores — the reason
{:.2}of1.005is1.00 - Strings: links, books and videos
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