How does lazy_static::lazy_static! differ from once_cell::sync::Lazy for lazy static initialization patterns?

lazy_static::lazy_static! and once_cell::sync::Lazy both provide lazy initialization of static values, but they differ significantly in API design, ergonomics, and integration with Rust's type system. The lazy_static! macro uses a code-generation approach that creates hidden structs implementing Deref, while once_cell::Lazy<T> is a direct type that holds an initialization closure and value, providing more idiomatic Rust integration, better error messages, and simpler access patterns. The once_cell approach is now part of the standard library (as std::sync::OnceLock and std::sync::LazyLock in recent versions), making lazy_static largely obsolete for new code.

Basic lazy_static Usage

use lazy_static::lazy_static;
use std::sync::Mutex;
 
lazy_static! {
    static ref CONFIG: Mutex<Config> = Mutex::new(Config::default());
    static ref CACHE: Mutex<Vec<String>> = Mutex::new(Vec::new());
}
 
#[derive(Default)]
struct Config {
    debug: bool,
    port: u16,
}
 
fn main() {
    // Access via deref coercion
    CONFIG.lock().unwrap().debug = true;
    CACHE.lock().unwrap().push("entry".to_string());
    
    // The static is initialized on first access
    // Subsequent accesses use the cached value
}

lazy_static! uses a macro to generate hidden structs that implement Deref for lazy access.

Basic once_cell::Lazy Usage

use once_cell::sync::Lazy;
use std::sync::Mutex;
 
static CONFIG: Lazy<Mutex<Config>> = Lazy::new(|| Mutex::new(Config::default()));
static CACHE: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
 
#[derive(Default)]
struct Config {
    debug: bool,
    port: u16,
}
 
fn main() {
    // Access is direct, no special syntax
    CONFIG.lock().unwrap().debug = true;
    CACHE.lock().unwrap().push("entry".to_string());
    
    // Lazy<T> implements Deref<Target = T>
    // First access initializes the value
}

once_cell::Lazy uses a regular static declaration with a type that handles lazy initialization.

Syntax Comparison

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
 
// lazy_static: macro-based syntax
lazy_static! {
    static ref VALUE1: String = String::from("hello");
    static ref VALUE2: Vec<i32> = {
        let mut v = Vec::new();
        v.push(1);
        v.push(2);
        v
    };
}
 
// once_cell: type-based syntax
static VALUE3: Lazy<String> = Lazy::new(|| String::from("hello"));
static VALUE4: Lazy<Vec<i32>> = Lazy::new(|| {
    let mut v = Vec::new();
    v.push(1);
    v.push(2);
    v
});
 
fn main() {
    // Both work the same way at runtime
    println!("lazy_static: {}", *VALUE1);
    println!("once_cell: {}", *VALUE3);
}

lazy_static uses a macro block with static ref; once_cell::Lazy uses normal static declarations.

Type Visibility

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
 
// lazy_static: hidden type, accessed via Deref
lazy_static! {
    static ref LAZY_STATIC_VALUE: i32 = 42;
}
 
// once_cell: explicit type Lazy<i32>
static ONCE_CELL_VALUE: Lazy<i32> = Lazy::new(|| 42);
 
fn main() {
    // lazy_static: type is opaque
    // The macro creates something like:
    // struct LAZY_STATIC_VALUE { ... }
    // impl Deref for LAZY_STATIC_VALUE { ... }
    
    // once_cell: type is Lazy<i32>
    // You can see and name the type
    fn use_lazy_static(value: &i32) {
        println!("Value: {}", value);
    }
    
    use_lazy_static(&ONCE_CELL_VALUE);
    
    // With lazy_static, you'd need:
    fn use_lazy_static_macro(value: &i32) {
        println!("Value: {}", value);
    }
    
    // The Deref happens automatically
    use_lazy_static_macro(&LAZY_STATIC_VALUE);
}

once_cell::Lazy exposes the actual type; lazy_static hides it behind a macro-generated struct.

Deref Behavior

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
 
lazy_static! {
    static ref LAZY_VEC: Vec<i32> = vec
![1, 2, 3];
}
 
static ONCE_VEC: Lazy<Vec<i32>> = Lazy::new(|| vec
![1, 2, 3]);
 
fn main() {
    // Both implement Deref to the inner type
    
    // lazy_static: deref coercion
    let len = LAZY_VEC.len()
; // Deref to Vec<i32>
    let first = LAZY_VEC[0]; // Index uses Deref
    
    // once_cell: same deref coercion
    let len = ONCE_VEC.len();
    let first = ONCE_VEC[0];
    
    // Explicit deref works for both
    let vec_ref: &Vec<i32> = &*LAZY_VEC;
    let vec_ref: &Vec<i32> = &*ONCE_VEC;
    
    println!("lazy_static len: {}", len);
    println!("once_cell len: {}", len);
}

Both use Deref to provide transparent access to the inner value.

Initialization Closure

use once_cell::sync::Lazy;
 
// once_cell::Lazy takes a closure for initialization
static DATABASE: Lazy<Database> = Lazy::new(|| {
    // Complex initialization
    let db = Database::connect("localhost:5432")
        .expect("Failed to connect");
    db.run_migrations();
    db
});
 
static COMPUTED: Lazy<String> = Lazy::new(|| {
    // Expensive computation
    let mut result = String::new();
    for i in 0..100 {
        result.push_str(&format!("{}-", i));
    }
    result
});
 
struct Database {
    url: String,
}
 
impl Database {
    fn connect(url: &str) -> Result<Self, String> {
        Ok(Database { url: url.to_string() })
    }
    
    fn run_migrations(&self) {
        // Migration logic
    }
}
 
fn main() {
    // Initialization happens on first access
    // The closure is called exactly once
    println!("Database: {}", DATABASE.url);
    println!("Computed: {}", COMPUTED.chars().take(10).collect::<String>());
}

Lazy::new takes a FnOnce closure that initializes the value on first access.

Error Handling During Initialization

use once_cell::sync::Lazy;
use std::sync::OnceLock;
 
// Lazy cannot handle initialization errors
// If the closure panics, Lazy is poisoned
 
// Use OnceLock for fallible initialization
static MAYBE_DATABASE: OnceLock<Database> = OnceLock::new();
 
struct Database {
    connected: bool,
}
 
impl Database {
    fn connect() -> Result<Self, String> {
        // Simulated connection
        Ok(Database { connected: true })
    }
}
 
fn get_database() -> &'static Database {
    MAYBE_DATABASE.get_or_init(|| {
        // This closure returns the value, not Result
        Database::connect().expect("Database connection failed")
    })
}
 
// For fallible initialization, use get_or_try_init
fn get_database_fallible() -> Result<&'static Database, String> {
    static DB: OnceLock<Database> = OnceLock::new();
    DB.get_or_try_init(|| Database::connect())
}
 
fn main() {
    let db = get_database();
    println!("Database connected: {}", db.connected);
}

For fallible initialization, use OnceLock::get_or_try_init instead of Lazy.

Thread Safety

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
use std::sync::Mutex;
use std::thread;
 
lazy_static! {
    static ref LAZY_COUNTER: Mutex<i32> = Mutex::new(0);
}
 
static ONCE_COUNTER: Lazy<Mutex<i32>> = Lazy::new(|| Mutex::new(0));
 
fn main() {
    // Both are thread-safe
    // Only one thread initializes the value
    // All threads see the same value
    
    let mut handles = vec
![];
    
    for _ in 0..10 {
        let handle = thread::spawn(|| {
            *LAZY_COUNTER.lock().unwrap() += 1;
            *ONCE_COUNTER.lock().unwrap() += 1;
        });
        handles.push(handle)
;
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("lazy_static counter: {}", *LAZY_COUNTER.lock().unwrap());
    println!("once_cell counter: {}", *ONCE_COUNTER.lock().unwrap());
}

Both lazy_static! and once_cell::sync::Lazy provide thread-safe initialization.

Initialization Timing

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
 
lazy_static! {
    static ref LAZY_INIT: String = {
        println!("lazy_static initializing");
        String::from("lazy")
    };
}
 
static ONCE_INIT: Lazy<String> = Lazy::new(|| {
    println!("once_cell initializing");
    String::from("once")
});
 
fn main() {
    println!("Before first access");
    
    // This triggers lazy_static initialization
    let _ = &*LAZY_INIT;
    
    println!("After lazy_static");
    
    // This triggers once_cell initialization
    let _ = &*ONCE_INIT;
    
    println!("After once_cell");
    
    // Both initialize lazily - only when first accessed
}

Both approaches initialize values on first access, not at program startup.

Standard Library Integration

// Rust 1.70+ includes OnceLock and LazyLock in std
// once_cell::sync::Lazy -> std::sync::LazyLock (Rust 1.80+)
// once_cell::sync::OnceLock -> std::sync::OnceLock
 
use std::sync::OnceLock;
 
static STD_LAZY: OnceLock<String> = OnceLock::new();
 
fn main() {
    // Standard library approach (Rust 1.70+)
    let value = STD_LAZY.get_or_init(|| {
        String::from("standard library")
    });
    
    println!("Value: {}", value);
    
    // OnceLock is in std::sync
    // LazyLock (once_cell::Lazy equivalent) is in std::sync (Rust 1.80+)
}

Modern Rust includes OnceLock and LazyLock in the standard library.

Complex Types

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
use std::collections::HashMap;
 
lazy_static! {
    static ref LAZY_MAP: HashMap<&'static str, i32> = {
        let mut m = HashMap::new();
        m.insert("one", 1);
        m.insert("two", 2);
        m.insert("three", 3);
        m
    };
}
 
static ONCE_MAP: Lazy<HashMap<&'static str, i32>> = Lazy::new(|| {
    let mut m = HashMap::new();
    m.insert("one", 1);
    m.insert("two", 2);
    m.insert("three", 3);
    m
});
 
fn main() {
    // Both work identically for complex types
    println!("lazy_static map: {:?}", *LAZY_MAP);
    println!("once_cell map: {:?}", *ONCE_MAP);
    
    // Access elements
    println!("one: {}", LAZY_MAP.get("one").unwrap());
    println!("two: {}", ONCE_MAP.get("two").unwrap());
}

Both handle complex types like HashMap equivalently.

Const Patterns

use once_cell::sync::Lazy;
use std::sync::OnceLock;
 
// Lazy cannot be const-initialized with a non-const closure
// But OnceLock can be const-initialized with a None value
 
static EMPTY_VEC: OnceLock<Vec<i32>> = OnceLock::new();
 
// Lazy requires an initialization closure
static LAZY_VEC: Lazy<Vec<i32>> = Lazy::new(Vec::new);
 
fn main() {
    // OnceLock::new() is const
    // Lazy::new() is not const (takes a closure)
    
    // Initialize when needed
    EMPTY_VEC.get_or_init(|| vec
![1, 2, 3]);
    
    println!("OnceLock: {:?}", EMPTY_VEC.get().unwrap())
;
    println!("Lazy: {:?}", *LAZY_VEC);
}

OnceLock::new() is const; Lazy::new() requires a non-const closure.

Lazy Static with Dependencies

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
 
// lazy_static: dependencies between statics work
lazy_static! {
    static ref BASE_CONFIG: Config = Config::default();
    static ref DERIVED_CONFIG: DerivedConfig = {
        DerivedConfig::from_base(&BASE_CONFIG)
    };
}
 
// once_cell: same dependency pattern
static BASE_CONFIG2: Lazy<Config> = Lazy::new(Config::default);
static DERIVED_CONFIG2: Lazy<DerivedConfig> = Lazy::new(|| {
    DerivedConfig::from_base(&BASE_CONFIG2)
});
 
#[derive(Default)]
struct Config {
    port: u16,
}
 
struct DerivedConfig {
    port: u16,
    host: String,
}
 
impl DerivedConfig {
    fn from_base(base: &Config) -> Self {
        DerivedConfig {
            port: base.port,
            host: "localhost".to_string(),
        }
    }
}
 
fn main() {
    // Both handle dependencies correctly
    // DERIVED_CONFIG depends on BASE_CONFIG
    // Accessing DERIVED_CONFIG first initializes BASE_CONFIG
    
    println!("Port: {}", DERIVED_CONFIG.port);
    println!("Port: {}", DERIVED_CONFIG2.port);
}

Both handle dependencies between lazy statics correctly.

Performance Characteristics

use lazy_static::lazy_static;
use once_cell::sync::Lazy;
use std::time::Instant;
 
lazy_static! {
    static ref LAZY_VALUE: String = {
        // Simulated expensive initialization
        std::thread::sleep(std::time::Duration::from_millis(10));
        String::from("lazy")
    };
}
 
static ONCE_VALUE: Lazy<String> = Lazy::new(|| {
    std::thread::sleep(std::time::Duration::from_millis(10));
    String::from("once")
});
 
fn main() {
    // First access: initializes
    let start = Instant::now();
    let _ = &*LAZY_VALUE;
    println!("lazy_static first: {:?}", start.elapsed());
    
    let start = Instant::now();
    let _ = &*ONCE_VALUE;
    println!("once_cell first: {:?}", start.elapsed());
    
    // Subsequent access: cached
    let start = Instant::now();
    let _ = &*LAZY_VALUE;
    println!("lazy_static cached: {:?}", start.elapsed());
    
    let start = Instant::now();
    let _ = &*ONCE_VALUE;
    println!("once_cell cached: {:?}", start.elapsed());
    
    // Both have similar performance after initialization
    // once_cell may be slightly faster due to simpler implementation
}

Both have similar performance; once_cell may be slightly faster after initialization.

Debugging and Error Messages

use once_cell::sync::Lazy;
 
// once_cell provides better error messages
// because the type is explicit
 
static VALUE: Lazy<String> = Lazy::new(|| String::from("hello"));
 
fn main() {
    // Type is clearly Lazy<String>
    // Compiler errors show the actual type
    
    // Error: mismatched types
    // let v: i32 = VALUE; // Error: expected i32, found Lazy<String>
    
    // With lazy_static, the type is hidden
    // Error messages may be less clear
    
    // once_cell integrates better with IDE features
    // - Go to definition works
    // - Type inference works
    // - Documentation is accessible
}

once_cell::Lazy provides better IDE integration and error messages due to explicit typing.

Migration Pattern

// Before: lazy_static
// lazy_static! {
//     static ref CONFIG: Mutex<Config> = Mutex::new(Config::default());
// }
 
// After: once_cell::sync::Lazy
use once_cell::sync::Lazy;
use std::sync::Mutex;
 
static CONFIG: Lazy<Mutex<Config>> = Lazy::new(|| Mutex::new(Config::default()));
 
// Or with std (Rust 1.80+)
// static CONFIG: std::sync::LazyLock<Mutex<Config>> = 
//     std::sync::LazyLock::new(|| Mutex::new(Config::default()));
 
#[derive(Default)]
struct Config {
    debug: bool,
}
 
fn main() {
    // Usage remains the same
    CONFIG.lock().unwrap().debug = true;
}

Migration from lazy_static to once_cell is straightforward: remove the macro and change the type.

Synthesis

API Comparison:

Aspect lazy_static! once_cell::Lazy
Syntax Macro block Type annotation
Type visibility Hidden Explicit Lazy<T>
Initialization Closure in macro Lazy::new(|| ...)
Access Deref coercion Deref coercion
Thread safety Yes Yes
Std support No Yes (as LazyLock)

Key Differences:

Feature lazy_static once_cell
Type clarity Opaque Explicit
IDE support Limited Full
Error messages May be unclear Clear types
Std integration External crate In std (Rust 1.80+)
Fallible init Not supported Use OnceLock
Const init No OnceLock::new()

Use Cases:

Scenario Recommended
New code std::sync::LazyLock or once_cell::sync::Lazy
Fallible initialization OnceLock::get_or_try_init
Simple lazy value Lazy::new(|| value)
Complex init Lazy::new(|| { ... })
Const context OnceLock::new() then get_or_init

Key insight: lazy_static::lazy_static! and once_cell::sync::Lazy achieve the same goal—lazy initialization of static values—but differ fundamentally in approach. lazy_static! is a macro that generates hidden types implementing Deref, while once_cell::Lazy<T> is an explicit type that takes an initialization closure. This difference has practical implications: once_cell::Lazy provides better IDE support (go-to-definition, type hints), clearer compiler error messages, and composes naturally with Rust's type system. The once_cell approach has been incorporated into the standard library as std::sync::OnceLock and std::sync::LazyLock (Rust 1.70+ and 1.80+), making lazy_static largely obsolete for new code. For fallible initialization where the closure might return Result, use OnceLock::get_or_try_init which can propagate initialization errors rather than panic. The underlying implementation of both uses similar synchronization primitives (typically Once), so performance is comparable after initialization.