What is the difference between lazy_static::lazy_static! and std::sync::OnceLock for one-time initialization?

lazy_static! is a procedural macro that creates static variables with lazy initialization, while OnceLock is a type in the standard library that provides thread-safe one-time initialization without macros. Both solve the same fundamental problem—initializing static data that can't be computed at compile time—but differ significantly in ergonomics, capabilities, and API design. lazy_static! uses a macro to generate a wrapper type and deref implementation, making the lazy value transparent to access, while OnceLock requires explicit get_or_init calls but provides more control over initialization timing and the ability to inspect initialization state.

Basic lazy_static Usage

use lazy_static::lazy_static;
use std::collections::HashMap;
 
lazy_static! {
    static ref CONFIG: HashMap<&'static str, String> = {
        let mut map = HashMap::new();
        map.insert("host", "localhost".to_string());
        map.insert("port", "8080".to_string());
        map
    };
    
    static ref VERSION: String = format!("{}.{}.{}", 1, 2, 3);
}
 
fn main() {
    // Access is transparent - Deref to the underlying type
    println!("Host: {}", CONFIG["host"]);
    println!("Version: {}", *VERSION);
    
    // First access triggers initialization
    // Subsequent accesses return the cached value
}

lazy_static! creates a static reference that automatically initializes on first access.

Basic OnceLock Usage

use std::sync::OnceLock;
use std::collections::HashMap;
 
static CONFIG: OnceLock<HashMap<&'static str, String>> = OnceLock::new();
static VERSION: OnceLock<String> = OnceLock::new();
 
fn get_config() -> &'static HashMap<&'static str, String> {
    CONFIG.get_or_init(|| {
        let mut map = HashMap::new();
        map.insert("host", "localhost".to_string());
        map.insert("port", "8080".to_string());
        map
    })
}
 
fn main() {
    // Explicit initialization required
    let config = get_config();
    println!("Host: {}", config["host"]);
    
    // Can also initialize directly
    VERSION.get_or_init(|| format!("{}.{}.{}", 1, 2, 3));
    println!("Version: {}", VERSION.get().unwrap());
}

OnceLock requires explicit get_or_init calls but provides more control.

API Comparison

use std::sync::OnceLock;
 
// lazy_static: Macro generates everything
// - Creates a static variable
// - Generates a wrapper type
// - Implements Deref for transparent access
// - Handles initialization atomically
 
// OnceLock: Explicit type with methods
static VALUE: OnceLock<String> = OnceLock::new();
 
fn demonstrate_oncelock_api() {
    // get_or_init: Initialize if needed, return reference
    let value = VALUE.get_or_init(|| "initialized".to_string());
    
    // get: Returns Option<&T> - None if not initialized
    if let Some(v) = VALUE.get() {
        println!("Already initialized: {}", v);
    }
    
    // set: Returns Result<(), T> - Err if already initialized
    let result = VALUE.set("new value".to_string());
    assert!(result.is_err());  // Already initialized
    
    // take: Remove and return the value, leaving OnceLock empty
    // Only available for T: Clone or when you don't need to put it back
    // Actually: take() returns Option<T>
}
 
// lazy_static doesn't provide these capabilities:
// - No way to check if initialized without triggering init
// - No way to reinitialize
// - No way to take the value out

OnceLock provides introspection capabilities that lazy_static! lacks.

Initialization Timing Control

use std::sync::OnceLock;
 
static CACHE: OnceLock<Vec<u64>> = OnceLock::new();
 
fn expensive_computation() -> Vec<u64> {
    println!("Computing...");
    (0..1000).collect()
}
 
fn lazy_static_equivalent() {
    // lazy_static: No control - first access initializes
    // let value = &*LAZY_CACHE;  // Triggers init
    
    // OnceLock: Can defer initialization
    if some_condition() {
        // Initialize early
        CACHE.get_or_init(expensive_computation);
    }
    
    // Can check without initializing
    if CACHE.get().is_none() {
        println!("Not yet initialized");
    }
    
    // Initialize on demand
    let data = CACHE.get_or_init(expensive_computation);
}
 
fn some_condition() -> bool { true }
 
fn main() {
    lazy_static_equivalent();
}

OnceLock allows checking initialization state without triggering initialization.

Thread Safety Guarantees

use std::sync::OnceLock;
use std::thread;
 
static GLOBAL: OnceLock<String> = OnceLock::new();
 
fn thread_safety() {
    let handles: Vec<_> = (0..10)
        .map(|i| {
            thread::spawn(move || {
                // All threads get the same reference
                // Initialization happens exactly once
                let value = GLOBAL.get_or_init(|| {
                    println!("Thread {} initializing", i);
                    format!("initialized by thread {}", i)
                });
                value.clone()
            })
        })
        .collect();
 
    for handle in handles {
        let result = handle.join().unwrap();
        println!("Got: {}", result);
    }
    
    // All threads see the same value
    // Exactly one thread performs initialization
}
 
// lazy_static provides equivalent thread safety:
// - First access wins initialization
// - Other threads block until complete
// - Initialization happens exactly once
 
fn main() {
    thread_safety();
}

Both provide identical thread safety guarantees for initialization.

Generic and Complex Types

use std::sync::OnceLock;
 
// Both support generic types
struct Cache<T> {
    data: OnceLock<Vec<T>>,
}
 
impl<T: Clone> Cache<T> {
    fn new() -> Self {
        Cache { data: OnceLock::new() }
    }
    
    fn get_or_compute(&self, compute: impl FnOnce() -> Vec<T>) -> &Vec<T> {
        self.data.get_or_init(compute)
    }
}
 
// Complex nested types work fine
static COMPLEX: OnceLock<std::collections::HashMap<
    String,
    Vec<std::sync::Arc<std::sync::Mutex<Vec<u64>>>>
>> = OnceLock::new();
 
fn main() {
    let cache = Cache::<i32>::new();
    let data = cache.get_or_compute(|| vec![1, 2, 3]);
    println!("{:?}", data);
}

OnceLock handles complex types without macro limitations.

Interior Mutability Patterns

use std::sync::OnceLock;
use std::sync::RwLock;
 
// lazy_static with RwLock
lazy_static::lazy_static! {
    static ref LAZY_DATA: RwLock<Vec<String>> = RwLock::new(Vec::new());
}
 
// OnceLock with RwLock - equivalent pattern
static ONCELOCK_DATA: OnceLock<RwLock<Vec<String>>> = OnceLock::new();
 
fn get_data() -> &'static RwLock<Vec<String>> {
    ONCELOCK_DATA.get_or_init(|| RwLock::new(Vec::new()))
}
 
fn add_item(item: String) {
    get_data().write().unwrap().push(item);
}
 
fn get_items() -> Vec<String> {
    get_data().read().unwrap().clone()
}
 
fn main() {
    add_item("first".to_string());
    add_item("second".to_string());
    
    println!("Items: {:?}", get_items());
}

Both can be combined with interior mutability types like RwLock or Mutex.

const Initialization

use std::sync::OnceLock;
 
// OnceLock can be created in const context
static STATIC: OnceLock<String> = OnceLock::new();
 
// Can also use const fn for initialization
const CONST_INIT: OnceLock<i32> = OnceLock::new();
 
// This enables patterns like:
struct Config {
    cache: OnceLock<String>,
}
 
impl Config {
    // const fn new() is possible
    const fn new() -> Self {
        Config { cache: OnceLock::new() }
    }
}
 
// lazy_static! cannot be used in const contexts
// It must be used for static declarations
 
fn main() {
    let config = Config::new();
    println!("Config created");
}

OnceLock::new() is const fn, enabling use in more contexts.

Error Handling During Initialization

use std::sync::OnceLock;
use std::fs::read_to_string;
 
static CONFIG: OnceLock<Result<Config, ConfigError>> = OnceLock::new();
 
#[derive(Debug)]
struct Config {
    host: String,
    port: u16,
}
 
#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(String),
}
 
fn load_config() -> Result<Config, ConfigError> {
    let content = read_to_string("config.toml")
        .map_err(ConfigError::Io)?;
    
    // Parse config...
    Ok(Config { host: "localhost".to_string(), port: 8080 })
}
 
fn get_config() -> Result<&'static Config, &'static ConfigError> {
    static CONFIG: OnceLock<Result<Config, ConfigError>> = OnceLock::new();
    
    CONFIG.get_or_init(|| load_config()).as_ref()
}
 
// Alternative: Use Option for simpler error handling
static MAYBE_CONFIG: OnceLock<Option<Config>> = OnceLock::new();
 
fn get_config_option() -> Option<&'static Config> {
    MAYBE_CONFIG.get_or_init(|| load_config().ok()).as_ref()
}
 
fn main() {
    match get_config() {
        Ok(config) => println!("Host: {}", config.host),
        Err(e) => println!("Error: {:?}", e),
    }
}

OnceLock handles initialization that may fail gracefully.

Performance Characteristics

use std::sync::OnceLock;
use std::time::Instant;
 
static COMPUTED: OnceLock<Vec<u64>> = OnceLock::new();
 
fn expensive_init() -> Vec<u64> {
    (0..1_000_000).collect()
}
 
fn benchmark() {
    let start = Instant::now();
    
    // First access - initializes
    let v1 = COMPUTED.get_or_init(expensive_init);
    let first_duration = start.elapsed();
    
    let start = Instant::now();
    
    // Second access - returns cached
    let v2 = COMPUTED.get_or_init(|| panic!("should not call"));
    let second_duration = start.elapsed();
    
    println!("First access: {:?}", first_duration);
    println!("Second access: {:?}", second_duration);
    println!("Second access is essentially free");
}
 
fn main() {
    benchmark();
}

Both have similar performance: expensive initialization once, cheap access afterward.

Migration from lazy_static to OnceLock

// Before: lazy_static
use lazy_static::lazy_static;
 
lazy_static! {
    static ref DATABASE_POOL: DatabasePool = {
        DatabasePool::connect("localhost:5432")
    };
}
 
// After: OnceLock
use std::sync::OnceLock;
 
static DATABASE_POOL: OnceLock<DatabasePool> = OnceLock::new();
 
fn get_pool() -> &'static DatabasePool {
    DATABASE_POOL.get_or_init(|| {
        DatabasePool::connect("localhost:5432")
    })
}
 
// If you had multiple lazy_statics:
lazy_static! {
    static ref CACHE: Cache = Cache::new();
    static ref METRICS: Metrics = Metrics::default();
}
 
// Equivalent OnceLock pattern:
static CACHE: OnceLock<Cache> = OnceLock::new();
static METRICS: OnceLock<Metrics> = OnceLock::new();
 
struct Cache;
impl Cache { fn new() -> Self { Cache } }
struct Metrics;
impl Default for Metrics { fn default() -> Self { Metrics } }
 
fn main() {}

Migration is straightforward, though you lose the transparent deref ergonomics.

When to Use Each

use std::sync::OnceLock;
 
// Use lazy_static when:
// 1. You want the most ergonomic syntax
// 2. You're already using the crate
// 3. You don't need initialization state introspection
 
lazy_static::lazy_static! {
    static ref SIMPLE: String = "simple".to_string();
}
 
// Use OnceLock when:
// 1. You want to avoid external dependencies
// 2. You need to check initialization state
// 3. You want const-time construction
// 4. You need to take or replace the value
// 5. You need initialization that might fail
 
static FLEXIBLE: OnceLock<String> = OnceLock::new();
 
fn check_before_init() {
    // Can check without initializing
    if FLEXIBLE.get().is_none() {
        println!("Not initialized yet");
        // Decide whether to initialize
        if std::env::var("INIT_EARLY").is_ok() {
            FLEXIBLE.get_or_init(|| "initialized early".to_string());
        }
    }
}
 
fn main() {
    check_before_init();
}

Choose based on your requirements for ergonomics vs. flexibility.

The OnceLock::get_or_try_init Pattern

use std::sync::OnceLock;
use std::fs::read_to_string;
 
static CONFIG: OnceLock<String> = OnceLock::new();
 
// Standard get_or_init - panic on error
fn load_config_panic() -> &'static String {
    CONFIG.get_or_init(|| {
        read_to_string("config.txt")
            .expect("config must exist")
    })
}
 
// For fallible initialization, use Result pattern
static FALLIBLE_CONFIG: OnceLock<Result<String, std::io::Error>> = OnceLock::new();
 
fn load_config_result() -> Result<&'static String, &'static std::io::Error> {
    FALLIBLE_CONFIG.get_or_init(|| {
        read_to_string("config.txt")
    }).as_ref()
}
 
fn main() {
    match load_config_result() {
        Ok(content) => println!("Config: {}", content),
        Err(e) => println!("Error: {}", e),
    }
}

Handle fallible initialization by wrapping the result type.

Advanced OnceLock Patterns

use std::sync::OnceLock;
 
// Pattern: Lazy static with multiple initialization paths
static DATA: OnceLock<Vec<u8>> = OnceLock::new();
 
fn get_data() -> &'static Vec<u8> {
    DATA.get_or_init(|| {
        // Try loading from file first
        if let Ok(content) = std::fs::read("data.bin") {
            return content;
        }
        
        // Fall back to generating
        generate_default_data()
    })
}
 
fn generate_default_data() -> Vec<u8> {
    vec![0, 1, 2, 3, 4]
}
 
// Pattern: Staging before commit
static STAGED: OnceLock<String> = OnceLock::new();
 
fn stage_value(value: String) -> bool {
    STAGED.set(value).is_ok()  // Returns false if already set
}
 
fn commit_staged() -> &'static str {
    STAGED.get().unwrap_or(&"default".to_string())
}
 
fn main() {
    stage_value("staged value".to_string());
    println!("Committed: {}", commit_staged());
}

OnceLock enables initialization patterns impossible with lazy_static!.

Synthesis

Quick reference:

// lazy_static - most ergonomic, uses macro
lazy_static::lazy_static! {
    static ref NAME: Type = { initialization };
}
 
// Access: transparent via Deref
// let value = &*NAME;
 
// OnceLock - standard library, explicit API
use std::sync::OnceLock;
static NAME: OnceLock<Type> = OnceLock::new();
 
// Access: explicit get_or_init
// let value = NAME.get_or_init(|| initialization);
 
// Key differences:
// 1. lazy_static: macro-based, transparent access
//    OnceLock: type-based, explicit get/set
 
// 2. lazy_static: cannot check init state
//    OnceLock: get() returns Option
 
// 3. lazy_static: cannot reinitialize
//    OnceLock: take() removes value, can re-init
 
// 4. lazy_static: requires external crate
//    OnceLock: standard library (Rust 1.70+)
 
// 5. lazy_static: static declarations only
//    OnceLock: can be struct fields, const fn creation
 
// Choose lazy_static for:
// - Maximum ergonomics
// - Simple static lazy values
// - Existing codebases using it
 
// Choose OnceLock for:
// - No external dependencies
// - Need to check init state
// - Need take/re-init capability
// - Struct fields or const contexts
// - Better error handling patterns

Key insight: lazy_static! optimizes for ergonomics through macro magic—transparent access via Deref makes lazy values feel like regular statics. OnceLock optimizes for explicitness and flexibility—you see every access, can inspect state, and have more control over the lifecycle. The standard library inclusion of OnceLock (Rust 1.70+) makes it the preferred choice for new code: it avoids dependencies, enables const construction, and provides a richer API (get, set, take, get_or_init). Use lazy_static! when maintaining existing code or when its ergonomic transparent access is worth the dependency overhead. The thread-safety guarantees are identical: both ensure exactly-once initialization with proper synchronization.