When the default output isn't enough
You build a struct to hold a user. You run println!("{}", user). The output is User { username: "alice" }. That's fine for debugging, but your CLI tool or log file looks messy. You want alice or User: alice. Rust gives you two ways to print: debug output for developers, and display output for humans. The debug side works out of the box with #[derive(Debug)]. The human side requires a small trait implementation.
Display vs Debug
Think of Debug as the raw data dump. It shows every field, every type, exactly how the computer sees it. That's what you want when something breaks and you need to inspect the state. Display is the presentation layer. It's how your type wants to look when a human reads it. A Date might show Date { year: 2024, month: 5, day: 12 } in debug, but May 12, 2024 in display. A Color might be Color { r: 255, g: 0, b: 0 } internally, but #FF0000 for the user.
Rust enforces this separation. You cannot accidentally print a raw struct to a user unless you implement Display. This prevents leaking internal details and keeps your public API clean.
The minimal implementation
Implementing Display requires the std::fmt module. The trait has one method: fmt. This method receives a mutable reference to a Formatter and returns a Result.
use std::fmt;
struct User {
username: String,
}
// Display tells Rust how to format this type for humans.
impl fmt::Display for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// write! returns a Result. Propagate errors if the formatter fails.
write!(f, "User: {}", self.username)
}
}
fn main() {
let user = User { username: "alice".to_string() };
// {} uses Display. {:?} uses Debug.
println!("{}", user);
}
The write! macro inside fmt works like println!, but it writes to the Formatter instead of stdout. The formatter handles buffering and error propagation. You just return the result of write!. If the underlying stream is broken, the error bubbles up to the caller.
Convention aside: Always use write! inside fmt when you need formatting. Use f.write_str("literal") only when writing a static string with no interpolation. write! is safer and respects formatting specifiers.
How the formatter works
The Formatter is the brain of the operation. When you write println!("{:>10}", user), the macro parses that spec and passes the settings into the Formatter. Your fmt implementation can query the formatter to see what the caller asked for.
The formatter tracks width, alignment, fill character, sign, and precision. Most of the time, you don't need to check these manually. The write! macro respects them automatically. If you write write!(f, "({})", self.value), and the caller asked for width 10, write! pads the output to 10 characters.
However, sometimes you need more control. Maybe you want to pad individual components, or change behavior based on a flag. That's when you query the formatter directly.
use std::fmt;
struct Point {
x: f64,
y: f64,
}
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Check if a width was specified, like {:>10}.
let width = f.width().unwrap_or(0);
// Build the string first, then pad if needed.
let s = format!("({}, {})", self.x, self.y);
// Use the formatter's write method to handle padding/alignment.
// This respects the width and alignment from the format string.
write!(f, "{:width$}", s, width = width)
}
}
The f.width() method returns Option<usize>. If the caller didn't specify a width, it returns None. You should always handle the None case gracefully. Never panic on missing width. The unwrap_or(0) pattern is standard here.
Convention aside: f.width() and f.align() are the most common queries. f.fill() is rare. If you need to check alignment, use f.align(). It returns Option<Alignment>. You can match on Alignment::Left, Alignment::Right, or Alignment::Center.
Custom flags and the alternate format
The Formatter also tracks flags. These are boolean settings triggered by special characters in the format string. The most common flag is the alternate flag, triggered by #. For example, {:#x} prints a hex number with a 0x prefix.
You can use this flag to toggle formatting options. A color type might show #FF0000 with the alternate flag and FF0000 without it. A boolean might show true/false normally and 1/0 with the alternate flag.
use std::fmt;
struct Color {
r: u8,
g: u8,
b: u8,
}
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Check the alternate flag. {:#} sets this to true.
let prefix = if f.alternate() { "#" } else { "" };
// Format the hex values. The 02x specifier ensures two digits with zero padding.
write!(f, "{}{:02x}{:02x}{:02x}", prefix, self.r, self.g, self.b)
}
}
fn main() {
let red = Color { r: 255, g: 0, b: 0 };
println!("{}", red); // FF0000
println!("{:#}", red); // #FF0000
}
The f.alternate() method returns bool. You can also check f.sign_plus(), f.sign_minus(), f.uppercase(), and f.sign_aware_zero_pad(). These map to +, -, X vs x, and 0 padding respectively. Use them to make your type responsive to standard formatting conventions.
Convention aside: The alternate flag is conventionally used for "verbose" or "decorated" output. For numbers, it adds prefixes or suffixes. For custom types, use it to add meaningful decoration, like a hash prefix or a unit suffix. Don't use it to completely change the meaning of the output.
Pitfalls and compiler errors
If you try to print a type without Display using {}, the compiler rejects you with E0277 (the trait bound Type: std::fmt::Display is not satisfied). The fix is implementing the trait or switching to {:?} if Debug is derived.
If you forget to return the result from write!, you get E0308 (mismatched types). The function expects fmt::Result, but you provided (). Always return the write! call. The compiler won't let you drop the result silently.
Runtime trap: calling println! inside fmt. This creates an infinite loop. The formatter calls fmt, fmt calls println!, which calls fmt again. The stack overflows. Never call formatting macros inside fmt. Use write! or f.write_str.
Another subtle issue is implementing ToString manually. You never need to do this. Rust provides a blanket implementation: any type that implements Display automatically gets ToString. If you implement ToString manually, you break the connection to Display. The compiler won't stop you, but you'll confuse everyone who reads your code.
Convention aside: Never implement ToString. Implement Display. The compiler gives you ToString for free. If you see a manual ToString impl in a codebase, it's a smell. Refactor it to Display.
Decision matrix
Use Display when you want a clean, user-facing string representation. This is the default for {}. Implement this for types that appear in logs, CLI output, or UI text.
Use Debug when you need a complete, unambiguous dump of the data structure. This is the default for {:?}. Derive this for every struct and enum during development. It helps you inspect state when things go wrong.
Use LowerHex or UpperHex when your type represents a value that is naturally expressed in hexadecimal. This covers {:#x} and {:#X}. Implement this for colors, memory addresses, or cryptographic hashes.
Use Binary when your type is a bitfield or flag set. This covers {:#b}. Implement this for permission masks or hardware registers where individual bits matter.
Use Octal when your type maps to file permissions or legacy systems. This covers {:#o}. Implement this for Unix permission modes or device nodes.
Derive Debug everywhere. Implement Display where it counts.