What is the difference between once_cell::sync::Lazy and lazy_static! macro for static initialization?

Lazy static initialization defers the creation of values until first access, avoiding the overhead of startup initialization and enabling runtime-initialized statics. Both once_cell::sync::Lazy and the lazy_static! macro solve this problem, but with different ergonomics, capabilities, and integration patterns.

The Problem: Static Initialization Constraints

Rust's static initialization is constrained to compile-time constants:

// This works: compile-time constant
static MAX_CONNECTIONS: usize = 100;
 
// This fails: HashMap needs runtime initialization
static CONFIG: HashMap<String, String> = HashMap::new();
// Error: calls in constants are limited to constant functions
 
// This fails: function call at static initialization
static TIMESTAMP: String = format!("{}: {}", chrono::Utc::now(), "started");
// Error: calls in constants are limited to constant functions

Lazy initialization solves this by deferring the initialization to first access.

lazy_static!: The Original Macro Approach

The lazy_static! macro wraps initialization in a one-time execution block:

use lazy_static::lazy_static;
use std::collections::HashMap;
 
lazy_static! {
    static ref CONFIG: HashMap<&'static str, &'static str> = {
        let mut m = HashMap::new();
        m.insert("host", "localhost");
        m.insert("port", "8080");
        m
    };
    
    static ref TIMESTAMP: String = {
        format!("Started at: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"))
    };
}
 
fn main() {
    // First access initializes CONFIG
    println!("Host: {}", CONFIG.get("host").unwrap());
    
    // Subsequent accesses use cached value
    println!("Port: {}", CONFIG.get("port").unwrap());
}

The macro generates a unique type implementing Deref, hiding the synchronization details.

once_cell::sync::Lazy: The Type-Based Approach

once_cell::sync::Lazy provides a type that wraps initialization logic:

use once_cell::sync::Lazy;
use std::collections::HashMap;
 
static CONFIG: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
    let mut m = HashMap::new();
    m.insert("host", "localhost");
    m.insert("port", "8080");
    m
});
 
static TIMESTAMP: Lazy<String> = Lazy::new(|| {
    format!("Started at: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"))
});
 
fn main() {
    // First access initializes
    println!("Host: {}", CONFIG.get("host").unwrap());
    
    // Lazy<T> implements Deref<Target = T>
    let timestamp: &String = &*TIMESTAMP;
}

Lazy<T> is a real type you can work with directly, not a macro-generated wrapper.

Ergonomics: Macro vs Type

The macro syntax can feel unusual compared to normal Rust declarations:

use lazy_static::lazy_static;
 
// Macro syntax: unusual static ref form
lazy_static! {
    static ref VALUE: i32 = compute_value();
}
 
// vs normal static syntax
static NORMAL_VALUE: i32 = 42;

Lazy uses standard Rust syntax:

use once_cell::sync::Lazy;
 
// Standard static syntax with Lazy type
static VALUE: Lazy<i32> = Lazy::new(|| compute_value());
 
// Can even use the simpler form with const initialization
static SIMPLE: Lazy<i32> = Lazy::new(|| 42);

This consistency with normal Rust makes Lazy more approachable and IDE-friendly.

Type Visibility and Access

Lazy<T> is a concrete type, enabling patterns impossible with the macro:

use once_cell::sync::Lazy;
use std::collections::HashMap;
 
// You can name the type in function signatures
fn get_config() -> &'static Lazy<HashMap<String, String>> {
    &CONFIG
}
 
// You can store Lazy in structs
struct AppState {
    config: &'static Lazy<Config>,
    database: &'static Lazy<DatabasePool>,
}
 
static CONFIG: Lazy<HashMap<String, String>> = Lazy::new(|| {
    HashMap::new()
});
 
static DATABASE: Lazy<DatabasePool> = Lazy::new(|| {
    DatabasePool::connect()
});

With lazy_static!, you cannot name the generated type:

use lazy_static::lazy_static;
use std::collections::HashMap;
 
lazy_static! {
    static ref CONFIG: HashMap<String, String> = HashMap::new();
}
 
// Cannot write this type: the macro generates an anonymous type
// fn get_config() -> &'static ??? { &CONFIG }

Initialization Access

Lazy provides access to initialization status:

use once_cell::sync::Lazy;
 
static VALUE: Lazy<i32> = Lazy::new(|| {
    println!("Initializing!");
    42
});
 
fn main() {
    // Check if initialized without triggering initialization
    if !Lazy::initialized(&VALUE) {
        println!("Not yet initialized");
    }
    
    // Force initialization explicitly
    Lazy::force(&VALUE);
    
    // Check again
    if Lazy::initialized(&VALUE) {
        println!("Now initialized");
    }
}

lazy_static! provides no such introspection—the first Deref call always initializes.

Mutable Statics

Both approaches support mutable statics with proper synchronization:

use lazy_static::lazy_static;
use std::sync::Mutex;
 
lazy_static! {
    static ref COUNTER: Mutex<i32> = Mutex::new(0);
}
 
use once_cell::sync::Lazy;
use std::sync::Mutex;
 
static COUNTER: Lazy<Mutex<i32>> = Lazy::new(|| Mutex::new(0));
 
// Both require explicit locking
fn increment() {
    let mut count = COUNTER.lock().unwrap();
    *count += 1;
}

For simpler cases, once_cell provides race::OnceBox for atomic lazy initialization:

use once_cell::race::OnceBox;
 
static VALUE: OnceBox<i32> = OnceBox::new();
 
fn get_value() -> &'static i32 {
    VALUE.get_or_init(|| Box::new(compute_expensive_value()))
}

Generic Lazy Initialization

Lazy<T> works naturally in generic contexts:

use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::hash::Hash;
use std::fmt::Debug;
 
struct Cache<K, V> 
where 
    K: Eq + Hash + Debug + 'static,
    V: Debug + 'static,
{
    data: Lazy<HashMap<K, V>>,
}
 
impl<K, V> Cache<K, V>
where
    K: Eq + Hash + Debug + 'static,
    V: Debug + 'static,
{
    fn new() -> Self {
        Cache {
            data: Lazy::new(HashMap::new),
        }
    }
}

lazy_static! cannot be used in generic contexts because statics must have concrete types.

Integration with Other Types

once_cell provides additional types for lazy initialization patterns:

use once_cell::sync::{Lazy, OnceCell};
use std::thread;
 
// OnceCell: Manual lazy initialization
static MANUAL: OnceCell<String> = OnceCell::new();
 
fn init_manual(value: String) {
    // Only succeeds once; returns Err if already initialized
    MANUAL.set(value).expect("Already initialized");
}
 
fn get_manual() -> &'static str {
    MANUAL.get().map(|s| s.as_str()).unwrap_or("not initialized")
}
 
// Lazy: Automatic lazy initialization
static AUTO: Lazy<String> = Lazy::new(|| {
    "automatically initialized".to_string()
});
 
// unsync::Lazy for single-threaded contexts
use once_cell::unsync::Lazy as UnsyncLazy;
 
fn single_threaded() {
    let lazy: UnsyncLazy<i32> = UnsyncLazy::new(|| expensive_computation());
    println!("Value: {}", *lazy);
}
 
fn expensive_computation() -> i32 {
    42
}

The lazy_static! crate provides only the macro with thread-safe semantics.

Performance Characteristics

Both approaches use similar synchronization primitives internally:

// Both use atomic operations for the initialization check
// Both block competing threads during initialization
 
// Lazy initialization check is very fast after first init:
// - One atomic load (relaxed)
// - One pointer dereference

The overhead after initialization is minimal—a single atomic load. Initialization itself requires synchronization.

Standard Library Adoption

once_cell's patterns have been incorporated into the standard library:

// Rust 1.70+: std::sync::OnceLock (similar to once_cell::sync::OnceCell)
use std::sync::OnceLock;
 
static CONFIG: OnceLock<HashMap<String, String>> = OnceLock::new();
 
fn get_config() -> &'static HashMap<String, String> {
    CONFIG.get_or_init(|| {
        let mut m = HashMap::new();
        m.insert("key".to_string(), "value".to_string());
        m
    })
}
 
// Rust 1.80+: std::sync::LazyLock (similar to once_cell::sync::Lazy)
use std::sync::LazyLock;
 
static VALUE: LazyLock<i32> = LazyLock::new(|| expensive_init());
 
fn expensive_init() -> i32 {
    42
}

The standard library types are nearly identical to once_cell types, making the external crate optional for newer Rust versions.

Migration from lazy_static to once_cell

Migrating is straightforward:

// Before: lazy_static
use lazy_static::lazy_static;
 
lazy_static! {
    static ref CONFIG: HashMap<&'static str, String> = {
        let mut m = HashMap::new();
        m.insert("host", "localhost".to_string());
        m
    };
}
 
// After: once_cell
use once_cell::sync::Lazy;
 
static CONFIG: Lazy<HashMap<&'static str, String>> = Lazy::new(|| {
    let mut m = HashMap::new();
    m.insert("host", "localhost".to_string());
    m
});
 
// Or with std (Rust 1.80+)
use std::sync::LazyLock;
 
static CONFIG: LazyLock<HashMap<&'static str, String>> = LazyLock::new(|| {
    let mut m = HashMap::new();
    m.insert("host", "localhost".to_string());
    m
});

When to Use Each Approach

Use once_cell::sync::Lazy or std::sync::LazyLock when:

// You need type visibility
static CACHE: Lazy<Cache> = Lazy::new(Cache::new);
fn get_cache() -> &'static Lazy<Cache> { &CACHE }
 
// You need to check initialization status
if Lazy::initialized(&CACHE) { /* ... */ }
 
// You want standard Rust syntax
static VALUE: Lazy<i32> = Lazy::new(|| compute());
 
// You're in a generic context
struct Container<T> {
    cache: Lazy<HashMap<String, T>>,
}
 
// You want std compatibility (Rust 1.80+)
use std::sync::LazyLock;
static VALUE: LazyLock<String> = LazyLock::new(|| "value".to_string());

Use lazy_static! when:

// Maintaining legacy code that already uses it
lazy_static! {
    static ref CONFIG: HashMap<&'static str, &'static str> = load_config();
}
 
// You prefer the macro syntax for multiple statics
lazy_static! {
    static ref DB: Database = Database::connect();
    static ref CACHE: Cache = Cache::new();
    static ref METRICS: Metrics = Metrics::new();
}

Error Handling in Initialization

Both approaches panic on initialization failure by default, but once_cell offers alternatives:

use once_cell::sync::OnceCell;
use std::io;
 
static MAYBE_CONFIG: OnceCell<Result<Config, io::Error>> = OnceCell::new();
 
fn get_config() -> Result<&'static Config, &'static io::Error> {
    MAYBE_CONFIG
        .get_or_init(|| Config::load())
        .as_ref()
        .map(|c| *c)
        .map_err(|e| e)
}
 
// With lazy_static, panics are the only option
use lazy_static::lazy_static;
 
lazy_static! {
    static ref CONFIG: Config = Config::load().expect("Failed to load config");
}

Synthesis

once_cell::sync::Lazy and lazy_static! both provide thread-safe lazy static initialization with similar runtime performance. The key differences are ergonomic and structural:

Lazy<T> is a concrete type that integrates naturally with Rust's type system. You can name it in function signatures, store it in structs, check initialization status, and use it in generic contexts. It uses standard Rust syntax, making it more approachable and IDE-friendly.

lazy_static! uses a macro that generates an anonymous type, which works well for simple cases but limits flexibility. The macro syntax groups multiple statics together, which some developers prefer for organization.

For new code, once_cell::sync::Lazy (or std::sync::LazyLock in Rust 1.80+) is generally preferred due to better type integration and standard library alignment. The lazy_static! macro remains useful for legacy codebases and for developers who prefer its grouping syntax for multiple related statics.

Both approaches have minimal overhead after initialization—a single atomic load—and both ensure thread-safe one-time initialization. The choice is primarily about ergonomics and type system integration rather than performance.