How to Use String Interpolation in Rust

Rust does not support native string interpolation like Python or JavaScript; instead, you use the `format!` macro or the `println!` macro with `{}` placeholders to inject variables into strings.

When f-strings don't work

You write f"Hello {name}" in Python. It works. You paste it into Rust. The compiler rejects it with a syntax error. Rust doesn't have f-strings. It has format!. This isn't a limitation. It's a design choice that gives you type safety and zero-cost abstraction. format! is a macro. It runs at compile time. It inspects your types and generates the exact code to write them into a buffer. There is no runtime reflection. There is no generic string parser. The compiler bakes the logic in.

The basics: format! and placeholders

The format! macro constructs a String by interpolating values into a template. It uses {} as placeholders. Each placeholder corresponds to an argument passed after the template string. The macro checks the types at compile time. If a type doesn't know how to format itself, the code won't compile.

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

    // format! creates a new String on the heap.
    // It expands to code that writes each value into a buffer.
    // {} calls the Display trait on the argument.
    let greeting = format!("Hello, {}! You are {} years old.", name, age);

    println!("{}", greeting);
}

format! returns a String. That String lives on the heap. Every call to format! performs a heap allocation. The macro calculates the required capacity, allocates memory, writes the bytes, and returns the owned string. This is safe. You never get a buffer overflow. You never get a dangling pointer. The trade-off is allocation overhead.

Convention aside: format! is the standard way to build strings in Rust. The community rarely uses the + operator for string concatenation unless joining two String values in a trivial expression. format! is optimized, readable, and handles type conversion automatically. Reach for format! first.

Named arguments and reusing values

Positional arguments work fine for simple cases. When the template gets complex or you need to reuse a value, named arguments keep the code readable. You can name arguments inside the braces and provide them as keyword arguments.

fn main() {
    let user = "Bob";
    let score = 95;

    // Named arguments let you reference values by name.
    // This avoids confusion about argument order.
    // You can reuse {user} multiple times without passing it twice.
    let report = format!("User {user} scored {score} points. {user} wins.");

    println!("{}", report);
}

Named arguments support reuse. If you need the same value in three places, you pass it once and reference it by name. The compiler verifies the names match. Mismatched names trigger a compile error. This prevents silent bugs where a swapped argument produces garbage output.

Display vs Debug: the trait contract

format! relies on traits to convert values to text. The {} placeholder requires the Display trait. The {:?} placeholder requires the Debug trait. These are two different contracts.

Display is for user-facing output. It produces clean, readable text. Debug is for developers. It produces detailed, machine-readable output including struct names and field values.

If you try to use {} on a type that doesn't implement Display, the compiler rejects you with E0277 (trait bound not satisfied). The error message tells you the type doesn't implement std::fmt::Display.

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

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

    // This fails with E0277.
    // Point does not implement Display.
    // let bad = format!("{}", p);

    // This works. {:?} uses the Debug trait.
    let good = format!("{:?}", p);
    println!("{}", good);
    // Output: Point { x: 10, y: 20 }
}

Derive Debug on your structs to get {:?} support instantly. Implement Display manually when you need a custom user-facing format. Convention dictates that Display should never panic and should produce output suitable for end users. Debug output can be verbose and technical.

Trust the traits. If a type doesn't implement Display, it doesn't print with {}. The compiler enforces this contract.

Advanced formatting: width, alignment, and precision

The placeholder syntax supports flags for formatting control. You can specify width, alignment, precision, and type specifiers inside the braces. This lets you build formatted tables, padded logs, and precise numeric output without manual string manipulation.

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

    // {:>10} right-aligns in a field of width 10.
    // {:.2} limits floating point to 2 decimal places.
    // {:?} uses Debug formatting for the string slice.
    let line = format!("{:>10} | {:.2} | {:?}", name, score, "raw");

    println!("{}", line);
    // Output:      Alice | 95.68 | "raw"
}

Width padding fills space with spaces by default. You can change the fill character. {:0>5} pads with zeros. {:*<10} uses the next argument as the width. Precision works for floats and strings. {:.5} truncates a string to five characters. The macro validates precision at compile time for types that support it.

These flags are part of the fmt module specification. They work consistently across format!, println!, and write!. Learn the syntax once and apply it everywhere.

The cost of interpolation

format! allocates. It returns a String. String lives on the heap. Every call to format! asks the allocator for memory. In a tight loop, this adds up. Allocation pressure causes cache misses and increases latency. If you are building strings in a performance-critical inner loop, format! becomes a bottleneck.

Profile your code first. Most application logic doesn't need micro-optimizations. format! is fast enough for 99% of use cases. When profiling shows string allocation is the bottleneck, switch to buffer reuse.

The write! macro appends to an existing buffer. It takes a writer as the first argument. You can pass a String or a Vec<u8>. The macro writes into the buffer without allocating a new string. You control the capacity. You reuse the buffer across iterations.

use std::fmt::Write;

fn build_messages(names: &[&str]) -> String {
    // Pre-allocate a buffer with estimated capacity.
    // This avoids repeated reallocations as the string grows.
    let mut buffer = String::with_capacity(1024);

    for name in names {
        // write! returns a Result.
        // String writes only fail on out-of-memory.
        // Unwrap is safe here because OOM is fatal anyway.
        write!(buffer, "Hello, {}!\n", name).unwrap();
    }

    buffer
}

fn main() {
    let names = vec!["Alice", "Bob", "Charlie"];
    let result = build_messages(&names);
    println!("{}", result);
}

write! returns a Result. The error type is std::fmt::Error. For String writers, the only error is out-of-memory. The community convention is to .unwrap() or use ? depending on context. Acknowledge the Result. Don't ignore it. The compiler forces you to handle it, which reminds you that writes can fail.

Convention aside: Keep write! calls grouped. The macro expands to code that checks the writer's state. Grouping writes reduces overhead. Don't sprinkle single-character writes across a function. Build chunks and write them.

format_args! for lazy evaluation

Sometimes you want to pass formatted arguments to another function without allocating a String first. The format_args! macro creates a lazy Arguments struct. It captures the template and values but doesn't write anything yet. The actual formatting happens when you pass the Arguments to a writer.

This is useful for logging libraries, custom formatters, and APIs that accept formatted input. You avoid the allocation until the final write. If the log level is disabled, the arguments are never formatted. You save both allocation and computation.

fn log_message(args: std::fmt::Arguments) {
    // The formatting happens here.
    // If this function is skipped, no work was done.
    println!("{}", args);
}

fn main() {
    // format_args! returns Arguments.
    // No String is allocated.
    let args = format_args!("User {} logged in at {}", "Alice", 12345);
    log_message(args);
}

format_args! is the building block behind format!, println!, and write!. Those macros call format_args! internally and then write the result. Using format_args! directly gives you control over when and where the formatting occurs.

Pitfalls and compiler errors

String interpolation in Rust is safe, but you'll hit compiler errors if you ignore the rules. The compiler catches mistakes early. Learn the errors and fix them quickly.

E0277 appears when a type doesn't implement the required trait. You tried to use {} on a type without Display. Switch to {:?} or implement Display. E0308 appears when types don't match. You passed a String where a &str was expected, or vice versa. Rust distinguishes owned and borrowed strings. Use .as_str() to borrow a String. Use .to_string() to own a &str.

E0599 appears when you call a method that doesn't exist. This happens if you try to call string methods on a format_args! result. Arguments doesn't implement Deref to str. You can't call .len() or .contains() on it. Write it to a buffer first.

Another pitfall is confusing format! with println!. format! returns a String. println! writes to stdout and returns (). If you assign println! to a variable, the compiler complains about unused results. If you pass format! where a print is expected, you get a type mismatch. Know which macro does what.

Don't fight the allocation. Reuse the buffer.

Decision: when to use this vs alternatives

Use format! when you need a new String value to store, return, or pass to another function. Use println! or eprintln! when you only need to output text to the console and don't need the result. Use write! when you are appending to an existing String or Vec<u8> buffer to avoid repeated allocations. Use format_args! when you want to pass formatting arguments to another function without allocating a String first. Use string concatenation with + only when you are joining String types and the logic is trivial; format! is usually clearer and safer.

Counter-intuitive but true: format! is a macro, not a function. The compiler generates the code. There is no runtime overhead for the interpolation logic itself. The cost is the allocation and the write operations. Optimize the allocation, not the macro.

Where to go next