How does strum::Display derive generate human-readable enum variant strings without manual implementation?

strum::Display generates a Display trait implementation that converts enum variant names to strings by applying configurable transformations—lowercasing, replacing underscores with spaces, or using custom attributes—eliminating the need for manual match statements or string formatting code. The derive macro inspects variant names at compile time and produces optimized code that returns static strings or formatted output, with customization through #[strum(...)] attributes for casing, prefix stripping, and per-variant overrides.

Basic Display Derive

use strum::Display;
 
#[derive(Display)]
enum Status {
    Active,
    Inactive,
    Pending,
    Complete,
}
 
fn main() {
    let status = Status::Active;
    println!("Status: {}", status);  // Output: "Active"
    
    let status = Status::Pending;
    println!("Status: {}", status);  // Output: "Pending"
    
    // The generated Display impl:
    // impl std::fmt::Display for Status {
    //     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    //         match self {
    //             Status::Active => write!(f, "Active"),
    //             Status::Inactive => write!(f, "Inactive"),
    //             Status::Pending => write!(f, "Pending"),
    //             Status::Complete => write!(f, "Complete"),
    //         }
    //     }
    // }
}

The basic derive generates a match that writes the variant name as-is.

Casing Transformations

use strum::Display;
 
#[derive(Display)]
#[strum(serialize_all = "lowercase")]
enum Status {
    Active,
    Inactive,
    Pending,
    Complete,
}
 
fn main() {
    println!("{}", Status::Active);    // "active"
    println!("{}", Status::Inactive);  // "inactive"
    println!("{}", Status::Pending);   // "pending"
}
 
#[derive(Display)]
#[strum(serialize_all = "snake_case")]
enum HttpMethod {
    Get,
    Post,
    Put,
    Delete,
    Patch,
}
 
fn main() {
    // Already simple names, but let's see more complex examples
    println!("{}", HttpMethod::Get);  // "get"
}
 
#[derive(Display)]
#[strum(serialize_all = "kebab_case")]
enum ContentType {
    ApplicationJson,
    TextHtml,
    MultipartFormData,
}
 
fn main() {
    println!("{}", ContentType::ApplicationJson);    // "application-json"
    println!("{}", ContentType::TextHtml);           // "text-html"
    println!("{}", ContentType::MultipartFormData);  // "multipart-form-data"
}
 
#[derive(Display)]
#[strum(serialize_all = "screaming_snake_case")]
enum LogLevel {
    Debug,
    Info,
    Warning,
    Error,
}
 
fn main() {
    println!("{}", LogLevel::Debug);   // "DEBUG"
    println!("{}", LogLevel::Info);    // "INFO"
    println!("{}", LogLevel::Warning); // "WARNING"
}
 
#[derive(Display)]
#[strum(serialize_all = "title_case")]
enum Priority {
    LowPriority,
    MediumPriority,
    HighPriority,
}
 
fn main() {
    println!("{}", Priority::LowPriority);    // "Low Priority"
    println!("{}", Priority::MediumPriority); // "Medium Priority"
}

serialize_all applies casing rules to all variants uniformly.

Available Casing Options

use strum::Display;
 
// All available casing options:
 
#[derive(Display)]
#[strum(serialize_all = "lowercase")]
// "SomeVariant" -> "somevariant"
struct LowercaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "UPPERCASE")]
// "SomeVariant" -> "SOMEVARIANT"
struct UppercaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "snake_case")]
// "SomeVariant" -> "some_variant"
struct SnakeCaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "kebab_case")]
// "SomeVariant" -> "some-variant"
struct KebabCaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "screaming_snake_case")]
// "SomeVariant" -> "SOME_VARIANT"
struct ScreamingSnakeCaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "title_case")]
// "SomeVariant" -> "Some Variant"
struct TitleCaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "camelCase")]
// "SomeVariant" -> "someVariant"
struct CamelCaseExample;
 
#[derive(Display)]
#[strum(serialize_all = "PascalCase")]
// "SomeVariant" -> "SomeVariant" (no change)
struct PascalCaseExample;

Casing options cover common string format conventions.

Per-Variant Customization

use strum::Display;
 
#[derive(Display)]
enum HttpResponse {
    #[strum(serialize = "OK")]
    Ok,
    
    #[strum(serialize = "Not Found")]
    NotFound,
    
    #[strum(serialize = "Internal Server Error")]
    InternalServerError,
    
    // Without attribute, uses default behavior
    BadRequest,
}
 
fn main() {
    println!("{}", HttpResponse::Ok);                // "OK"
    println!("{}", HttpResponse::NotFound);          // "Not Found"
    println!("{}", HttpResponse::InternalServerError); // "Internal Server Error"
    println!("{}", HttpResponse::BadRequest);        // "BadRequest"
}
 
// Multiple serialization options (for IntoEnumIterator)
#[derive(Display)]
enum Color {
    #[strum(serialize = "red", serialize = "Red")]
    Red,
    
    #[strum(serialize = "blue", serialize = "Blue")]
    Blue,
}
 
fn main() {
    // Display uses the first serialize value
    println!("{}", Color::Red);  // "red"
}

Use #[strum(serialize = "...")] to override individual variant strings.

Combining Default Casing with Overrides

use strum::Display;
 
#[derive(Display)]
#[strum(serialize_all = "snake_case")]
enum DatabaseError {
    ConnectionFailed,      // -> "connection_failed"
    QueryTimeout,           // -> "query_timeout"
    
    #[strum(serialize = "db_not_found")]
    DatabaseNotFound,       // -> "db_not_found" (override)
    
    PermissionDenied,       // -> "permission_denied"
    
    #[strum(serialize = "unknown")]
    Unknown,                // -> "unknown" (override)
}
 
fn main() {
    println!("{}", DatabaseError::ConnectionFailed);    // "connection_failed"
    println!("{}", DatabaseError::DatabaseNotFound);     // "db_not_found"
    println!("{}", DatabaseError::PermissionDenied);     // "permission_denied"
    println!("{}", DatabaseError::Unknown);              // "unknown"
}

Override specific variants while keeping the default casing for others.

Enums with Associated Data

use strum::Display;
 
#[derive(Display)]
enum Message {
    Simple(String),
    Complex { code: u32, text: String },
    Empty,
}
 
fn main() {
    // For variants with fields, strum uses the variant name
    // Fields are NOT included in the Display output by default
    let msg = Message::Simple("hello".to_string());
    println!("{}", msg);  // "Simple"
    
    let msg = Message::Complex {
        code: 404,
        text: "Not Found".to_string()
    };
    println!("{}", msg);  // "Complex"
    
    let msg = Message::Empty;
    println!("{}", msg);  // "Empty"
}
 
// To include field data, you need a custom impl or different approach:
 
#[derive(Display)]
enum ResultCode {
    #[strum(serialize = "Success")]
    Success,
    
    #[strum(serialize = "Error")]
    Error { code: u32 },
    // Still displays as "Error" - fields ignored by default
}
 
// For field-aware Display, implement manually:
impl std::fmt::Display for ResultCode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ResultCode::Success => write!(f, "Success"),
            ResultCode::Error { code } => write!(f, "Error(code: {})", code),
        }
    }
}

Display derive uses variant names; associated data isn't included in output.

Prefix Stripping

use strum::Display;
 
#[derive(Display)]
#[strum(serialize_all = "lowercase")]
#[strum(prefix = "status_")]  // Not a real attribute, showing concept
enum Status {
    StatusActive,
    StatusInactive,
    StatusPending,
}
 
// Actually, strum doesn't have a prefix attribute for Display
// But you can achieve similar results with naming:
 
#[derive(Display)]
#[strum(serialize_all = "lowercase")]
enum StatusClean {
    Active,    // Cleaner naming
    Inactive,
    Pending,
}
 
// Or use serialize attributes:
 
#[derive(Display)]
enum HttpStatus {
    #[strum(serialize = "active")]
    StatusActive,
    
    #[strum(serialize = "inactive")]
    StatusInactive,
    
    #[strum(serialize = "pending")]
    StatusPending,
}

Use serialize attributes to strip prefixes or rename variants.

Comparison with Manual Implementation

use strum::Display;
 
// Manual Display implementation:
 
enum StatusManual {
    Active,
    Inactive,
    Pending,
}
 
impl std::fmt::Display for StatusManual {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            StatusManual::Active => write!(f, "Active"),
            StatusManual::Inactive => write!(f, "Inactive"),
            StatusManual::Pending => write!(f, "Pending"),
        }
    }
}
 
// With strum::Display:
 
#[derive(Display)]
enum StatusStrum {
    Active,
    Inactive,
    Pending,
}
 
// Both produce identical output, but strum:
// 1. Requires less boilerplate
// 2. Is automatically updated when variants change
// 3. Supports casing transformations
// 4. Less error-prone (no manual string typing)
 
// For more complex formatting:
 
#[derive(Display)]
enum ComplexStatus {
    #[strum(serialize = "status:active")]
    Active,
    
    #[strum(serialize = "status:inactive")]
    Inactive,
    
    #[strum(serialize = "status:pending-review")]
    PendingReview,
}

The derive macro reduces boilerplate and prevents typos from manual strings.

Generated Code Examination

use strum::Display;
 
// What strum::Display generates:
 
#[derive(Display)]
#[strum(serialize_all = "lowercase")]
enum Direction {
    North,
    South,
    East,
    West,
}
 
// Approximately generates:
 
impl std::fmt::Display for Direction {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Direction::North => write!(f, "north"),
            Direction::South => write!(f, "south"),
            Direction::East => write!(f, "east"),
            Direction::West => write!(f, "west"),
        }
    }
}
 
// The key points:
// 1. Match on &self
// 2. Each arm writes a static string
// 3. No runtime string allocation for simple cases
// 4. Casing is computed at compile time
 
fn main() {
    let dir = Direction::North;
    println!("{}", dir);  // "north"
    
    // The string "north" is embedded in the binary
    // No heap allocation occurs
}

Generated code uses static strings computed at compile time.

Static String Optimization

use strum::Display;
 
#[derive(Display)]
enum Simple {
    A,
    B,
    C,
}
 
fn main() {
    // strum::Display generates code that writes static strings
    // This is as efficient as hand-written Display impl
    
    let s = format!("{}", Simple::A);
    
    // For even more control, use as_str():
    let str_val: &'static str = Simple::A.as_str();  // Only with IntoEnumIterator
    
    // But for pure Display, you can convert:
    use std::fmt::Write;
    let mut output = String::new();
    write!(output, "{}", Simple::A).unwrap();
}
 
// If you need static string access:
 
#[derive(Display, strum::EnumString)]
#[strum(serialize_all = "lowercase")]
enum StaticEnum {
    First,
    Second,
    Third,
}
 
impl StaticEnum {
    fn as_static_str(&self) -> &'static str {
        match self {
            StaticEnum::First => "first",
            StaticEnum::Second => "second",
            StaticEnum::Third => "third",
        }
    }
}
 
// Or use strum::EnumVariantNames for this pattern

For static string access, consider also using EnumVariantNames or manual as_str() methods.

Integration with Other strum Traits

use strum::{Display, EnumString, EnumIter, IntoEnumIterator};
 
#[derive(Display, EnumString, EnumIter, Debug)]
#[strum(serialize_all = "snake_case")]
enum Status {
    Active,
    Inactive,
    Pending,
    Complete,
}
 
fn main() {
    // Display: format as string
    println!("{}", Status::Active);  // "active"
    
    // EnumString: parse from string
    use std::str::FromStr;
    let status: Status = Status::from_str("active").unwrap();
    println!("{:?}", status);  // Active
    
    // EnumIter: iterate over all variants
    for status in Status::iter() {
        println!("Variant: {}", status);
    }
    // active
    // inactive
    // pending
    // complete
    
    // Combining: format and parse roundtrip
    let original = Status::Pending;
    let formatted = format!("{}", original);  // "pending"
    let parsed: Status = formatted.parse().unwrap();
    assert!(matches!(parsed, Status::Pending));
}

Combine Display with EnumString for bidirectional conversion.

Default Message Formatting

use strum::Display;
 
// For documentation or user messages:
 
#[derive(Display)]
#[strum(serialize_all = "title_case")]
enum UserRole {
    AdminUser,
    RegularUser,
    GuestUser,
}
 
fn main() {
    let role = UserRole::AdminUser;
    
    // In user-facing messages:
    println!("Welcome, {}!", role);  // "Welcome, Admin User!"
    
    // In logs:
    println!("[INFO] User role: {}", role);  // "[INFO] User role: Admin User"
    
    // In error messages:
    println!("Permission denied. Required role: {}", role);
}
 
// Compare with raw variant names:
#[derive(Debug)]
enum RoleDebug {
    AdminUser,
    RegularUser,
}
 
fn show_debug() {
    let role = RoleDebug::AdminUser;
    println!("{:?}", role);  // "AdminUser" - not as readable
}

Display provides user-friendly strings where Debug shows implementation details.

Complex Example: HTTP Headers

use strum::Display;
 
#[derive(Display)]
#[strum(serialize_all = "title_case")]
enum HttpHeader {
    ContentType,
    ContentLength,
    Authorization,
    CacheControl,
    UserAgent,
    Accept,
    AcceptEncoding,
    #[strum(serialize = "X-Custom-Header")]
    XCustomHeader,
}
 
impl HttpHeader {
    fn as_header_name(&self) -> &'static str {
        // Use the Display output as header name
        // This would require a separate method since Display returns String
        match self {
            HttpHeader::ContentType => "Content-Type",
            HttpHeader::ContentLength => "Content-Length",
            HttpHeader::Authorization => "Authorization",
            HttpHeader::CacheControl => "Cache-Control",
            HttpHeader::UserAgent => "User-Agent",
            HttpHeader::Accept => "Accept",
            HttpHeader::AcceptEncoding => "Accept-Encoding",
            HttpHeader::XCustomHeader => "X-Custom-Header",
        }
    }
}
 
fn main() {
    // Using Display for logging
    println!("Processing header: {}", HttpHeader::ContentType);
    
    // For actual header usage, you might need specific formatting
    // Display gives you a starting point
}

HTTP headers often need specific formatting; Display provides a base with overrides where needed.

Synthesis

Derive macro output:

// Input:
#[derive(Display)]
#[strum(serialize_all = "snake_case")]
enum Example {
    FirstValue,
    SecondValue,
    #[strum(serialize = "special")]
    ThirdValue,
}
 
// Generated (approximately):
impl std::fmt::Display for Example {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Example::FirstValue => write!(f, "first_value"),
            Example::SecondValue => write!(f, "second_value"),
            Example::ThirdValue => write!(f, "special"),
        }
    }
}

Casing options:

Option Input Output
lowercase SomeVariant somevariant
UPPERCASE SomeVariant SOMEVARIANT
snake_case SomeVariant some_variant
kebab_case SomeVariant some-variant
screaming_snake_case SomeVariant SOME_VARIANT
title_case SomeVariant Some Variant
camelCase SomeVariant someVariant
PascalCase SomeVariant SomeVariant

When to use strum::Display:

  • User-facing enum representation
  • Log messages with enum values
  • API responses with enum names
  • Consistent string representation across variants

When to implement Display manually:

  • Complex formatting with variant fields
  • Context-dependent string representation
  • Non-deterministic formatting
  • Performance-critical paths needing &'static str

Key insight: strum::Display is a code generator that transforms enum variant names into strings according to rules specified at derive time. The macro parses variant names, applies casing transformations, and generates a match expression that writes static strings. This eliminates the boilerplate of manual Display implementations while ensuring consistency—every variant follows the same transformation rules unless explicitly overridden. The compile-time nature of the transformation means no runtime string manipulation overhead; the formatted strings are computed during macro expansion and embedded directly in the binary. Combined with other strum traits like EnumString, you get bidirectional conversion between enums and strings with a single derive attribute, making enums first-class citizens in serialization, configuration, and user interfaces.