How to format strings

Use the `format!` macro for creating new strings in memory and the `println!` or `print!` macros for outputting to the console.

The string you need doesn't exist yet

You're building a log parser. You have the line number, the column index, and the unexpected token. You need to stitch these pieces into a single error message before writing it to a file. In Python, you reach for an f-string. In JavaScript, you grab a template literal. Rust handles this with macros that look familiar but behave differently under the hood. The compiler generates the formatting code at compile time, not at runtime. This means formatting is fast, and the compiler catches typos in your placeholders before you ever run the code.

The blueprint analogy

Think of a format string as a blueprint for a stamping machine. You hand the blueprint to the compiler. The compiler inspects the blueprint, checks the types of the arguments, and generates a tiny, optimized function just for that specific stamp. When your program runs, it doesn't parse the string. It runs the generated function.

This is why you can't pass a random type to a placeholder. The compiler checks the blueprint against the arguments. If the types don't match the expected traits, compilation fails. This also explains why formatting in Rust is performant. There is no reflection or runtime string parsing. The work is done once, when you compile.

Minimal example

The format! macro creates a new String in memory. The println! macro writes to the console. Both use the same formatting syntax.

fn main() {
    // format! returns a String allocated on the heap.
    // The compiler generates code to build this specific string.
    let greeting = format!("Hello, {}! You are {} years old.", "Alice", 30);

    // println! writes to stdout and returns ().
    // It uses the same formatting machinery as format!.
    println!("{}", greeting);
}

format! gives you a value you can store, return, or pass around. println! is a side effect. It prints and discards the result. Both rely on curly brace placeholders. The compiler matches arguments to placeholders by position by default.

Display versus Debug

Rust has two main traits for converting values to text. Display is for end users. Debug is for developers. The placeholder you choose determines which trait the compiler requires.

Use {} for Display. This trait is for human-readable output. Types like String, i32, and f64 implement Display automatically. Custom structs do not. If you try to print a struct with {}, the compiler rejects you with E0277 (trait bound not satisfied). The error tells you the type doesn't implement std::fmt::Display.

Use {:?} for Debug. This trait is for debugging. It shows the internal structure of a value. You can add #[derive(Debug)] to any struct to get this for free.

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 10, y: 20 };

    // This works because Point derives Debug.
    // The output includes the struct name and field values.
    println!("Point: {:?}", p);

    // This fails with E0277.
    // Point does not implement Display.
    // println!("Point: {}", p);
}

Convention aside: The community treats Display as the public face of a type and Debug as the internal skeleton. If you're building a library, implement Display only if the output is useful to users. Always derive Debug. It saves hours of debugging later.

Realistic example: Report generation

Formatting shines when you need alignment, padding, and type conversion. You can control width, alignment, precision, and base.

fn generate_report(name: &str, score: f64, rank: u32) -> String {
    // Build a formatted report line.
    // {:<15} left-aligns name in a 15-character field.
    // {:.1} rounds score to 1 decimal place.
    // {:04} zero-pads rank to 4 digits.
    format!("Player: {:<15} | Score: {:.1} | Rank: #{:04}", name, score, rank)
}

fn main() {
    let report = generate_report("Alice", 98.765, 1);
    println!("{}", report);
    // Output: Player: Alice           | Score: 98.8 | Rank: #0001
}

The colon after the placeholder introduces formatting flags. Width sets the minimum number of characters. Alignment controls padding direction. Precision limits decimal places or truncates strings. Type flags convert numbers to different bases.

Formatting features

You can combine flags to get exactly the output you need. Width and alignment work together. Precision works with floats and strings. Type flags work with numbers.

Width sets the minimum field size. If the value is shorter, padding fills the gap. The default padding character is a space. You can change the padding character by placing it before the width.

fn main() {
    let val = 42;

    // Right-align in width 5. Pads with spaces.
    println!("{:5}", val);   // "   42"

    // Left-align in width 5.
    println!("{:<5}", val);  // "42   "

    // Center-align in width 5.
    println!("{:^5}", val);  // "  42 "

    // Zero-pad in width 5.
    println!("{:05}", val);  // "00042"

    // Pad with dots in width 5.
    println!("{:.>5}", val); // "...42"
}

Precision controls decimal places for floats and truncation for strings. For floats, it rounds. For strings, it cuts.

fn main() {
    // Round float to 2 decimal places.
    println!("{:.2}", 3.14159); // "3.14"

    // Truncate string to 5 characters.
    println!("{:.5}", "Hello, World!"); // "Hello"
}

Type flags convert numbers to different representations. Lowercase flags produce lowercase output. Uppercase flags produce uppercase output.

fn main() {
    let n = 255;

    // Hexadecimal.
    println!("{:x}", n);  // "ff"
    println!("{:X}", n);  // "FF"

    // Binary.
    println!("{:b}", n);  // "11111111"

    // Octal.
    println!("{:o}", n);  // "377"

    // Exponential notation.
    println!("{:e}", 1234.5); // "1.2345e3"
}

Positional and named arguments give you control over order and reuse. By default, arguments match placeholders by position. You can override this with indices or names.

fn main() {
    // Reuse the first argument with {0}.
    println!("{0} says {0} to {1}", "Alice", "Bob");
    // Output: Alice says Alice to Bob

    // Named arguments. Rust 2021 allows {name} directly.
    let name = "Charlie";
    println!("User: {name}");
    // Output: User: Charlie
}

Convention aside: Rust 2021 edition supports {name} syntax directly in format strings. You don't need name = name anymore. The compiler matches the variable name to the placeholder. This is the standard now. Use named arguments when the order is confusing or when you reuse values. Use positional arguments for simple cases.

Pitfalls and errors

The compiler catches most formatting mistakes at compile time. You'll rarely see a runtime panic from a bad format string. The errors are specific and actionable.

E0277 is the most common error. It happens when a type doesn't implement the required trait. If you use {} on a struct without Display, you get E0277. The fix is to derive Debug and use {:?}, or implement Display manually.

E0308 appears when types don't match. If you pass a String where an i32 is expected, the compiler rejects you. The error points to the mismatched argument.

Argument count mismatches are caught immediately. If you have two placeholders but one argument, the compiler tells you which index is missing. There is no undefined behavior. The code won't compile.

fn main() {
    let name = "Alice";

    // This fails at compile time.
    // The compiler expects two arguments but gets one.
    // println!("Hello, {}! You are {}.", name);
}

Don't fight the compiler here. If the error mentions a trait bound, check your derives. If it mentions a type mismatch, check your arguments. The compiler is right.

Performance and allocation

format! allocates a new String on the heap. This is necessary because the result is a new value. If you're in a tight loop, repeated allocation can hurt performance.

Use format_args! when you need to avoid allocation. It produces a lazy formatter that doesn't allocate until you write it. You pass the result to write! or writeln!.

use std::io::Write;

fn log_to_buffer(buffer: &mut Vec<u8>, count: u32) {
    // format_args! creates a lazy formatter.
    // No allocation happens here.
    let args = format_args!("Count: {}", count);

    // write! consumes the formatter and writes to the buffer.
    // Allocation happens only if the buffer needs to grow.
    write!(buffer, "{}", args).unwrap();
}

format_args! is useful in hot loops or when writing to files. It defers allocation until the write happens. If you're writing to a Vec<u8>, the buffer might reuse capacity. This reduces pressure on the allocator.

Convention aside: The community calls this the "lazy formatting" pattern. Use format_args! when profiling shows formatting is a bottleneck. Don't optimize prematurely. format! is fast enough for most code. Reach for format_args! only when you have measured data.

Decision matrix

Use format! when you need a String value to store, return, or pass to another function. Use println! or print! when you only need to output text to the console and don't care about the result. Use write! or writeln! when you're writing to a file, a buffer, or any type that implements std::io::Write. Use format_args! when you're in a tight loop and want to avoid allocating a String on every iteration.

Trust the borrow checker on string lifetimes. If you're trying to format a borrowed string and keep the result, format! is your friend. It takes ownership of the formatted content. You don't need to worry about the original data going away.

Where to go next