What is the purpose of tracing::field::display for lazy formatting of field values?

tracing::field::display wraps a value implementing Display to defer its string formatting until the span or event is actually recorded—avoiding expensive to_string() calls when the subscriber is disabled for that log level, while field::debug does the same for Debug implementations. This lazy evaluation is critical for performance in hot paths where formatting costs would otherwise be incurred even when the resulting string is never used.

The Problem: Eager Formatting

use tracing::{info, span, Level};
 
fn eager_formatting() {
    let data = ExpensiveToFormat { values: vec
![1, 2, 3, 4, 5] }
;
    
    // PROBLEM: format is called eagerly, even if INFO level is disabled
    info!(data = %data, "Processing data");
    // The % forces Display, but format() is called immediately
    
    // Even worse: format() in the message itself
    info!("Processing: {}", data.to_string());
    // to_string() is ALWAYS called, even if this log is never emitted
}
 
struct ExpensiveToFormat {
    values: Vec<i32>,
}
 
impl std::fmt::Display for ExpensiveToFormat {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Expensive operation
        let sum: i32 = self.values.iter().sum();
        write!(f, "ExpensiveToFormat {{ sum: {}, count: {}, values: {:?} }}", 
               sum, self.values.len(), self.values)
    }
}

Eager formatting wastes CPU cycles when logging is disabled.

Basic field::display Usage

use tracing::field::display;
use tracing::{info, Level};
 
fn lazy_formatting() {
    let data = ExpensiveToFormat { values: vec
![1, 2, 3, 4, 5] }
;
    
    // field::display wraps the value for lazy formatting
    info!(data = %data, "Processing data");
    // Same as:
    info!(data = display(&data), "Processing data");
    
    // The format is ONLY called if the subscriber records this event
    // If INFO level is disabled, Display::fmt is never called
}

field::display creates a DisplayValue wrapper that defers formatting.

Explicit display Wrapper

use tracing::field::display;
use tracing::{info, debug, trace};
 
fn explicit_display() {
    let value = MyStruct { id: 42, name: "test".to_string() };
    
    // Method 1: Using % shorthand
    info!(value = %value, "Using shorthand");
    
    // Method 2: Using display() function explicitly
    info!(value = display(&value), "Using display function");
    
    // Method 3: Using field assignment
    let field = tracing::field::display(&value);
    info!(value = field, "Using field variable");
    
    // All three are equivalent for Display types
}
 
struct MyStruct {
    id: u32,
    name: String,
}
 
impl std::fmt::Display for MyStruct {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "MyStruct(id={}, name={})", self.id, self.name)
    }
}

The % sigil is shorthand for display().

field::debug for Debug Types

use tracing::field::debug;
use tracing::info;
 
fn debug_wrapper() {
    let data = MyData { items: vec
![1, 2, 3] }
;
    
    // For Debug types, use debug() or ? shorthand
    info!(data = ?data, "Using Debug shorthand");
    // Equivalent to:
    info!(data = debug(&data), "Using debug function");
    
    // The ? sigil is shorthand for debug()
    // The Debug format is ONLY called if the event is recorded
}
 
struct MyData {
    items: Vec<i32>,
}
 
impl std::fmt::Debug for MyData {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Expensive debug formatting
        write!(f, "MyData {{ items: {:?}, sum: {} }}", 
               self.items, 
               self.items.iter().sum::<i32>())
    }
}

field::debug provides the same lazy evaluation for Debug types.

Performance Comparison

use tracing::field::display;
use tracing::{info, Level};
 
struct ExpensiveDisplay {
    data: Vec<String>,
}
 
impl std::fmt::Display for ExpensiveDisplay {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Simulate expensive formatting
        let formatted: String = self.data.iter()
            .map(|s| s.to_uppercase())
            .collect::<Vec<_>>()
            .join(", ");
        write!(f, "[{}]", formatted)
    }
}
 
fn performance_comparison() {
    let expensive = ExpensiveDisplay { 
        data: (0..1000).map(|i| format!("item_{}", i)).collect() 
    };
    
    // BAD: Always formats, even if disabled
    let start = std::time::Instant::now();
    for _ in 0..10000 {
        info!("{}", expensive.to_string());  // Always calls Display::fmt
    }
    let eager_time = start.elapsed();
    
    // GOOD: Only formats if needed
    let start = std::time::Instant::now();
    for _ in 0..10000 {
        info!(data = display(&expensive), "Processing");  // Lazy!
    }
    let lazy_time = start.elapsed();
    
    println!("Eager: {:?}", eager_time);
    println!("Lazy: {:?}", lazy_time);
    // Lazy is much faster when logging is disabled
}

The difference is dramatic when logging is disabled at compile time.

When Formatting Actually Happens

use tracing::field::display;
use tracing::{info, span, Level};
 
struct Tracker {
    name: &'static str,
}
 
impl std::fmt::Display for Tracker {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        println!("Display::fmt called for {}", self.name);
        write!(f, "Tracker({})", self.name)
    }
}
 
fn when_formatting_happens() {
    // Scenario 1: Subscriber records INFO
    let subscriber = tracing_subscriber::fmt()
        .with_max_level(Level::INFO)
        .finish();
    tracing::subscriber::with_default(subscriber, || {
        let tracker = Tracker { name: "enabled" };
        info!(tracker = display(&tracker), "This is logged");
        // Output: "Display::fmt called for enabled"
        // The subscriber records INFO, so Display::fmt IS called
    });
    
    // Scenario 2: Subscriber filtered out (TRACE level)
    let subscriber = tracing_subscriber::fmt()
        .with_max_level(Level::ERROR)  // INFO disabled
        .finish();
    tracing::subscriber::with_default(subscriber, || {
        let tracker = Tracker { name: "disabled" };
        info!(tracker = display(&tracker), "This is NOT logged");
        // NO output! Display::fmt is NEVER called
        // The event is filtered out before formatting
    });
}

Formatting is skipped entirely when the event is filtered out.

The Shorthand Syntax

use tracing::{info, debug, trace, warn, error};
 
fn shorthand_syntax() {
    let value = MyDisplayType { id: 42 };
    
    // Display shorthand: % sigil
    info!(value = %value, "Using % shorthand");
    // Equivalent to:
    info!(value = display(&value), "Using display function");
    
    // Debug shorthand: ? sigil
    info!(value = ?value, "Using ? shorthand");
    // Equivalent to:
    info!(value = debug(&value), "Using debug function");
    
    // Mixed fields
    let other = 123;
    info!(
        display_field = %value,    // Display (lazy)
        debug_field = ?value,      // Debug (lazy)
        regular_field = other,     // Debug by default (lazy for primitive)
        "Mixed field types"
    );
}
 
struct MyDisplayType {
    id: u32,
}
 
impl std::fmt::Display for MyDisplayType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "ID({})", self.id)
    }
}

The % and ? sigils provide cleaner syntax for common cases.

Inside Spans

use tracing::field::display;
use tracing::{info, span, Level};
 
fn spans_and_display() {
    let data = ExpensiveData { items: vec
![1, 2, 3] }
;
    
    // Spans also support lazy formatting
    let span = span!(Level::INFO, "operation", data = %data);
    let _enter = span.enter();
    
    // If the span is disabled, Display::fmt is never called
    info!("Doing work inside span");
    
    // The span's data is formatted only when recorded
}
 
struct ExpensiveData {
    items: Vec<i32>,
}
 
impl std::fmt::Display for ExpensiveData {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let sum: i32 = self.items.iter().sum();
        write!(f, "ExpensiveData {{ sum: {}, count: {} }}", sum, self.items.len())
    }
}

Spans benefit from lazy formatting just like events.

Custom Display Implementations

use tracing::field::display;
use tracing::info;
 
struct HttpRequest {
    method: String,
    path: String,
    headers: Vec<(String, String)>,
    body: Option<String>,
}
 
impl std::fmt::Display for HttpRequest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Expensive: format all headers and body
        let headers_str = self.headers.iter()
            .map(|(k, v)| format!("{}: {}", k, v))
            .collect::<Vec<_>>()
            .join("\n  ");
        
        write!(f, "{} {}\n  {}\n\n{}", 
               self.method, 
               self.path, 
               headers_str,
               self.body.as_deref().unwrap_or(""))
    }
}
 
fn log_http_request() {
    let request = HttpRequest {
        method: "POST".to_string(),
        path: "/api/users".to_string(),
        headers: vec
![("Content-Type".to_string(), "application/json".to_string())]
,
        body: Some(r#"{"name": "Alice"}"#.to_string()),
    };
    
    // With lazy formatting, the expensive Display is only called if needed
    info!(request = display(&request), "Handling request");
    
    // Without lazy formatting:
    // info!(request = %request.to_string(), "Handling request");
    // This would ALWAYS format, even for disabled log levels
}

Expensive Display implementations benefit most from lazy formatting.

Combining with Other Field Types

use tracing::field::display;
use tracing::info;
 
fn mixed_fields() {
    let expensive = ExpensiveStruct::new();
    let cheap_value = 42;
    let debug_value = vec
![1, 2, 3]
;
    
    // Mix different field types
    info!(
        expensive = display(&expensive),  // Lazy Display
        debug_field = ?debug_value,       // Lazy Debug
        cheap_field = cheap_value,        // Primitive (cheap to format)
        owned_string = %format!("computed: {}", 123),  // NOT lazy!
        "Processing with mixed fields"
    );
    
    // Note: format!() is NOT lazy - it's evaluated eagerly
    // For truly lazy evaluation, use display() with a closure-like pattern
}
 
struct ExpensiveStruct {
    data: Vec<i32>,
}
 
impl ExpensiveStruct {
    fn new() -> Self {
        Self { data: (0..1000).collect() }
    }
}
 
impl std::fmt::Display for ExpensiveStruct {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "ExpensiveStruct {{ sum: {} }}", self.data.iter().sum::<i32>())
    }
}

Owned strings and format! calls are still evaluated eagerly.

Tracing Macros and Field Recording

use tracing::field::{display, debug};
use tracing::{info, debug as log_debug, trace, Level};
 
fn field_recording_order() {
    let data = Tracker { name: "test" };
    
    // Fields are recorded in order when the event is emitted
    trace!(
        field1 = display(&data),  // Recorded if trace enabled
        field2 = ?data,           // Recorded if trace enabled
        field3 = 123,             // Recorded if trace enabled
        "Multiple fields"
    );
    
    // If trace level is disabled, NONE of the Display/Debug calls happen
}
 
struct Tracker {
    name: &'static str,
}
 
impl std::fmt::Display for Tracker {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Tracker({})", self.name)
    }
}
 
impl std::fmt::Debug for Tracker {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Tracker::Debug({})", self.name)
    }
}

All fields are lazy; none are evaluated if the event is filtered.

When display() Doesn't Help

use tracing::field::display;
use tracing::info;
 
fn when_display_doesnt_help() {
    // Case 1: Pre-computed strings don't need display()
    let message = format!("Computed: {}", expensive_computation());
    info!(message = %message, "Pre-computed string");
    // format!() was already called, display() just wraps the String
    
    // Case 2: Primitives are cheap to format
    let number = 42;
    info!(number = %number, "Cheap to format");
    // No benefit - integers format instantly
    
    // Case 3: Always-emitted logs with simple types
    info!(value = %"simple", "Always shown");
    // If INFO is always enabled, no benefit
    
    // Case 4: Creating the value is expensive (not just formatting)
    let expensive = build_expensive_object();  // This call happens regardless!
    info!(expensive = display(&expensive), "Using expensive object");
    // The build_expensive_object() call still happens
}
 
fn expensive_computation() -> i32 {
    // Simulate expensive work
    std::thread::sleep(std::time::Duration::from_millis(100));
    42
}
 
fn build_expensive_object() -> ExpensiveObject {
    // Expensive to CREATE, not just to format
    ExpensiveObject { data: vec
![0; 10000] }
}
 
struct ExpensiveObject {
    data: Vec<u32>,
}
 
impl std::fmt::Display for ExpensiveObject {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Object with {} items", self.data.len())
    }
}

display() only defers formatting, not object creation.

Comparison with Eager Alternatives

use tracing::field::display;
use tracing::info;
 
struct LogData {
    entries: Vec<String>,
}
 
impl std::fmt::Display for LogData {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{} entries", self.entries.len())
    }
}
 
fn comparison() {
    let data = LogData { entries: vec
!["a".to_string(); 100] }
;
    
    // Option 1: Eager format in string interpolation (WORST)
    // info!("Data: {}", data);  // ALWAYS formats
    
    // Option 2: Eager format to variable (BAD)
    // let formatted = data.to_string();
    // info!(data = %formatted, "Data");  // ALWAYS formats
    
    // Option 3: Lazy with % sigil (GOOD)
    info!(data = %data, "Data");  // Only formats if needed
    
    // Option 4: Lazy with display() (GOOD - explicit)
    info!(data = display(&data), "Data");  // Only formats if needed
    
    // Option 5: In-span format (STILL EAGER!)
    // info!(data = format!("{}", data), "Data");  // format! is called
}

The % and ? sigils are the most idiomatic for lazy formatting.

Real-World Example: HTTP Server

use tracing::field::display;
use tracing::{info, debug, trace, warn};
 
struct HttpRequest {
    method: String,
    path: String,
    body: Option<String>,
}
 
impl std::fmt::Display for HttpRequest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self.body {
            Some(body) => write!(f, "{} {} ({} bytes)", self.method, self.path, body.len()),
            None => write!(f, "{} {} (no body)", self.method, self.path),
        }
    }
}
 
fn handle_request(request: HttpRequest) {
    // Trace-level logging - only formats if trace enabled
    trace!(request = display(&request), "Received request");
    
    // Process request...
    let response = process(&request);
    
    // Info-level with expensive debug info
    debug!(request = ?request, response = ?response, "Request processed");
}
 
fn process(request: &HttpRequest) -> HttpResponse {
    HttpResponse { status: 200 }
}
 
struct HttpResponse {
    status: u16,
}
 
impl std::fmt::Debug for HttpResponse {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "HttpResponse {{ status: {} }}", self.status)
    }
}

HTTP servers benefit from lazy formatting in hot paths.

Subscriber Filtering

use tracing::field::display;
use tracing::{info, debug, trace, Level};
use tracing_subscriber::EnvFilter;
 
fn subscriber_filtering() {
    // Set up subscriber with specific filter
    let subscriber = tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env()
            .add_directive(Level::INFO.into()))
        .finish();
    
    tracing::subscriber::with_default(subscriber, || {
        // This WILL be logged (INFO level)
        info!(important = %"value", "Important info");
        
        // This will NOT be logged (DEBUG level)
        // Display::fmt is NEVER called
        debug!(debug_data = display(&expensive_format()), "Debug info");
        
        // This will NOT be logged (TRACE level)
        // Display::fmt is NEVER called
        trace!(trace_data = display(&very_expensive()), "Trace info");
    });
}
 
fn expensive_format() -> ExpensiveType {
    ExpensiveType { data: vec
![0; 1000] }
}
 
fn very_expensive() -> VeryExpensiveType {
    VeryExpensiveType { data: vec
![0; 10000] }
}
 
struct ExpensiveType {
    data: Vec<u32>,
}
 
impl std::fmt::Display for ExpensiveType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "ExpensiveType {{ len: {} }}", self.data.len())
    }
}
 
struct VeryExpensiveType {
    data: Vec<u32>,
}
 
impl std::fmt::Display for VeryExpensiveType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Very expensive formatting
        let sum: u64 = self.data.iter().map(|&x| x as u64).sum();
        write!(f, "VeryExpensiveType {{ sum: {} }}", sum)
    }
}

Filtered-out events skip formatting entirely.

Display vs Debug

use tracing::field::{display, debug};
use tracing::info;
 
fn display_vs_debug() {
    let value = MyType { 
        id: 42, 
        name: "test".to_string(),
        internal: vec
![1, 2, 3],
 }
;
    
    // display(): Uses Display trait (human-readable)
    info!(value = display(&value), "Human readable");
    // Output: "MyType(42, test)"
    
    // debug(): Uses Debug trait (developer-readable)
    info!(value = debug(&value), "Developer readable");
    // Output: "MyType { id: 42, name: \"test\", internal: [1, 2, 3] }"
    
    // Choose based on audience:
    // - Display for end-user logs
    // - Debug for developer logs
}
 
struct MyType {
    id: u32,
    name: String,
    internal: Vec<i32>,
}
 
impl std::fmt::Display for MyType {
    fn fmt(&self, f: & mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "MyType({}, {})", self.id, self.name)
    }
}
 
impl std::fmt::Debug for MyType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MyType")
            .field("id", &self.id)
            .field("name", &self.name)
            .field("internal", &self.internal)
            .finish()
    }
}

Use display for user-friendly output, debug for developer output.

Summary Table

use tracing::field::{display, debug};
use tracing::info;
 
fn summary() {
    let value = MyValue { data: 42 };
    
    // | Approach        | Syntax               | Lazy? | Trait    |
    // |-----------------|----------------------|-------|----------|
    // | Shorthand       | field = %value       | Yes   | Display  |
    // | Shorthand       | field = ?value       | Yes   | Debug    |
    // | Function        | field = display(&v)  | Yes   | Display  |
    // | Function        | field = debug(&v)     | Yes   | Debug    |
    // | Eager           | field = value        | Yes*  | Debug    |
    // | Eager format    | field = format!(...) | No    | N/A      |
    // | Eager to_string | field = v.to_string()| No    | N/A      |
    
    // *Primitives are cheap; complex types still use Debug
}
 
struct MyValue {
    data: u32,
}
 
impl std::fmt::Display for MyValue {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "MyValue({})", self.data)
    }
}

Choose based on trait implementation and verbosity needs.

Synthesis

Quick reference:

use tracing::field::display;
use tracing::info;
 
// For Display types - use % or display()
let value = MyDisplayType::new();
info!(value = %value, "Using shorthand");
info!(value = display(&value), "Using function");
 
// For Debug types - use ? or debug()
let data = vec
![1, 2, 3]
;
info!(data = ?data, "Using shorthand");
info!(data = debug(&data), "Using function");
 
// For expensive formatting - always use lazy
struct Expensive;
impl std::fmt::Display for Expensive {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Expensive work here
        write!(f, "computed")
    }
}
let expensive = Expensive;
info!(expensive = %expensive, "Only formats if INFO enabled");

When to use display():

// Use display/debug when:
// 1. Display/Debug implementation is expensive (complex formatting)
// 2. Log level may be disabled (filtered out)
// 3. Hot code paths (called frequently)
// 4. Structs with expensive to_string() implementations
 
// Don't bother when:
// 1. Simple types (integers, small strings)
// 2. Always-emitted logs (no filtering)
// 3. Cold code paths (startup, shutdown)
// 4. Value creation is the expensive part (display only defers formatting)

Key insight: tracing::field::display exists because structured logging has a unique property: most log statements are never emitted. In production, you might log at INFO level, skipping thousands of DEBUG and TRACE statements. Without lazy formatting, every one of those skipped statements would still incur the cost of formatting its arguments—a waste of CPU cycles. The % and ? sigils (and their display()/debug() function equivalents) solve this by wrapping values in a DisplayValue/DebugValue type that implements Value but doesn't call Display::fmt/Debug::fmt until the subscriber actually records the field. The tracing macros check the subscriber's filter before recording any fields—if the event is disabled, the Value::record method is never called, and Display::fmt is never invoked. This is why tracing can have info! statements in hot loops without performance penalty when info-level logging is disabled: the entire formatting cost is skipped. The caveat is that display() only defers formatting, not value creation—if build_expensive_object() creates a value that's then logged with display(), the expensive creation still happens. For that case, you'd need conditional logging or if_enabled! style guards. The % shorthand is preferred for readability, but display() is useful when you need to pass the field value as a variable or compose it with other field operations.