ZetCode

Rust string formatting

last modified November 13, 2026

In this article we show how to format strings and values in Rust.

Rust formats text with a family of macros: format! returns a String, print! and println! write to standard output, eprint! and eprintln! to standard error, and write! and writeln! to any writer. All of them take a format string with {} placeholders followed by the arguments.

The format string is checked at compile time. A wrong number of arguments, an unknown named argument, or a type that cannot be formatted in the requested way is a compile error, not a run-time surprise. The rules are described in the std::fmt module.

A placeholder has the general form {argument:fill align sign # 0 width .precision type}, where every part is optional. The examples were tested with Rust 1.97 and the 2024 edition. They are run as Cargo projects with cargo run -q.

The formatting macros

The first example uses the most common formatting macros.

main.rs
fn main() {
    let name = "Roman";
    let age = 34;

    println!("{} is {} years old", name, age);

    let msg = format!("{} is {} years old", name, age);
    println!("{msg}");
    println!("length: {}", msg.len());

    print!("no newline, ");
    print!("still the same line");
    println!();

    eprintln!("this goes to standard error");
}

Each {} placeholder is replaced with the next argument, formatted with its Display implementation.

let msg = format!("{} is {} years old", name, age);

The format! macro builds a new String instead of printing it. We can store it, measure it, or pass it on.

print!("no newline, ");
print!("still the same line");
println!();

The print! macro does not add a newline. A println! without arguments prints just the newline.

eprintln!("this goes to standard error");

The eprintln! macro writes to standard error. In the terminal both streams are shown together; a redirection such as cargo run -q 2> /dev/null would hide the last line.

$ cargo run -q
Roman is 34 years old
Roman is 34 years old
length: 21
no newline, still the same line
this goes to standard error

Positional and named arguments

Arguments can be referred to by position, by name, or by a variable captured directly from the surrounding scope.

main.rs
fn main() {
    let city = "Bratislava";
    let population = 475_503;

    println!("{0} has {1} inhabitants; {0} is a capital", city, population);
    println!("{c} has {p} inhabitants", c = city, p = population);
    println!("{city} has {population} inhabitants");

    let words = ["sky", "cup", "war"];
    println!("{} {} {}", words[0], words[1], words[2]);
    println!("{} chars in {}", words[0].len(), words[0].to_uppercase());
}

Indexes and names let us use one argument several times or in a different order than they were passed.

println!("{0} has {1} inhabitants; {0} is a capital", city, population);

The {0} placeholder refers to the first argument; it appears twice in the output.

println!("{c} has {p} inhabitants", c = city, p = population);
println!("{city} has {population} inhabitants");

Named arguments are given as name = value after the format string. Since Rust 2021, a placeholder can also name a variable in scope directly (an inline captured identifier); this is the most readable form.

println!("{} chars in {}", words[0].len(), words[0].to_uppercase());

Only plain identifiers can be captured. Expressions such as words[0] or method calls must be passed as arguments.

$ cargo run -q
Bratislava has 475503 inhabitants; Bratislava is a capital
Bratislava has 475503 inhabitants
Bratislava has 475503 inhabitants
sky cup war
3 chars in SKY

Debug output

The {:?} placeholder uses the Debug trait, which is meant for programmers rather than end users.

main.rs
#[derive(Debug)]
struct User {
    name: String,
    active: bool,
    roles: Vec<&'static str>,
}

fn main() {
    let nums = vec![1, 2, 3];
    let pair = ("x", 1.5);
    let maybe: Option<i32> = None;
    let text = "tab\there \"quoted\"";

    println!("{:?}", nums);
    println!("{:?}", pair);
    println!("{maybe:?}");
    println!("{}", text);
    println!("{:?}", text);

    let user = User {
        name: String::from("Lucia"),
        active: true,
        roles: vec!["admin", "editor"],
    };

    println!("{user:?}");
    println!("{user:#?}");

    if user.active {
        println!("{} has {} roles", user.name, user.roles.len());
    }
}

Vectors, tuples, and Option do not implement Display, because there is no single obvious way to show them to a user. They do implement Debug.

println!("{}", text);
println!("{:?}", text);

The Display form prints the string as it is. The Debug form adds quotes and escapes special characters, so we can see the tab and the inner quotes.

#[derive(Debug)]
struct User {

The derive attribute generates a Debug implementation for our own struct.

println!("{user:#?}");

The # flag selects the alternate form. For Debug it is the pretty-printed output with one field per line, which is easier to read for nested data.

$ cargo run -q
[1, 2, 3]
("x", 1.5)
None
tab	here "quoted"
"tab\there \"quoted\""
User { name: "Lucia", active: true, roles: ["admin", "editor"] }
User {
    name: "Lucia",
    active: true,
    roles: [
        "admin",
        "editor",
    ],
}
Lucia has 2 roles

Width and alignment

A width sets the minimum number of characters a value takes. Alignment and a fill character control where the padding goes.

main.rs
fn main() {
    let word = "Rust";

    println!("[{:10}]", word);
    println!("[{:<10}]", word);
    println!("[{:>10}]", word);
    println!("[{:^10}]", word);

    println!("[{:10}]", 42);
    println!("[{:<10}]", 42);

    println!("[{:*^20}]", " title ");
    println!("[{:->10}]", 7);
    println!("[{:.<12}{:.>8}]", "Coffee", "2.50");

    println!("[{:3}]", "too long for width");
}

The alignment characters are < for left, > for right, and ^ for center.

println!("[{:10}]", word);
println!("[{:10}]", 42);

Without an explicit alignment, strings are left-aligned and numbers are right-aligned.

println!("[{:*^20}]", " title ");
println!("[{:.<12}{:.>8}]", "Coffee", "2.50");

The character before the alignment is the fill. Any character can be used; it must be followed by an alignment. Two dotted fields make a price list line.

println!("[{:3}]", "too long for width");

The width is a minimum. A longer value is never cut.

$ cargo run -q
[Rust      ]
[Rust      ]
[      Rust]
[   Rust   ]
[        42]
[42        ]
[****** title *******]
[---------7]
[Coffee..........2.50]
[too long for width]

Width and precision from arguments

The width and precision do not have to be literals. They can come from arguments or variables.

main.rs
fn main() {
    let items = ["apple", "banana", "kiwi", "pomegranate"];
    let width = items.iter().map(|s| s.len()).max().unwrap_or(0);

    for item in items {
        println!("|{item:>width$}|");
    }

    println!("|{:>1$}|", "x", 5);
    println!("|{:>w$.p$}|", 12.34567, w = 10, p = 3);
    println!("|{:.*}|", 2, 1.23456);
}

We right-align a column whose width is the length of the longest item.

println!("|{item:>width$}|");

The width$ syntax takes the width from the variable width, which must be a usize.

println!("|{:>1$}|", "x", 5);
println!("|{:>w$.p$}|", 12.34567, w = 10, p = 3);
println!("|{:.*}|", 2, 1.23456);

1$ refers to the argument at index 1, and w$ and p$ to named arguments. The .* form takes two arguments: first the precision, then the value.

$ cargo run -q
|      apple|
|     banana|
|       kiwi|
|pomegranate|
|    x|
|    12.346|
|1.23|

Precision

For floating-point numbers the precision is the number of digits after the decimal point. For strings it is the maximum number of characters.

main.rs
fn main() {
    let pi = std::f64::consts::PI;

    println!("{}", pi);
    println!("{:.0}", pi);
    println!("{:.2}", pi);
    println!("{:.5}", pi);
    println!("{:.3}", 2.0);

    for x in [0.125, 0.375, 2.5, 3.5] {
        println!("{x} -> {x:.2} -> {x:.0}");
    }

    println!("{:.2}", 1.005);
    println!("{:.20}", 1.005);

    println!("{:.3}", "truncated");
    println!("[{:8.3}]", "truncated");
}

Without a precision, a float is printed with as many digits as needed to read back exactly the same value.

for x in [0.125, 0.375, 2.5, 3.5] {
    println!("{x} -> {x:.2} -> {x:.0}");
}

These values are exactly representable in binary, so each of them lies exactly halfway. Rust rounds such ties to the even digit: 0.125 becomes 0.12 and 2.5 becomes 2, while 3.5 becomes 4.

println!("{:.2}", 1.005);
println!("{:.20}", 1.005);

The literal 1.005 is stored as a slightly smaller binary number, as twenty digits reveal. That is why it rounds down to 1.00. Formatting rounds the stored value, not the decimal literal we wrote.

println!("{:.3}", "truncated");
println!("[{:8.3}]", "truncated");

For strings, the precision truncates the value. The width is applied after truncation.

$ cargo run -q
3.141592653589793
3
3.14
3.14159
2.000
0.125 -> 0.12 -> 0
0.375 -> 0.38 -> 0
2.5 -> 2.50 -> 2
3.5 -> 3.50 -> 4
1.00
1.00499999999999989342
tru
[tru     ]

Signs, zero padding, and exponents

The + flag always prints the sign and the 0 flag pads numbers with zeros.

main.rs
fn main() {
    let values = [42, -7, 0];

    for v in values {
        println!("[{v}] [{v:+}] [{v:05}] [{v:+05}] [{v:<5}]");
    }

    let price = 3.5;
    println!("[{price:08.2}]");
    println!("[{:+.1}]", -0.04);
    println!("[{:e}] [{:E}] [{:.2e}]", 1234.5, 0.00012, 6.02214076e23);
}

Zero padding is inserted after the sign, so -7 becomes -0007, not 000-7.

println!("[{price:08.2}]");

The width includes the decimal point and the fractional digits: eight characters in total.

println!("[{:+.1}]", -0.04);

A small negative value rounded to zero keeps its sign and prints as -0.0. This is something to watch for in reports.

println!("[{:e}] [{:E}] [{:.2e}]", 1234.5, 0.00012, 6.02214076e23);

The e and E types print numbers in scientific notation. A precision limits the digits of the mantissa.

$ cargo run -q
[42] [+42] [00042] [+0042] [42   ]
[-7] [-7] [-0007] [-0007] [-7   ]
[0] [+0] [00000] [+0000] [0    ]
[00003.50]
[-0.0]
[1.2345e3] [1.2E-4] [6.02e23]

Number bases

Integers can be printed in binary, octal, and hexadecimal.

main.rs
fn main() {
    let n = 255;

    println!("dec {n}, bin {n:b}, oct {n:o}, hex {n:x}, HEX {n:X}");
    println!("{n:#b} {n:#o} {n:#x}");
    println!("{:#010b}", 5);
    println!("{:08b}", 5_u8);
    println!("{:#06x}", 0xbeef_u16);

    let neg: i8 = -1;
    println!("{neg} as bits: {neg:b}");
    println!("{:x}", -2_i32);

    let bytes = [0xde_u8, 0xad, 0x0b, 0x0e];
    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
    println!("{hex}");
}

The type letters are b, o, x, and X. The # flag adds the 0b, 0o, or 0x prefix.

println!("{:#010b}", 5);
println!("{:08b}", 5_u8);

With the prefix, the width includes the two prefix characters, so ten characters give eight binary digits. Zero padding to the size of the type shows all bits of a byte.

let neg: i8 = -1;
println!("{neg} as bits: {neg:b}");

Negative numbers are printed in their two's complement form, using the bits of their type: eight ones for an i8, eight hexadecimal digits for an i32.

let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();

A common task is turning bytes into a hexadecimal string. Without 02, the byte 0x0b would print as a single b and the result would be ambiguous.

$ cargo run -q
dec 255, bin 11111111, oct 377, hex ff, HEX FF
0b11111111 0o377 0xff
0b00000101
00000101
0xbeef
-1 as bits: 11111111
fffffffe
dead0b0e

Literal braces

To print a brace character, we double it.

main.rs
fn main() {
    let key = "port";
    let value = 8080;

    println!("{{}}");
    println!("{{ \"{key}\": {value} }}");
    println!("{{{value}}}");
    let template = "{not a placeholder}";
    println!("{template}");
}

Inside a format string, {{ prints { and }} prints }.

println!("{{{value}}}");

Three braces are an escaped brace followed by a placeholder. The result is the value in braces.

let template = "{not a placeholder}";
println!("{template}");

Only the format string itself is parsed. Braces inside argument values are printed as they are, which is why a format string must be a literal: user input can never be interpreted as placeholders.

$ cargo run -q
{}
{ "port": 8080 }
{8080}
{not a placeholder}

Implementing Display

To print our own type with {}, we implement the fmt::Display trait.

main.rs
use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 3, y: -4 };

    println!("{p}");
    println!("The point is {}", p);

    let s = p.to_string();
    println!("{} chars: {s}", s.len());

    println!("[{p:>12}]");
}

The trait has one method, fmt, which writes the value into a Formatter.

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

The write! macro formats into the formatter just as format! formats into a new string.

let s = p.to_string();

Every type that implements Display gets the to_string method for free.

println!("[{p:>12}]");

The last line asks for a width of 12, but the output is not padded. The width and other options are stored in the formatter, and our write! call ignores them.

$ cargo run -q
(3, -4)
The point is (3, -4)
7 chars: (3, -4)
[(3, -4)]

Respecting width and alignment

The Formatter::pad method writes a string and applies the width, alignment, fill, and precision requested by the caller.

main.rs
use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let s = format!("({}, {})", self.x, self.y);
        f.pad(&s)
    }
}

fn main() {
    let points = [Point { x: 3, y: -4 }, Point { x: 10, y: 0 }, Point { x: -1, y: 25 }];

    for p in &points {
        println!("[{p:>12}] [{p:<12}] [{p:^12}]");
    }
}

We first build the text and then hand it to pad.

let s = format!("({}, {})", self.x, self.y);
f.pad(&s)

Now Point behaves like a string: it can be aligned in all three ways and used in tables.

$ cargo run -q
[     (3, -4)] [(3, -4)     ] [  (3, -4)   ]
[     (10, 0)] [(10, 0)     ] [  (10, 0)   ]
[    (-1, 25)] [(-1, 25)    ] [  (-1, 25)  ]

Reading formatter options

A Display implementation can read the requested options and decide how to use them.

main.rs
use std::fmt;

struct Celsius(f64);

impl fmt::Display for Celsius {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let prec = f.precision().unwrap_or(1);
        let s = format!("{:.*} °C", prec, self.0);

        match f.width() {
            Some(w) => write!(f, "{s:>w$}"),
            None => write!(f, "{s}"),
        }
    }
}

fn main() {
    let t = Celsius(21.4567);

    println!("{t}");
    println!("{t:.3}");
    println!("{t:.0}");
    println!("[{t:12.2}]");
}

The Celsius type uses the precision for its number and the width for the whole text including the unit.

let prec = f.precision().unwrap_or(1);
let s = format!("{:.*} °C", prec, self.0);

The precision method returns Option<usize>. If the caller did not give a precision, we use one decimal digit as the default.

match f.width() {
    Some(w) => write!(f, "{s:>w$}"),
    None => write!(f, "{s}"),
}

The width method returns the requested width. We right-align the text when a width was given. Calling pad here instead would apply the precision a second time and cut the string.

$ cargo run -q
21.5 °C
21.457 °C
21 °C
[    21.46 °C]

Custom Debug output

Deriving Debug prints every field. When a field should be hidden, we implement the trait by hand.

main.rs
use std::fmt;

struct Account {
    user: String,
    password: String,
    balance: f64,
}

impl fmt::Debug for Account {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.debug_struct("Account")
            .field("user", &self.user)
            .field("password", &"********")
            .field("balance", &self.balance)
            .finish()
    }
}

fn main() {
    let acc = Account {
        user: String::from("peter"),
        password: String::from("s3cr3t"),
        balance: 1250.5,
    };

    println!("{acc:?}");
    println!("{acc:#?}");
    println!("password length: {}", acc.password.len());
}

The account's password must not end up in logs.

f.debug_struct("Account")
    .field("user", &self.user)
    .field("password", &"********")
    .field("balance", &self.balance)
    .finish()

The debug_struct builder produces the same layout as the derived implementation and supports the pretty {:#?} form. We replace the password with a mask. Similar builders exist for tuples (debug_tuple), lists, sets, and maps.

$ cargo run -q
Account { user: "peter", password: "********", balance: 1250.5 }
Account {
    user: "peter",
    password: "********",
    balance: 1250.5,
}
password length: 6

Writing into strings and streams

The write! and writeln! macros work with any type that implements fmt::Write or io::Write.

main.rs
use std::fmt::Write as _;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut report = String::new();

    for i in 1..=3 {
        writeln!(report, "line {i}: {}", i * i).unwrap();
    }
    write!(report, "total lines: 3").unwrap();

    println!("{report}");

    let stdout = io::stdout();
    let mut out = stdout.lock();
    writeln!(out, "written with writeln! to stdout")?;
    writeln!(io::stderr(), "written with writeln! to stderr")?;

    Ok(())
}

A String implements fmt::Write, so we can append formatted text to it without creating temporary strings.

use std::fmt::Write as _;
use std::io::{self, Write};

Both traits have a method named write_fmt, which the macros call. We import fmt::Write anonymously with as _: its methods are available, but its name does not clash with io::Write.

writeln!(report, "line {i}: {}", i * i).unwrap();

Writing to a String cannot fail, but the macro still returns a fmt::Result, so we unwrap it.

let mut out = stdout.lock();
writeln!(out, "written with writeln! to stdout")?;

Standard output implements io::Write. Writing to a stream can fail, for example when the output pipe is closed, so the result is an io::Result that we propagate with ?. Locking standard output once is faster than locking it in each println! call when we write many lines.

$ cargo run -q
line 1: 1
line 2: 4
line 3: 9
total lines: 3
written with writeln! to stdout
written with writeln! to stderr

Formatting a table

Width, alignment, and precision together produce aligned tables.

main.rs
struct Item {
    name: &'static str,
    qty: u32,
    price: f64,
}

fn main() {
    let items = [
        Item { name: "Notebook", qty: 3, price: 2.49 },
        Item { name: "Fountain pen", qty: 1, price: 34.9 },
        Item { name: "Ink", qty: 12, price: 4.0 },
    ];

    let line = "-".repeat(40);

    println!("{:<16}{:>6}{:>9}{:>9}", "Item", "Qty", "Price", "Total");
    println!("{line}");

    let mut sum = 0.0;
    for it in &items {
        let total = it.qty as f64 * it.price;
        sum += total;
        println!("{:<16}{:>6}{:>9.2}{:>9.2}", it.name, it.qty, it.price, total);
    }

    println!("{line}");
    println!("{:<31}{:>9.2}", "Sum", sum);
}

Text columns are left-aligned and number columns right-aligned, with two decimal places for prices.

println!("{:<16}{:>6}{:>9.2}{:>9.2}", it.name, it.qty, it.price, total);

The header line uses the same widths as the data rows. The widths 16 + 6 + 9 + 9 add up to 40, the length of the separator line.

println!("{:<31}{:>9.2}", "Sum", sum);

The sum spans the first three columns (16 + 6 + 9 = 31) so that it lines up with the last column.

$ cargo run -q
Item               Qty    Price    Total
----------------------------------------
Notebook             3     2.49     7.47
Fountain pen         1    34.90    34.90
Ink                 12     4.00    48.00
----------------------------------------
Sum                                90.37

Width counts characters

The width is measured in Unicode scalar values (chars), not in bytes and not in screen columns.

main.rs
fn main() {
    let words = ["tea", "čaj", "茶", "🍵"];

    for w in words {
        println!("[{w:>6}] {} bytes, {} chars", w.len(), w.chars().count());
    }
}

Each word gets a width of six characters.

println!("[{w:>6}] {} bytes, {} chars", w.len(), w.chars().count());

The word čaj has four bytes but three characters, and it is padded correctly. The Chinese character and the emoji count as one character each, but a terminal shows them two columns wide, so their lines are one column too long. For such text, a crate like unicode-width computes the display width.

$ cargo run -q
[   tea] 3 bytes, 3 chars
[   čaj] 4 bytes, 3 chars
[     茶] 3 bytes, 1 chars
[     🍵] 4 bytes, 1 chars

format_args

The format_args! macro prepares a formatted message without allocating a string. It is what all other formatting macros use internally.

main.rs
use std::fmt;

fn log(level: &str, args: fmt::Arguments) {
    println!("[{level:<5}] {args}");
}

fn main() {
    let user = "anna";
    let tries = 3;

    log("INFO", format_args!("user {user} logged in"));
    log("WARN", format_args!("{tries} failed attempts for {user}"));

    let s = 42.to_string() + &format!("{:>4}", 7);
    println!("{s:?}");
}

A logging function can accept fmt::Arguments and decide itself where the text goes.

fn log(level: &str, args: fmt::Arguments) {
    println!("[{level:<5}] {args}");
}

The Arguments value implements Display, so it can be placed into another format string. The message is formatted directly into the output; no intermediate String is built.

let s = 42.to_string() + &format!("{:>4}", 7);

For comparison, to_string and format! each allocate a new String. That is fine for most code; the Arguments approach matters in hot paths and libraries.

$ cargo run -q
[INFO ] user anna logged in
[WARN ] 3 failed attempts for anna
"42   7"

Source

std::fmt module documentation

std::fmt::Formatter documentation

Rust by Example: Formatted print

In this article we have formatted strings and values in Rust. Converting numbers to strings is covered in Rust int to string, and string handling in Rust strings. Implementing traits is explained in Rust traits.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.