Printing and Formatting (println!)

Every Rust program you’ve seen so far probably starts with println! — but there’s far more to it than printing “Hello, world!”. Rust’s printing macros give you a small, type-safe formatting language built right into the compiler: you can control width, precision, alignment, number bases, and how your own types are displayed, and the compiler checks almost all of it before your program ever runs. This lesson covers the whole family — println!, print!, eprintln!, and format! — and the formatting mini-language they share.

Overview / How it works

println! is not a function — the trailing ! marks it as a macro. Macros are expanded by the compiler before type-checking and code generation, which is exactly why Rust can do something C’s printf cannot: it checks, at compile time, that every {} placeholder in your format string has a matching argument of a type that actually knows how to format itself. In C, passing the wrong type to printf can silently print garbage or crash at runtime. In Rust, it fails to compile.

The first argument to println! is always a string literal called the format string. Everywhere you write {} inside it, Rust substitutes the next argument, converted to text via a formatting trait. There are two main traits: std::fmt::Display, used by {}, is for the “user-facing” representation of a value (how a String or an i32 naturally prints). std::fmt::Debug, used by {:?}, is for a programmer-facing representation meant for inspecting data while developing — it can be automatically generated with #[derive(Debug)], while Display generally cannot (you must implement it yourself, because there’s no single obviously-correct way to display an arbitrary struct to a user).

Formatting also interacts with ownership in a way worth noticing early: println! only needs to read your values to format them, so it borrows its arguments rather than taking ownership of them. A String you print is still yours to use afterward:

let s = String::from("hello");
println!("{}", s);
println!("{}", s);

Both lines print hello — the first println! does not move or consume s, because formatting works through a shared reference under the hood. This is different from passing s to a function that takes ownership, which would make the second use a compile error.

There’s a small family of related macros: println! prints text plus a trailing newline to standard output; print! is the same but without the newline; eprintln! and eprint! do the same but write to standard error instead of standard output (useful for diagnostics and warnings that shouldn’t pollute a program’s real output); and format! uses the exact same formatting language but returns a new, owned String instead of printing anything.

Syntax

The general shape of every one of these macros is the same: a format string followed by a comma-separated list of arguments.

println!("literal text {} more text {:spec}", arg0, arg1);

Inside the braces, the full mini-language looks like {argument:fill align sign #0 width.precision type} — every part except the braces themselves is optional. The table below breaks down what each part means.

Part Meaning
argument Which value to use: a position like {0}, a name like {name} bound with name = value, or nothing (uses the next argument in order).
fill and align A padding character (default space) plus < (left), > (right), or ^ (center) to align within the given width.
sign + forces a sign on positive numbers.
# "Alternate" form — adds 0x/0o/0b prefixes for hex/octal/binary, or pretty-prints {:#?} across multiple lines.
0 Pads numbers with leading zeros instead of spaces, respecting the sign.
width Minimum field width in characters, e.g. {:8}.
.precision Digits after the decimal point for floats, e.g. {:.2}, or max length for strings.
type How to format the value: ? for Debug, x/X for hex, o for octal, b for binary, e/E for scientific notation. Omitted means Display.

Examples

Start with the basics: substituting values into a format string in order.

fn main() {
    let name = "Ferris";
    let age = 10;
    println!("Hello, {}! You are {} years old.", name, age);
    println!("{} is a Rust mascot.", name);
}

Output:

Hello, Ferris! You are 10 years old.
Ferris is a Rust mascot.

name is a &str and age is an i32; both implement Display, so {} works for each. Notice name is used twice across two separate println! calls — again, formatting only borrows, so this is completely fine.

Next, positional arguments, named arguments, and the Debug trait for a custom struct:

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

fn main() {
    println!("{0} + {1} = {2}", 2, 3, 5);
    println!("{a} and {b}", a = "cat", b = "dog");

    let p = Point { x: 3, y: 7 };
    println!("{:?}", p);
    println!("{:#?}", p);
}

Output:

2 + 3 = 5
cat and dog
Point { x: 3, y: 7 }
Point {
    x: 3,
    y: 7,
}

{0}, {1}, and {2} pick arguments by position (and can be reused or reordered), while {a} and {b} pick them by name. Point has no Display implementation, so println!("{}", p) would fail to compile — but #[derive(Debug)] generates a Debug implementation automatically, so {:?} works. The alternate form {:#?} pretty-prints the same data across multiple, indented lines, which is genuinely useful once structs get nested.

Now the numeric formatting specifiers — width, precision, alignment, padding, and number bases:

fn main() {
    let pi = 3.14159265;
    println!("{:.2}", pi);
    println!("{:8.2}", pi);
    println!("{:<8.2}|", pi);
    println!("{:>8.2}|", pi);
    println!("{:^8.2}|", pi);
    println!("{:08.2}", pi);

    let n = 42;
    println!("{:5}|", n);
    println!("{:05}", n);
    println!("{:x}", n);
    println!("{:#x}", n);
    println!("{:b}", n);
}

Output:

3.14
    3.14
3.14    |
    3.14|
  3.14  |
00003.14
   42|
00042
2a
0x2a
101010

{:.2} rounds to two decimal places. Adding a width like {:8.2} pads the rounded result out to eight characters — numbers default to right alignment, so four leading spaces appear before 3.14. Explicit <, >, and ^ control left, right, and center alignment. {:08.2} pads with zeros instead of spaces. For the integer 42, {:x}, {:#x}, and {:b} print hexadecimal (plain and with the 0x prefix) and binary.

Finally, the rest of the printing family — print! for no trailing newline, format! for building a String, and eprintln! for standard error:

fn main() {
    print!("Loading");
    for _ in 0..3 {
        print!(".");
    }
    println!(" done!");

    let greeting = format!("Hello, {}!", "world");
    println!("{}", greeting);

    eprintln!("This goes to standard error, not standard output.");
}

Output:

Loading... done!
Hello, world!

The three print!(".") calls append dots with no newlines between them, and the following println! finishes the line. format! uses the identical formatting language but returns an owned String instead of printing — handy any time you need formatted text as a value rather than immediate output. The eprintln! line does print, but to standard error, so it doesn't appear in the program's normal (stdout) output shown above; that's exactly why it's the right macro for warnings and error diagnostics that shouldn't mix into a program's real output stream.

How it works step by step

When the compiler sees println!("...", arg0, arg1), it expands the macro before normal compilation continues. The format string is parsed as a literal at compile time: the compiler walks it looking for {...} placeholders, matches each one against the arguments you supplied (by position or by name), and checks that a formatting trait implementation exists for each argument's type and format specifier — a {:x} on a type with no hex formatting, or a {} on a type with no Display, is a compile error right there, not a runtime surprise. The expanded code ultimately builds an internal list of formatting arguments and calls into std::fmt machinery, which calls each value's Display::fmt or Debug::fmt method, writing formatted text into a buffer that println! then writes to standard output (or eprintln! to standard error) followed by a newline.

Common Mistakes

Mistake 1: printing a custom type with {} when it has no Display implementation. Only #[derive(Debug)] was added, or nothing at all:

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

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{}", p);
}

This fails to compile with an error like "Point doesn't implement std::fmt::Display". Rust deliberately does not guess a default text representation for your types — you either derive Debug and use {:?}, or you implement Display yourself if you want a user-facing format. The quick fix:

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

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{:?}", p);
}

Output:

Point { x: 1, y: 2 }

Mistake 2: forgetting to escape literal curly braces. Because { and } are special in format strings, writing them as-is when you actually want to print a literal brace is a trap:

fn main() {
    println!("{}");
}

This doesn't print the two characters {} — the compiler treats {} as a placeholder expecting an argument, and since none was given, it fails to compile with "invalid reference to positional argument 0 (no arguments were given)". To print a literal brace, double it up:

fn main() {
    println!("{{}} is how you print literal curly braces.");
}

Output:

{} is how you print literal curly braces.

Best Practices

  • Use {} (Display) for output meant for end users, and {:?} (Debug) for developer-facing inspection, logging, and debugging — don't reach for Debug as a substitute for a real Display implementation on public-facing types.
  • Reach for #[derive(Debug)] on almost every struct and enum you write — it's nearly free and pays off the moment you need to inspect a value while debugging.
  • Use {:#?} instead of {:?} for nested or multi-field structures; the extra readability is worth the extra lines.
  • Send diagnostics, warnings, and error messages to eprintln!, not println!, so a program's real output can still be piped or redirected cleanly.
  • Prefer format! over manual string concatenation (+ chains) when building a String out of several pieces — it's clearer and avoids repeated intermediate allocations.
  • Give width and precision as literal numbers in the format string when they're fixed at compile time; use the {:width$} / {:.prec$} syntax (with a named or positional argument supplying the number) only when width or precision must vary at runtime.

Practice Exercises

1. Write a program that stores a product name (&str) and a price (f64) in variables and prints a line like Widget: $19.99 using {:.2} for the price.

2. Define a struct Rectangle with width: u32 and height: u32 fields, derive Debug on it, then print an instance with both {:?} and {:#?} and compare the two outputs.

3. Print the numbers 1 through 5 right-aligned in a field of width 4 (hint: {:4} inside a loop), so they visually line up in a column.

Summary

  • println!, print!, eprintln!, eprint!, and format! all share the same formatting mini-language; they differ only in newline behavior, output stream, and whether they print or return a String.
  • {} uses the Display trait (user-facing text); {:?} uses Debug (developer-facing, often auto-derived with #[derive(Debug)]); {:#?} is Debug's pretty-printed form.
  • Format strings are checked by the compiler — mismatched types, missing arguments, and missing trait implementations are all compile errors, not runtime bugs.
  • Width, precision, alignment, fill characters, and number bases (hex, octal, binary) are all controlled inside the {:...} specifier.
  • Printing macros only borrow their arguments through a reference — they never take ownership, so values remain usable after being printed.
  • Literal braces in a format string must be escaped as {{ and }}.