The problem with expensive globals
You are building a web server. The configuration lives in a file on disk. Parsing that file involves reading bytes, decoding JSON, validating schemas, and merging defaults. The whole process takes 200 milliseconds. You do not want to block the server startup if the config is not immediately needed. You also do not want to parse the file on every incoming request.
You need a value that initializes exactly once, the first time anyone asks for it, and then sits there forever. Rust's static variables usually require constant values. You cannot run arbitrary code inside a static declaration. The compiler rejects you with a "statics require constant values" error if you try to call a function there.
Rust handles this with lazy initialization types. LazyLock in the standard library and OnceCell in the once_cell crate solve this problem safely. They wrap a value and defer its creation until the first access. The first caller pays the initialization cost. Everyone else gets the cached result instantly.
Lazy initialization in plain words
Think of a shared whiteboard in a meeting room. The answer to a complex calculation is not written down yet. The first person who needs the answer walks to the board, does the math, and writes the result. Everyone else who walks in later just reads the board. The calculation happens once. The reading happens many times.
LazyLock is that whiteboard with a lock on the door. If two people walk in at the exact same time, one waits while the other writes. Once the answer is on the board, the lock opens and everyone can read freely. The wrapper handles the synchronization. You just provide the logic that computes the value.
The type holds a promise: "I will give you the value, but I haven't made it yet." When you access the type, the promise resolves. The closure you provided runs. The result gets stored. Subsequent accesses skip the closure and return the stored data.
LazyLock: the standard library solution
Rust 1.94 stabilized std::sync::LazyLock. This is the preferred tool for thread-safe global values. It lives in the standard library, so you do not need external dependencies for globals. LazyLock implements Deref, which means you can treat it almost exactly like a reference to the inner type.
use std::sync::LazyLock;
/// Global configuration loaded lazily on first access.
static CONFIG: LazyLock<String> = LazyLock::new(|| {
// This closure runs exactly once, on the first access.
// It simulates expensive I/O or parsing.
std::thread::sleep(std::time::Duration::from_millis(50));
"production-config-value".to_string()
});
fn main() {
// First access triggers the closure.
// The thread blocks until initialization finishes.
println!("Config: {CONFIG}");
// Second access uses the cached value.
// No closure execution. No blocking.
println!("Config again: {CONFIG}");
}
The LazyLock::new call takes a closure. The closure captures nothing and returns the value you want to store. The static declaration creates the LazyLock wrapper in read-only memory. The wrapper contains a pointer to the heap-allocated value and a synchronization primitive.
When you write {CONFIG} in the format string, Rust uses deref coercion. It sees LazyLock<String>, applies Deref to get &String, and prints it. The syntax feels like you are accessing a normal static string, but the initialization happens behind the scenes.
Convention aside: The community prefers LazyLock over once_cell::sync::Lazy for new code. Both types do the same thing. LazyLock is in std. once_cell::sync::Lazy is a crate dependency. If you only need global lazy values, drop the once_cell crate and use LazyLock. Keep once_cell only if you need OnceCell for struct fields.
How LazyLock handles panics
Initialization code can fail. If your closure panics, LazyLock does not poison the global value. It resets its internal state and retries on the next access. This behavior prevents a single flaky initialization attempt from breaking your program forever.
If the closure panics, the lock is released. The next thread that accesses the LazyLock sees that initialization is not complete. It acquires the lock and runs the closure again. This retry loop continues until the closure succeeds or the program exits.
Design your initialization logic to be idempotent. The closure might run multiple times if it panics. Avoid side effects that should only happen once, like sending a network message or incrementing a counter. If the closure has side effects, a panic could cause them to repeat.
The first caller pays. Everyone else gets a free ride.
OnceCell: lazy fields in structs
Globals are easy. Struct fields are harder. You cannot put a LazyLock inside a struct because LazyLock is designed for static memory layout. For heap-allocated structs, use OnceCell from the once_cell crate. OnceCell is a wrapper that can be initialized once and then read many times.
OnceCell is not yet in the standard library. You need to add once_cell to your Cargo.toml. This crate provides sync::OnceCell for thread-safe fields and unsync::OnceCell for single-threaded fields.
use once_cell::sync::OnceCell;
/// A database connection pool that initializes lazily.
struct DatabaseClient {
// The connection is expensive to create.
// OnceCell ensures it is created only once, even across threads.
connection: OnceCell<String>,
}
impl DatabaseClient {
fn new() -> Self {
Self {
// Start empty. No connection yet.
connection: OnceCell::new(),
}
}
/// Returns a reference to the connection, initializing it if needed.
fn get_connection(&self) -> &String {
// get_or_init runs the closure only if the cell is empty.
// It handles locking internally.
self.connection.get_or_init(|| {
// Simulate expensive connection setup.
std::thread::sleep(std::time::Duration::from_millis(100));
"db-connection-string".to_string()
})
}
}
fn main() {
let client = DatabaseClient::new();
// First call initializes the connection.
let conn1 = client.get_connection();
println!("Connected: {conn1}");
// Second call returns the existing connection.
let conn2 = client.get_connection();
println!("Connected: {conn2}");
}
OnceCell::get_or_init takes a closure. It checks if the cell has a value. If empty, it acquires the lock, runs the closure, stores the result, and returns a reference. If full, it returns the reference immediately. The method is thread-safe and handles concurrent initialization attempts correctly.
Convention aside: Use get_or_try_init if your initialization can fail with a Result. This method stores the error if initialization fails, so subsequent calls return the error instead of retrying. This is useful for network connections where retrying might be wasteful. get_or_init is for infallible initialization. get_or_try_init is for fallible initialization where you want to cache the failure.
Pitfalls and compiler errors
Deadlocks are the main risk. If your initialization closure tries to access the same LazyLock or OnceCell, you create a deadlock. The thread holds the lock, waits for the closure to finish, and the closure waits for the lock. The program hangs. The compiler cannot detect this. You must avoid recursive access to the cell you are initializing.
If you need to access other lazy values during initialization, ensure there is no cycle. Value A depends on Value B, and Value B depends on Value A. This cycle causes a deadlock. Structure your dependencies as a directed acyclic graph. Initialize independent values first.
Compiler errors appear when you misuse types. If you try to put a non-Sync type inside a static, the compiler rejects you with E0277 (trait bound not satisfied). LazyLock wraps the type and makes it Sync. If you use LazyCell in a static, you get E0277 because LazyCell is not Sync. LazyCell is designed for thread_local! macros or single-threaded contexts.
If you try to move a value out of a LazyLock, you get E0507 (cannot move out of borrowed content). LazyLock only provides shared references. You cannot take ownership of the inner value. This is by design. The value lives for the entire program duration. If you need ownership, clone the value.
Never touch the lock while you're holding the key.
Decision matrix
Use std::sync::LazyLock when you need a thread-safe global value that initializes on first access. This is the standard choice for Rust 1.94 and later. It requires no external dependencies and integrates seamlessly with static declarations.
Use once_cell::sync::OnceCell when you need a lazy field inside a struct. LazyLock cannot be used in structs. OnceCell provides the same lazy initialization semantics for heap-allocated data. It requires the once_cell crate.
Use std::cell::LazyCell when you are inside a thread_local! macro or a single-threaded context where you want to avoid the overhead of atomic synchronization. LazyCell is faster than LazyLock because it does not use locks. It is not Sync, so it cannot be shared across threads.
Use once_cell::sync::Lazy only when you are maintaining code for Rust versions older than 1.94 or need specific extensions provided by the crate. For new code, prefer LazyLock from the standard library.
Use once_cell::unsync::OnceCell when you have a struct field that is only accessed from a single thread. This variant avoids atomic operations and is faster than the sync version. It is not Sync, so it cannot be shared across threads.
Stick to LazyLock for globals. It's the boring, correct choice.