Stitching values into text
You're building a status report for a network tool. You have an IP address, a port number, a latency measurement, and a boolean flag for connection status. You need to stitch these into a single line of text to send to a log file or display on a dashboard. In Python, you'd reach for an f-string. In C, you'd wrestle with printf and hope your format specifiers match the argument types. Rust gives you format!, which combines the readability of modern string interpolation with the type safety of the compiler. You get a brand new String on the heap, built exactly how you asked, with zero runtime surprises.
The macro behind the magic
format! is a macro, not a function. It runs at compile time and expands into code that constructs a String. Think of it like a fill-in-the-blanks form letter that the compiler checks before you ever run the program. You write a template with placeholders, and the macro generates code to allocate a buffer, write the formatted content, and return the result.
Unlike a runtime template engine that might crash if you pass the wrong type, format! validates everything during compilation. If you try to put a number where text belongs, or if you forget an argument, the compiler rejects the code immediately. The macro supports positional arguments, named arguments, and a rich set of formatting specifiers for alignment, padding, precision, and base conversion. It's the standard way to build strings from parts in Rust.
Minimal example
Start with the basics. Placeholders {} match arguments in order. The macro returns a String, which owns the data.
fn main() {
let name = "Alice";
let age = 30;
// {} is the placeholder. Arguments fill them left to right.
// format! returns a String, not a &str.
let greeting = format!("Hello, {}! You are {} years old.", name, age);
println!("{}", greeting);
}
The first {} takes name. The second {} takes age. The result is a String containing "Hello, Alice! You are 30 years old.". The macro handles the conversion of age from an integer to text automatically.
Walkthrough
When the compiler sees format!, it parses the format string and the arguments. It checks that the number of placeholders matches the number of arguments. It checks that each argument implements the required trait. For {}, the argument must implement Display. For {:?}, it must implement Debug.
The macro expands into code that creates a String with sufficient capacity. It writes each part of the format string and each formatted argument into the buffer. Finally, it returns the String. This happens at compile time, so there's no overhead for parsing the format string at runtime. The generated code is efficient and safe.
Realistic example
Complex templates benefit from named arguments and specifiers. Named arguments make the code readable. Specifiers control alignment, width, and precision.
fn main() {
let ip = "192.168.1.1";
let port = 8080;
let latency = 42.5678;
let is_connected = true;
// Named arguments use {name}. They can appear in any order.
// :>15 aligns right in a 15-character width.
// :05 pads with zeros to width 5.
// :.2 limits float precision to 2 decimal places.
let status = format!(
"Host: {ip:>15} | Port: {port:05} | Latency: {latency:.2}ms | Active: {is_connected}"
);
println!("{}", status);
}
Named arguments like {ip} refer to variables by name. This is clearer than positional arguments when the template is long. The specifier :>15 right-aligns the IP address in a field of width 15. The specifier :05 pads the port with zeros to width 5. The specifier :.2 rounds the latency to two decimal places. The result is a clean, aligned string.
Specifiers and control
Formatting specifiers follow the pattern {:[flags][width][.precision][type]}. You can combine them to get exactly the output you need.
Flags modify the output. # enables the alternate form, which adds prefixes like 0x for hex or 0b for binary. 0 pads with zeros instead of spaces. - left-aligns the value. + forces a sign for numbers. A space reserves a space for a sign on positive numbers.
Width sets the minimum number of characters. If the value is shorter, it gets padded. Precision controls the number of digits after the decimal point for floats, or the maximum width for strings.
Type specifies the format. d is decimal (default for integers). x is lowercase hex. X is uppercase hex. o is octal. b is binary. e and E are scientific notation. f and F are fixed-point floats. ? uses Debug. p prints the memory address.
fn main() {
let value = 42;
let pi = 3.14159;
// # adds prefix for hex.
let hex = format!("{:#x}", value);
// 0 pads with zeros to width 5.
let padded = format!("{:05}", value);
// + forces sign.
let signed = format!("{:+}", value);
// .2 limits precision to 2 decimals.
let short_pi = format!("{:.2}", pi);
// e uses scientific notation.
let sci = format!("{:.2e}", pi);
println!("Hex: {}", hex);
println!("Padded: {}", padded);
println!("Signed: {}", signed);
println!("Pi: {}", short_pi);
println!("Sci: {}", sci);
}
Convention aside: Use named arguments for complex templates. Positional arguments are fine for simple reuse, but named arguments make the code self-documenting. The community prefers readability in format strings because they are often scanned quickly.
Positional arguments and reuse
Positional arguments use indices like {0}, {1}. You can reuse an argument by repeating its index. This is useful when you need to reference the same value multiple times.
fn main() {
let user = "admin";
// {0} refers to the first argument.
// Reuse {0} to repeat the value.
let message = format!("User {0} logged in. User {0} has full access.", user);
println!("{}", message);
}
Positional arguments are explicit about which argument goes where. They prevent mistakes when the order of arguments doesn't match the order of placeholders. However, named arguments are generally preferred for readability. Use positional arguments when you need to reuse a value or when the template is generated dynamically.
Debug vs Display
Rust has two traits for formatting: Display and Debug. Display is for user-facing output. It produces readable text. Debug is for developer-facing output. It produces detailed information about the internal structure.
Use {} for Display. Use {:?} for Debug. Most standard types implement both. Custom types require you to derive or implement the traits.
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 10, y: 20 };
// {:?} uses Debug. It prints the struct name and fields.
let debug_str = format!("{:?}", p);
// {:#?} uses pretty printing for Debug.
let pretty_str = format!("{:#?}", p);
println!("Debug: {}", debug_str);
println!("Pretty: {}", pretty_str);
}
Convention aside: Derive Debug early. Add #[derive(Debug)] to your structs as soon as you define them. You'll need it for logging and error messages. Implementing Display manually is common for types that have a natural string representation.
Pitfalls and compiler errors
Formatting strings is safe, but there are common mistakes. The compiler catches most of them.
If you miss an argument, the compiler rejects you with E0061 (this function takes N arguments but M arguments were supplied). The macro counts placeholders and arguments at compile time. Fix the mismatch by adding the missing argument or removing the extra placeholder.
If you try to format a custom struct without Display, you get E0277 (the trait Display is not implemented for MyStruct). You need to derive or implement Display. If you only need debugging output, use {:?} and derive Debug.
If you try to return a &str from a function where you created the string with format!, the compiler rejects you with E0515 (cannot return value referencing local variable). The String is dropped at the end of the function, so the reference would dangle. Return the String itself, or use Cow<str> if you have a mix of owned and borrowed data.
Don't use format! if you only need to print. println! writes directly to stdout. format! allocates a String on the heap. Using format! just to print wastes memory and CPU cycles. Use println! for console output. Use format! when you need to store the result or pass it to another function.
Decision matrix
Use format! when you need to construct a String from multiple parts and store it or pass it to another function. Use println! or print! when you only need to display text to the console and don't need the result. Use write! or writeln! when you are writing to a file, socket, or any Write trait implementer without creating an intermediate String. Use concat! when you are joining string literals at compile time to create a &'static str with zero allocation. Use String::new() and push_str() in a loop when you are building a string incrementally in a tight loop to avoid repeated allocations.
Trust the macro. It checks your types before you run. Derive Debug early. You'll thank yourself later. Allocation is real. Don't format just to print.