Printing on the Terminal in Rust: println!, format!, and Standard Output Macros Explained

Printing on the Terminal in Rust

Printing on the Terminal in Rust

The first line of code most of us write in any new language is some variation of “Hello, World!” In Rust, that single line — println!("Hello, world!"); — hides a surprising amount of depth once you start digging into how it actually works. In this article, I want to walk through everything I’ve learned about printing in Rust: the macros involved, formatting syntax, the difference between standard output and standard error, and how these macros connect to Rust’s broader design philosophy around zero-cost abstractions.

Why println! Is a Macro, Not a Function

The very first thing that stood out to me was the exclamation mark. println! isn’t a function call — it’s a macro invocation. Rust macros are expanded at compile time, which means the compiler analyzes the format string and the arguments you pass in, checking that the types and placeholders line up correctly, before your program ever runs.

fn main() {
    println!("Hello, world!");
}

Output:

Hello, world!

If I mess up the number of placeholders versus arguments, the compiler catches it immediately:

fn main() {
    println!("My name is {}", ); // missing argument
}
error: 1 positional argument in format string, but no arguments were given

This compile-time checking is a direct extension of Rust’s memory safety and correctness philosophy — even something as “simple” as printing text gets the same rigor as the rest of the type system.

The Core Printing Macros

Rust’s standard library ships with a family of related macros for output:

fn main() {
    print!("Loading");
    print!("...");
    println!("done!");
}

Output:

Loading...done!

Notice that print! doesn’t automatically flush the output buffer the way println! does in many cases. If timing matters (for example, printing a progress indicator), I sometimes need to explicitly flush stdout using std::io::Write:

use std::io::{self, Write};

fn main() {
    print!("Processing");
    io::stdout().flush().unwrap();
}

Formatting Placeholders

The {} syntax is Rust’s basic formatting placeholder, and it relies on the Display trait being implemented for whatever type you’re printing.

fn main() {
    let name = "Ali";
    let age = 28;
    println!("{} is {} years old.", name, age);
}

Output:

Ali is 28 years old.

I can also use named arguments and positional indices for clarity, especially in longer strings:

fn main() {
    println!("{name} is {age} years old.", name = "Ali", age = 28);
    println!("{0} likes {1}, and {0} also likes coding.", "Ali", "coffee");
}

Output:

Ali is 28 years old.
Ali likes coffee, and Ali also likes coding.

Debug Printing with {:?} and {:#?}

Not every type implements Display, since not every type has an obvious “user-facing” representation. For that, Rust provides the Debug trait and the {:?} placeholder.

#[derive(Debug)]
struct User {
    name: String,
    age: u32,
}

fn main() {
    let user = User { name: String::from("Sara"), age: 24 };
    println!("{:?}", user);
    println!("{:#?}", user);
}

Output:

User { name: "Sara", age: 24 }
User {
    name: "Sara",
    age: 24,
}

The #[derive(Debug)] attribute automatically generates a Debug implementation for my struct. The {:#?} variant gives me a “pretty-printed,” indented version, which I use constantly when debugging nested data structures like vectors of structs.

Number Formatting

Rust’s formatting mini-language lets me control precision, width, alignment, and number base directly inside the placeholder.

fn main() {
    let pi = 3.14159265;
    println!("{:.2}", pi);       // 2 decimal places
    println!("{:8.2}", pi);      // width of 8, 2 decimals
    println!("{:>10}", "right"); // right-align in width 10
    println!("{:<10}|", "left"); // left-align in width 10
    println!("{:^10}|", "mid");  // center-align in width 10
    println!("{:b}", 10);        // binary
    println!("{:o}", 10);        // octal
    println!("{:x}", 255);       // hexadecimal (lowercase)
    println!("{:X}", 255);       // hexadecimal (uppercase)
}

Output:

3.14
    3.14
     right
left      |
   mid    |
1010
12
ff
FF

I use this formatting mini-language all the time for things like aligning table-like output in CLI tools, or converting numbers to hex when debugging low-level or binary data.

format!: Building Strings Without Printing

Sometimes I don’t want to print immediately — I want to build a String I can store, pass around, or use later. That’s exactly what format! is for.

fn main() {
    let name = "Zara";
    let greeting = format!("Welcome, {}!", name);
    println!("{}", greeting);
}

Output:

Welcome, Zara!

format! uses the exact same formatting syntax as println!, which means everything I just covered — precision, alignment, debug formatting — works identically. I reach for format! constantly when constructing error messages, log lines, or dynamic strings that get passed into other functions.

Standard Output vs Standard Error

This distinction matters more than it seems at first. println! and print! write to stdout, while eprintln! and eprint! write to stderr.

fn main() {
    println!("Normal output goes here.");
    eprintln!("Error or diagnostic output goes here.");
}

When I run this from the terminal, both lines appear on screen by default, but they’re on separate streams. This becomes important in real-world tooling — for example, if I redirect stdout to a file with program > output.txt, error messages sent through eprintln! will still show up in the terminal rather than getting silently redirected into the file. I use eprintln! for warnings, error diagnostics, and anything that shouldn’t pollute a program’s actual data output.

A Practical Example: A Formatted Report

Here’s a small program that pulls several of these concepts together into something closer to a real CLI tool:

struct Product {
    name: String,
    price: f64,
    quantity: u32,
}

fn main() {
    let products = vec![
        Product { name: String::from("Keyboard"), price: 45.99, quantity: 3 },
        Product { name: String::from("Mouse"), price: 19.50, quantity: 5 },
        Product { name: String::from("Monitor"), price: 199.00, quantity: 2 },
    ];

    println!("{:<12}{:>10}{:>10}", "Item", "Price", "Qty");
    println!("{}", "-".repeat(32));

    for product in &products {
        println!(
            "{:<12}{:>10.2}{:>10}",
            product.name, product.price, product.quantity
        );
    }
}

Output:

Item          Price       Qty
--------------------------------
Keyboard       45.99         3
Mouse          19.50         5
Monitor       199.00         2

This is the kind of formatting I actually use in real CLI applications — clean columns, aligned numbers, and no external dependencies required.

How Printing Works Internally

Under the hood, println! expands into code that writes to a locked handle on std::io::Stdout. Every call acquires a lock on the global stdout stream, formats the arguments according to the Display/Debug trait implementations, and writes the resulting bytes out. This has a small but real performance implication: if I’m printing in a tight loop with thousands of iterations, locking stdout repeatedly adds overhead. For performance-sensitive code, I sometimes lock stdout once and write to it directly:

use std::io::{self, Write};

fn main() {
    let stdout = io::stdout();
    let mut handle = stdout.lock();

    for i in 0..5 {
        writeln!(handle, "Line {}", i).unwrap();
    }
}

Output:

Line 0
Line 1
Line 2
Line 3
Line 4

This pattern avoids re-locking stdout on every single call, which matters when you’re generating large volumes of output.

Common Mistakes I Made

Best Practices

  1. Use println!/print! for normal output and eprintln!/eprint! for errors or diagnostics — this keeps stdout clean for programs that pipe your output elsewhere.
  2. Derive Debug on custom types early in development; it makes debugging print statements almost effortless.
  3. Use the formatting mini-language ({:.2}, {:>10}, etc.) instead of manually padding strings — it’s more idiomatic and less error-prone.
  4. For performance-critical, high-volume output, lock stdout once and reuse the handle instead of calling println! repeatedly.

Troubleshooting and FAQs

Q: Why do I get “the trait Display is not implemented” for my struct? Display isn’t automatically derivable because Rust doesn’t assume how you want your struct to look to end users. Implement it manually via impl std::fmt::Display for YourType, or use {:?} with #[derive(Debug)] instead.

Q: What’s the difference between format! and println!? format! returns a String without printing anything, while println! immediately writes the formatted text to standard output with a trailing newline.

Q: Why doesn’t my print! output show up immediately? Standard output is often line-buffered or block-buffered depending on the platform. Call io::stdout().flush() if you need output to appear before a newline is printed.

Q: How do I print colored text in the terminal? The standard library doesn’t support ANSI colors directly, but you can print raw ANSI escape codes manually, or use a crate like colored or termcolor for a cleaner API.

Summary

Printing in Rust looks deceptively simple on the surface, but underneath, it showcases a lot of what makes Rust distinctive: compile-time checked formatting, a clean separation between Display and Debug, a rich formatting mini-language, and explicit control over buffering and output streams when performance matters. Once these pieces clicked for me, I stopped seeing println! as just a “Hello, World!” macro and started appreciating it as a well-designed piece of the standard library.

References

Exit mobile version