#158 May 23, 2026

158. OnceLock<T> and LazyLock<T, F> — The std Replacements for lazy_static!

This morning’s Atomic* handled “many threads, one scalar.” For “many threads, one value — computed once, then read forever” the std answer is two types: LazyLock<T, F> when you can name the initializer up front, OnceLock<T> when you only learn the value at runtime. Both make the old lazy_static! macro and the once_cell crate redundant.

The bad old days: lazy_static!

Until Rust 1.80 (mid-2024), a thread-safe lazy global meant a macro from a crate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Cargo.toml: lazy_static = "1"
use lazy_static::lazy_static;
use std::collections::HashMap;

lazy_static! {
    static ref COUNTRIES: HashMap<&'static str, &'static str> = {
        let mut m = HashMap::new();
        m.insert("fr", "France");
        m.insert("de", "Germany");
        m
    };
}

fn main() {
    assert_eq!(COUNTRIES.get("fr"), Some(&"France"));
}

It worked, but it pulled in a dependency, used macro magic to fake a static, and gave you a custom Deref wrapper instead of a normal type. Every dependency in your tree that wanted a lazy global was either pulling in lazy_static or the more modern once_cell crate.

The std way: LazyLock<T, F>

Stabilized in Rust 1.80, LazyLock is a normal type — no macro, no Deref trickery, just a static like any other:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::collections::HashMap;
use std::sync::LazyLock;

static COUNTRIES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    let mut m = HashMap::new();
    m.insert("fr", "France");
    m.insert("de", "Germany");
    m
});

assert_eq!(COUNTRIES.get("fr"), Some(&"France"));

The closure runs the first time anything touches COUNTRIES, exactly once, and every thread that races to be that “first” gets the same value back. If two threads arrive together, one wins the init and the other blocks until it’s done — same contract lazy_static! always had, now in std.

When the initializer isn’t known at compile time: OnceLock<T>

LazyLock is great when you can write the initializer as a const fn-friendly closure. But what about config you only know after main() starts — a CLI flag, an env var, a parsed file? Stuffing that into a closure means either re-reading the env var at first use or capturing values you don’t have yet at static time. That’s OnceLock<T>’s job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::OnceLock;

static GREETING: OnceLock<String> = OnceLock::new();

fn init_from_args(name: &str) {
    GREETING.set(format!("hello, {name}")).ok();   // ok() = ignore "already set"
}

fn greeting() -> &'static str {
    GREETING.get().map(String::as_str).unwrap_or("hello, world")
}

init_from_args("ferris");
assert_eq!(greeting(), "hello, ferris");

set returns Err(value) if someone beat you to it — handy when multiple call sites might initialize and you want the first one to win without panicking.

The convenience method: get_or_init

The “check if set, init if not” dance is so common that OnceLock ships it as one call:

1
2
3
4
5
6
7
8
9
use std::sync::OnceLock;

fn config() -> &'static String {
    static CFG: OnceLock<String> = OnceLock::new();
    CFG.get_or_init(|| std::env::var("APP_CFG").unwrap_or_else(|_| "default".into()))
}

assert!(!config().is_empty());
assert_eq!(config(), config());    // same &'static str on every call

This is what most “lazy global computed from runtime data” code actually wants. Functions can own their own OnceLock as a function-local static — no need to pollute the module namespace.

Which one when

You haveReach for
A closure that needs no runtime inputLazyLock<T, F>
A value you’ll set later (from main, a builder, a DI container)OnceLock<T> with set
Per-function memoization of an expensive computationOnceLock<T> with get_or_init
Single-threaded version of the aboveLazyCell / OnceCell

LazyLock is the closer match to lazy_static!. OnceLock is the closer match to manual Mutex<Option<T>> patterns where you wanted “set once, read many.”

The trait bounds, briefly

Because these are Sync and live in a static, the contained T must be Send + Sync. The initializer closure for LazyLock must be Send. You won’t notice for String, HashMap, Vec, Arc<…> — but if you try to stuff an Rc<T> in there the compiler will (correctly) yell. The single-threaded versions, LazyCell and OnceCell, have no such bound — that’s the whole reason both pairs exist.

What you can finally delete

If a crate in your tree still has lazy_static = "1" or once_cell = "1" in its Cargo.toml, and your MSRV is 1.80 or newer, the migration is mechanical:

1
2
3
4
// before
lazy_static! { static ref X: T = init(); }
// after
static X: LazyLock<T> = LazyLock::new(init);
1
2
3
4
5
6
// before
use once_cell::sync::OnceCell;
static X: OnceCell<T> = OnceCell::new();
// after
use std::sync::OnceLock;
static X: OnceLock<T> = OnceLock::new();

One fewer dependency, one less macro in the expansion, and the type that shows up in error messages is just LazyLock<T> — not some crate-private deref wrapper. Tomorrow’s bite picks up the thread on Arc<T> — what to reach for when “one global value” isn’t enough and you need shared ownership across threads.

← Previous 157. Atomic* — The Thread-Safe Cell for Scalars Next → 159. Rc<T> — Single-Threaded Shared Ownership