What are the trade-offs between lazy_static! and once_cell::sync::Lazy for lazy static initialization?
lazy_static! uses a macro-based approach with explicit type annotations and requires accessing values through a dereference operation, while once_cell::sync::Lazy uses a type-based approach with natural Rust syntax, supports generics, and integrates better with the type system. Both provide thread-safe lazy initialization, but once_cell offers a more idiomatic API and was stabilized in the standard library in Rust 1.70.
Basic Usage Comparison
// lazy_static approach
use lazy_static::lazy_static;
lazy_static! {
static ref CONFIG: HashMap<String, String> = {
let mut map = HashMap::new();
map.insert("key".to_string(), "value".to_string());
map
};
}
// Access requires dereferencing
fn use_lazy_static() {
let value = &*CONFIG;
}// once_cell approach
use std::sync::LazyLock; // or once_cell::sync::Lazy in older versions
static CONFIG: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
let mut map = HashMap::new();
map.insert("key".to_string(), "value".to_string());
map
});
// Access is direct
fn use_once_cell() {
let value = &*CONFIG;
}Both initialize on first access, but once_cell uses standard static syntax.
Dereferencing and Type Handling
use lazy_static::lazy_static;
use std::sync::LazyLock;
lazy_static! {
static ref COUNT: i32 = 42;
}
static COUNT2: LazyLock<i32> = LazyLock::new(|| 42);
fn dereferencing() {
// lazy_static requires explicit dereference for most operations
let val: i32 = *COUNT;
println!("{}", *COUNT);
// once_cell also requires dereference but feels more natural
let val: i32 = *COUNT2;
println!("{}", *COUNT2);
// Both implement Deref, so methods work directly
let s: &'static str = "hello";
lazy_static! {
static ref STRING: String = String::from("hello");
}
// String methods work through Deref
let len = STRING.len();
}Both implement Deref, but once_cell feels more like native static initialization.
Complex Initialization Logic
use lazy_static::lazy_static;
use std::sync::LazyLock;
use std::collections::HashMap;
lazy_static! {
static ref COMPLEX: HashMap<u32, String> = {
let mut map = HashMap::new();
for i in 0..100 {
map.insert(i, format!("value_{}", i));
}
map
};
}
static COMPLEX2: LazyLock<HashMap<u32, String>> = LazyLock::new(|| {
let mut map = HashMap::new();
for i in 0..100 {
map.insert(i, format!("value_{}", i));
}
map
});
// Both support arbitrary initialization logic
// once_cell doesn't require macro bracesBoth support complex initialization; once_cell uses standard closure syntax.
Generics Support
use std::sync::LazyLock;
// once_cell supports generics naturally
static CACHED_VEC: LazyLock<Vec<i32>> = LazyLock::new(|| vec
![1, 2, 3]);
// lazy_static does NOT support generics
// This would not compile:
// lazy_static! {
// static ref CACHED: Vec<T> = ...;
// }
// Pattern: use a function with generics
fn get_cached<T: 'static>(value: T) -> &'static T {
// Cannot use lazy_static for generic types
// Would need separate static for each type
unimplemented!()
}once_cell naturally supports generic types; lazy_static macros cannot.
Interior Mutability Patterns
use lazy_static::lazy_static;
use std::sync::LazyLock;
use std::sync::Mutex;
// Both commonly used with Mutex for mutable state
lazy_static! {
static ref COUNTER: Mutex<i32> = Mutex::new(0);
}
static COUNTER2: LazyLock<Mutex<i32>> = LazyLock::new(|| Mutex::new(0));
fn increment() {
// Both work the same way
*COUNTER.lock().unwrap() += 1;
*COUNTER2.lock().unwrap() += 1;
}
// Atomic alternatives
use std::sync::atomic::{AtomicUsize, Ordering};
lazy_static! {
static ref ATOMIC: AtomicUsize = AtomicUsize::new(0);
}
static ATOMIC2: LazyLock<AtomicUsize> = LazyLock::new(|| AtomicUsize::new(0));Both work with Mutex and atomics for mutable state.
Thread Safety Guarantees
use std::sync::LazyLock;
use std::sync::OnceLock;
use std::thread;
fn thread_safety() {
static VALUE: LazyLock<Vec<i32>> = LazyLock::new(|| {
println!("Initializing...");
(0..10).collect()
});
// Both lazy_static and once_cell guarantee:
// - Thread-safe initialization
// - Only initialized once
// - All threads see the same value
let handles: Vec<_> = (0..10)
.map(|_| {
thread::spawn(|| {
let v = &*VALUE;
v.len()
})
})
.collect();
// "Initializing..." prints only once
}Both provide identical thread-safety guarantees using the same underlying synchronization.
Integration with Rust Ecosystem
use std::sync::LazyLock;
// once_cell is now in std (since 1.70)
// No external dependency needed
static STD_LAZY: LazyLock<String> = LazyLock::new(|| String::from("std"));
// Before 1.70, use once_cell crate
// use once_cell::sync::Lazy;
// static CRATE_LAZY: Lazy<String> = Lazy::new(|| String::from("crate"));
// lazy_static always requires external crate
use lazy_static::lazy_static;
lazy_static! {
static ref MACRO_LAZY: String = String::from("macro");
}once_cell is now part of std, reducing dependencies.
Visibility and Documentation
use std::sync::LazyLock;
// once_cell supports normal visibility and documentation
/// Global configuration cache
pub static CONFIG: LazyLock<Config> = LazyLock::new(|| {
Config::load()
});
// lazy_static syntax is different
use lazy_static::lazy_static;
lazy_static! {
/// This doc comment doesn't work the same way
pub static ref CONFIG2: Config = Config::load();
}
// Note: lazy_static doc comments can be awkward
// once_cell uses standard Rust documentationonce_cell integrates naturally with Rust's documentation system.
Error Handling in Initialization
use std::sync::LazyLock;
use std::sync::OnceLock;
// For fallible initialization, use OnceLock
static MAYBE_CONFIG: OnceLock<Config> = OnceLock::new();
fn get_config() -> Option<&'static Config> {
MAYBE_CONFIG.get_or_init(|| {
// Fallible initialization
Config::try_load().ok()
})
}
// lazy_static doesn't naturally support fallible init
// Workaround: use Option or Result types
use lazy_static::lazy_static;
lazy_static! {
static ref CONFIG_OPTION: Option<Config> = Config::try_load().ok();
}OnceLock supports fallible initialization; lazy_static requires workarounds.
Pattern: Configuration Loading
use std::sync::LazyLock;
use std::fs;
struct Config {
database_url: String,
api_key: String,
}
impl Config {
fn load() -> Self {
Config {
database_url: std::env::var("DATABASE_URL").unwrap_or_default(),
api_key: std::env::var("API_KEY").unwrap_or_default(),
}
}
}
// once_cell pattern
static CONFIG: LazyLock<Config> = LazyLock::new(|| Config::load());
fn get_db_url() -> &'static str {
&CONFIG.database_url
}
// lazy_static pattern
use lazy_static::lazy_static;
lazy_static! {
static ref CONFIG2: Config = Config::load();
}
fn get_db_url2() -> &'static str {
&CONFIG2.database_url
}Both patterns work similarly for configuration.
Pattern: Singleton Pattern
use std::sync::LazyLock;
use std::sync::Mutex;
struct Singleton {
data: Vec<String>,
}
impl Singleton {
fn new() -> Self {
Singleton { data: Vec::new() }
}
fn add(&mut self, item: String) {
self.data.push(item);
}
}
static INSTANCE: LazyLock<Mutex<Singleton>> = LazyLock::new(|| {
Mutex::new(Singleton::new())
});
fn add_item(item: String) {
INSTANCE.lock().unwrap().add(item);
}
// Both approaches work identically for singletons
// once_cell is more idiomatic in modern RustBoth implement singleton patterns effectively.
Pattern: Regex Caching
use std::sync::LazyLock;
use regex::Regex;
// Common pattern: compile regex once
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
.unwrap()
});
fn is_valid_email(email: &str) -> bool {
EMAIL_REGEX.is_match(email)
}
// lazy_static alternative
use lazy_static::lazy_static;
use regex::Regex;
lazy_static! {
static ref EMAIL_REGEX2: Regex = Regex::new(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
).unwrap();
}
fn is_valid_email2(email: &str) -> bool {
EMAIL_REGEX2.is_match(email)
}Both patterns are common for expensive initialization like regex compilation.
Performance Characteristics
use std::sync::LazyLock;
use std::time::Instant;
fn performance() {
// Both have similar performance characteristics:
// 1. First access: initialization cost
// 2. Subsequent accesses: atomic check (very fast)
// 3. Memory: same footprint
// Initialization happens exactly once
static DATA: LazyLock<Vec<i32>> = LazyLock::new(|| {
println!("Computing...");
(0..1000000).collect()
});
let start = Instant::now();
let _ = &*DATA; // First access - initializes
println!("First access: {:?}", start.elapsed());
let start = Instant::now();
let _ = &*DATA; // Second access - just reads
println!("Second access: {:?}", start.elapsed());
}Both have identical runtime performance after initialization.
Memory Ordering and Synchronization
use std::sync::LazyLock;
fn synchronization() {
// Both use atomic operations internally
// LazyLock guarantees:
// - Initialization runs exactly once
// - All threads see the initialized value
// - Proper synchronization around initialization
static VALUE: LazyLock<i32> = LazyLock::new(|| {
42
});
// The internal implementation uses:
// - AtomicBool or atomic state for "initialized" flag
// - Mutex or similar for initialization synchronization
// - Release/Acquire ordering for visibility guarantees
}Both use similar synchronization primitives internally.
Migration from lazy_static to once_cell
// Before: lazy_static
use lazy_static::lazy_static;
lazy_static! {
static ref DATA: HashMap<String, i32> = {
let mut m = HashMap::new();
m.insert("one".to_string(), 1);
m
};
}
// After: once_cell (or std::sync::LazyLock)
use std::sync::LazyLock;
static DATA: LazyLock<HashMap<String, i32>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert("one".to_string(), 1);
m
});
// Key changes:
// 1. Remove lazy_static! macro
// 2. Use static TYPE = LazyLock::new(|| ...) syntax
// 3. Access remains the same: &*DATA or DATA.method()Migration is straightforward with minimal code changes.
OnceLock vs LazyLock
use std::sync::{LazyLock, OnceLock};
// LazyLock: initialization closure at declaration
static LAZY: LazyLock<Vec<i32>> = LazyLock::new(|| vec
![1, 2, 3]);
// OnceLock: initialization can be deferred or conditional
static ONCE: OnceLock<Vec<i32>> = OnceLock::new();
fn get_or_init() -> &'static Vec<i32> {
ONCE.get_or_init(|| vec
![1, 2, 3])
}
// OnceLock advantages:
// - Can be initialized from external context
// - Supports fallible initialization
// - Initialization can be deferred
// LazyLock advantages:
// - Initialization logic at declaration
// - Cleaner syntax for simple casesOnceLock offers more flexibility; LazyLock is cleaner for simple cases.
Comparison Table
fn comparison() {
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// β Aspect β lazy_static β once_cell::Lazy β
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
// β Syntax β Macro β Type + closure β
// β Dependencies β External crate β std (1.70+) β
// β Generic support β No β Yes β
// β Type inference β Explicit required β Inferred β
// β Documentation β Awkward β Normal β
// β Visibility β Macro syntax β Normal β
// β Fallible init β Workaround needed β OnceLock variant β
// β IDE support β Limited β Full β
// β Macro hygiene β Complex β N/A β
// β Debugging β Macro expansion β Standard code β
// β Performance β Same β Same β
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
}Complete Example
use std::sync::LazyLock;
use std::collections::HashMap;
use std::sync::Mutex;
// Configuration singleton
static CONFIG: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
let mut config = HashMap::new();
config.insert("database_url", "localhost:5432");
config.insert("api_key", "secret");
config
});
// Connection pool singleton
static POOL: LazyLock<Mutex<Vec<Connection>>> = LazyLock::new(|| {
let mut pool = Vec::new();
for _ in 0..10 {
pool.push(Connection::connect());
}
Mutex::new(pool)
});
struct Connection {
id: usize,
}
impl Connection {
fn connect() -> Self {
static COUNTER: LazyLock<usize> = LazyLock::new(|| 0);
Connection { id: *COUNTER }
}
}
fn main() {
// Access is natural
println!("Database URL: {}", CONFIG.get("database_url").unwrap());
// Thread-safe mutable state
let mut pool = POOL.lock().unwrap();
pool.push(Connection::connect());
// OnceLock for fallible initialization
use std::sync::OnceLock;
static MAYBE_DATA: OnceLock<Result<Data, Error>> = OnceLock::new();
let data_result = MAYBE_DATA.get_or_init(|| {
// Expensive fallible operation
load_data_from_disk()
});
match data_result {
Ok(data) => println!("Loaded: {:?}", data),
Err(e) => println!("Error: {}", e),
}
}
#[derive(Debug)]
struct Data;
#[derive(Debug)]
struct Error;
fn load_data_from_disk() -> Result<Data, Error> {
Ok(Data)
}Summary
fn summary() {
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// β Feature β Recommendation β
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
// β New projects β Use std::sync::LazyLock (Rust 1.70+) β
// β Existing lazy_staticβ Migrate to once_cell when convenient β
// β Fallible init β Use OnceLock β
// β Generic types β Use once_cell (lazy_static doesn't support) β
// β External crate deps β Prefer std version to reduce dependencies β
// β Performance β Identical β
// β Thread safety β Identical β
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Key points:
// 1. once_cell is now in std (LazyLock, OnceLock)
// 2. lazy_static requires external crate
// 3. once_cell supports generics, lazy_static doesn't
// 4. once_cell has better IDE support (no macro expansion)
// 5. Both have identical runtime performance
// 6. LazyLock for eager-at-first-use initialization
// 7. OnceLock for deferred/fallible initialization
// 8. Migration is straightforward
}Key insight: The primary differences between lazy_static! and once_cell::sync::Lazy are syntactic and ecosystem rather than performance. lazy_static! uses a macro that creates special types with explicit dereferencing, while once_cell uses a type-based approach that integrates naturally with Rust's type system. Since Rust 1.70, std::sync::LazyLock and std::sync::OnceLock provide these patterns in the standard library, eliminating the need for external dependencies. The once_cell approach supports generics, has better IDE integration, and follows normal Rust syntax for visibility and documentation. Both provide identical thread-safety guarantees: initialization happens exactly once, and all threads observe the same value. Use LazyLock for simple lazy initialization and OnceLock when you need fallible initialization or want to control initialization timing externally. For new projects, prefer the std types to minimize dependencies.
