Oncelock

#158 May 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.

149. OnceLock::wait — Block a Thread Until Another One Initializes the Value

You have one thread loading a config and a handful of workers that can’t start until it’s ready. OnceLock::wait blocks until the value lands — no Condvar, no Mutex, no spin loop.

OnceLock<T> is a write-once cell: any number of threads can race to set it, but only the first wins. The usual reader API is get, which returns None until something has been stored:

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

let cell: OnceLock<u32> = OnceLock::new();
assert_eq!(cell.get(), None);
cell.set(42).unwrap();
assert_eq!(cell.get(), Some(&42));

That’s fine when the reader can keep working without the value. But what if the reader genuinely needs it now? Before Rust 1.86 you’d reach for a Mutex<Option<T>> plus a Condvar, or spin in a loop calling get — both more code and more bugs than the problem deserves.

OnceLock::wait parks the calling thread until the cell is initialized, then hands back &T:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
use std::sync::OnceLock;
use std::thread;
use std::time::Duration;

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

thread::scope(|s| {
    // Producer: pretend this is loading config from disk.
    s.spawn(|| {
        thread::sleep(Duration::from_millis(20));
        CONFIG.set("rustbites=on".into()).unwrap();
    });

    // Consumers: block until the producer is done, then read.
    for _ in 0..3 {
        s.spawn(|| {
            let cfg: &String = CONFIG.wait();
            assert_eq!(cfg, "rustbites=on");
        });
    }
});

Every consumer gets back the same &StringOnceLock only ever holds one value, so the borrow is shared and lives as long as the cell does. No cloning, no Arc wrapping.

wait plays nicely with the existing init helpers. If you have a fallible initializer that some threads might run and others just want to await, mix get_or_init with wait:

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

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

thread::scope(|s| {
    s.spawn(|| {
        // First thread here pays the cost; others get the cached value.
        GREETING.get_or_init(|| "hello, bites".into());
    });
    s.spawn(|| {
        // This thread doesn't care who initialized — just wants the value.
        assert_eq!(GREETING.wait(), "hello, bites");
    });
});

A few things worth knowing:

  • wait blocks forever if nobody ever calls set (or get_or_init succeeds). It’s a synchronization primitive, not a timeout — pair it with thread::spawn for a producer you actually control.
  • It’s &self, so any number of threads can wait on the same cell at once.
  • OnceLock<T> requires T: Send + Sync to be shared across threads, same as Arc.

For lazy-init that runs on first read, LazyLock is still the right tool. But when initialization happens elsewhere and other threads need to pause until it’s done, wait turns a Condvar dance into one method call.