Introduction
println! was your first line of Rust. Its {} are placeholders: the macro takes a format string plus a list of values and substitutes them in order. This is Rust's equivalent of Console.WriteLine, string.Format, and $"..." interpolation rolled into one family of macros.
The important difference: because these are macros, the format string is parsed at compile time. A wrong number of arguments or a value that cannot be printed is a compile error, not a runtime exception.
The print family
fn main() {
print!("no newline"); // stdout, no line break
println!("with newline"); // stdout + "\n"
eprintln!("something broke"); // stderr
let s = format!("a String: {}", 42); // returns String, prints nothing
println!("{}", s);
}| C# | Rust |
|---|---|
Console.Write(...) | print!(...) |
Console.WriteLine(...) | println!(...) |
Console.Error.WriteLine(...) | eprintln!(...) |
string.Format(...) / $"..." | format!(...) |
sb.AppendFormat(...) | write! / writeln! |
All of them share the exact same formatting syntax, so everything below applies to each one.
{} — the basic placeholder
let name = "Ada";
let age = 36;
println!("{} is {} years old", name, age); // Ada is 36 years oldPlaceholders are filled positionally, in order. You can also address arguments explicitly by index and reuse them:
println!("{0} {1} {0}", "a", "b"); // a b aIn C#: string.Format("{0} {1} {0}", "a", "b") — same idea, except Rust lets you omit the index when the order is straightforward.
Named arguments and captured variables
println!("{who} scored {points}", who = "Ada", points = 9);
let who = "Ada";
let points = 9;
println!("{who} scored {points}"); // captures the local variables directlyThe second form is the closest thing to C# string interpolation and is the idiomatic default in modern Rust (Rust 2021 and later).
In C#: $"{who} scored {points}". One restriction to remember: Rust can only capture plain identifiers. {user.name}, {items[0]} or {a + b} do not compile — C# allows arbitrary expressions inside {}, Rust does not. Pass them as an argument instead:
println!("{name} has {n} items", name = user.name, n = items.len());{} vs {:?} — Display vs Debug
Rust has two separate notions of "turn this into text":
{}uses theDisplaytrait: user-facing output, no surprises.{:?}uses theDebugtrait: developer-facing output, shows structure.{:#?}isDebugpretty-printed over multiple lines.
#[derive(Debug)]
struct Point { x: i32, y: i32 }
fn main() {
let p = Point { x: 1, y: 2 };
// println!("{}", p); // ERROR: Point doesn't implement Display
println!("{:?}", p); // Point { x: 1, y: 2 }
println!("{:#?}", p); // multi-line, indented
println!("{:?}", "hi"); // "hi" <- note the quotes
println!("{:?}", vec![1, 2]); // [1, 2]
println!("{:?}", Some(3)); // Some(3)
}#[derive(Debug)] generates the debug output for your type automatically (traits and derive get their own module later). Collections such as Vec and wrapper types such as Option only implement Debug, never Display — so printing a vector always needs {:?}.
There is also dbg!(expr), which prints file, line, the expression and its value to stderr and returns the value — a quick print-debugging tool:
let total = dbg!(2 + 3) * 10; // [src/main.rs:2:17] 2 + 3 = 5In C#: {} corresponds roughly to ToString(). {:?} has no direct equivalent — think of the structured dump you see in the debugger's watch window.
Format specifiers
The full placeholder syntax is {argument:spec} — everything after the colon controls the layout:
let pi = 3.14159_f64;
let n = 42;
println!("{:.2}", pi); // 3.14 two decimals
println!("{:>8}|", n); // 42| width 8, right aligned
println!("{:<8}|", n); // 42 | left aligned
println!("{:^8}|", n); // 42 | centered
println!("{:*^9}|", n); // ***42****| fill character '*'
println!("{:08.3}", pi); // 0003.142 zero padded, 3 decimals
println!("{:+}", n); // +42 always show the sign
println!("{:x} {:X} {:#x}", 255, 255, 255); // ff FF 0xff
println!("{:b} {:#b}", 5, 5); // 101 0b101
println!("{:o}", 8); // 10 octal
println!("{:e}", 1234.5); // 1.2345e3 scientific| Goal | Rust | C# |
|---|---|---|
| 2 decimals | {:.2} | {0:F2} |
| Width, right aligned | {:>8} | {0,8} |
| Width, left aligned | {:<8} | {0,-8} |
| Centered | {:^8} | – (no equivalent) |
| Custom fill char | {:*^9} | – (no equivalent) |
| Zero padded | {:08} | {0:D8} |
| Hex | {:x} / {:#x} | {0:x} |
| Binary | {:b} | Convert.ToString(n, 2) |
| Thousands separator | – (no built-in) | {0:N0} |
Note the two gaps in each direction: Rust has no built-in thousands separator (use a crate such as num-format), and C# has no fill/centering.
Width and precision can themselves come from a variable, which is handy for table output:
let width = 12;
println!("{:>width$}|", "total"); // right aligned in `width` columns
println!("{:.*}", 3, 3.14159); // precision from an argument -> 3.142The spec also combines with {:?}: {:>10?}, {:#?} and so on.
Escaping braces
To print a literal brace, double it:
println!("{{}} is a placeholder"); // {} is a placeholderIn C#: identical — $"{{}}".
Compile-time checking
// println!("{} {}", 1); // ERROR: 2 placeholders, 1 argument
// println!("{}", vec![1, 2]); // ERROR: Vec<i32> doesn't implement DisplayBoth are caught by the compiler. This is a real day-to-day difference from C#, where string.Format("{0} {1}", 1) compiles fine and throws a FormatException at runtime.
Also note the format string must be a literal. println!(some_string_variable) does not compile; use println!("{}", some_string_variable).
Building strings instead of printing
format! returns a String and is the usual way to assemble text:
let user = "Ada";
let pct = 91.5_f64;
let line = format!("{user}: {pct:.1}%"); // "Ada: 91.5%"For appending in a loop, write!/writeln! into an existing String avoids allocating a new one each time — the StringBuilder pattern:
use std::fmt::Write; // brings write! support for String into scope
let mut out = String::new();
for i in 1..=3 {
writeln!(out, "row {i}").unwrap();
}The same macros also write to files and sockets (std::io::Write), which is why file I/O later in the course will feel familiar.
Making your own type printable
Debug you derive; Display you write by hand — there is deliberately no #[derive(Display)], because how a type presents itself to a user is a design decision:
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() {
println!("{}", Point { x: 1, y: 2 }); // (1, 2)
}In C#: this is public override string ToString(). Implementing Display also gives you .to_string() on the type for free.
Key takeaway
{} is a placeholder filled positionally, by index, by name, or by capturing a local variable — the last form is today's default and mirrors C# interpolation. Use {} for user-facing output (Display), {:?} / {:#?} for structure dumps (Debug, usually derived). Everything after the colon ({:>8.2}, {:#x}) controls width, alignment, padding, precision, and number base, and the whole format string is validated at compile time.