Applications often need one shared value that is expensive or awkward to construct but should not change after initialization. Examples include parsed configuration, a lookup table, a compiled matcher, or metadata discovered during startup.

A plain static works only when the value can be created as a constant. A Mutex<Option<T>> can represent “not initialized yet,” but it also introduces a lock and a mutable state model that the program may not need after setup.

Rust’s std::sync::OnceLock<T> models the narrower requirement directly: the cell starts empty, one value is published, and readers can then borrow that value safely from multiple threads.

The important mental model is not “a faster mutex.” It is one-way state:

empty  ->  initialized

Normal shared use does not move back and forth between those states.

Start with the smallest useful example

A OnceLock can be used in a static because it has a constant constructor:

use std::sync::OnceLock;

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

fn app_name() -> &'static str {
    APP_NAME
        .get_or_init(|| "nalar-worker".to_owned())
        .as_str()
}

fn main() {
    assert_eq!(app_name(), "nalar-worker");
    assert_eq!(app_name(), "nalar-worker");
}

The first call to get_or_init initializes the cell. Later calls return a shared reference to the stored String instead of constructing another one.

Because APP_NAME is static, a reference borrowed from it can also have a 'static lifetime. OnceLock is handling synchronized publication of the value; the long lifetime comes from the static storage itself.

Think of initialization and access as separate operations

OnceLock provides several operations, but two questions cover most designs:

Who chooses the value?
When does initialization happen?

Use set when some explicit startup path should choose the value:

use std::sync::OnceLock;

static PORT: OnceLock<u16> = OnceLock::new();

fn initialize(port: u16) -> Result<(), u16> {
    PORT.set(port)
}

fn port() -> Option<u16> {
    PORT.get().copied()
}

fn main() {
    assert_eq!(port(), None);

    initialize(8080).unwrap();

    assert_eq!(port(), Some(8080));
    assert_eq!(initialize(9090), Err(9090));
}

set succeeds only while the cell is empty. If another value has already been stored, it returns the supplied value in Err.

Use get_or_init when the value should be created on first demand:

let value = PORT.get_or_init(|| 8080);
assert_eq!(*value, 8080);

After successful initialization, both approaches lead to the same useful property: callers receive shared references to one stored value.

Concurrent callers converge on one stored value

The main reason to use std::sync::OnceLock instead of a home-grown static mut or unsynchronized Option is that initialization is safe across threads.

Suppose several worker threads reach the same lazy value at nearly the same time:

use std::sync::{
    atomic::{AtomicUsize, Ordering},
    OnceLock,
};
use std::thread;

static VALUE: OnceLock<String> = OnceLock::new();
static INITIALIZATIONS: AtomicUsize = AtomicUsize::new(0);

fn shared_value() -> &'static str {
    VALUE
        .get_or_init(|| {
            INITIALIZATIONS.fetch_add(1, Ordering::Relaxed);
            "ready".to_owned()
        })
        .as_str()
}

fn main() {
    thread::scope(|scope| {
        for _ in 0..8 {
            scope.spawn(|| assert_eq!(shared_value(), "ready"));
        }
    });

    assert_eq!(INITIALIZATIONS.load(Ordering::Relaxed), 1);
}

For a successful initialization, competing calls to get_or_init synchronize so that one initializer supplies the stored value and the callers receive references to that value. Callers may have to wait while initialization is in progress.

The atomic counter is only there to make the example observable. OnceLock itself provides the synchronization needed for the stored String.

This guarantee matters because publication is part of the problem. It is not enough to prevent two threads from assigning simultaneously; readers must also see a fully initialized T, not partially constructed state.

get observes state without starting initialization

Sometimes a caller should inspect whether initialization has happened without causing it.

get does that:

use std::sync::OnceLock;

let cell: OnceLock<String> = OnceLock::new();

assert!(cell.get().is_none());

cell.set("loaded".to_owned()).unwrap();

assert_eq!(cell.get().map(String::as_str), Some("loaded"));

get does not run an initializer. It also does not wait for an initialization that is currently in progress; it can return None while another thread is still initializing the cell.

That difference makes get useful for observation, but it should not be mistaken for a “wait until ready” operation.

If your design requires callers to block until another thread finishes explicit initialization, use the synchronization operation intended for that requirement rather than repeatedly polling get.

Initialization input can create a first-caller-wins bug

get_or_init accepts a closure, which makes it tempting to pass request-specific input into a global cell:

use std::sync::OnceLock;

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

fn config_for(path: &str) -> &'static str {
    CONFIG
        .get_or_init(|| format!("loaded from {path}"))
        .as_str()
}

This compiles, but the API has a hidden policy: whichever call initializes CONFIG first determines the value for every later caller.

thread A: config_for("a.toml") --\
                                  -> one stored value
thread B: config_for("b.toml") --/

That may be correct if the first caller is deliberately responsible for choosing the process-wide configuration. It is risky if different callers reasonably expect their argument to matter.

For process-wide state whose source is important, explicit initialization is often clearer:

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

fn initialize_config(path: &str) -> Result<(), String> {
    let loaded = format!("loaded from {path}");
    CONFIG.set(loaded)
}

fn config() -> &'static str {
    CONFIG
        .get()
        .expect("configuration must be initialized before use")
        .as_str()
}

Now startup decides the configuration source, while ordinary readers cannot silently compete to choose it.

The example uses format! instead of file I/O so the initialization policy stays visible. In real code, the startup function could parse a file and return a domain-specific error before calling set.

Keep fallible work outside the cell when failure matters

A common requirement is fallible initialization: reading configuration, opening a resource, parsing data, or performing discovery can fail.

One simple design is to perform that work first and call set only after it succeeds:

use std::sync::OnceLock;

#[derive(Debug)]
struct Settings {
    workers: usize,
}

static SETTINGS: OnceLock<Settings> = OnceLock::new();

fn parse_settings(input: &str) -> Result<Settings, &'static str> {
    let workers = input.parse::<usize>().map_err(|_| "invalid worker count")?;

    if workers == 0 {
        return Err("worker count must be greater than zero");
    }

    Ok(Settings { workers })
}

fn initialize_settings(input: &str) -> Result<(), &'static str> {
    let settings = parse_settings(input)?;

    SETTINGS
        .set(settings)
        .map_err(|_| "settings were already initialized")
}

fn main() {
    initialize_settings("4").unwrap();
    assert_eq!(SETTINGS.get().unwrap().workers, 4);
}

The important ordering is:

fallible preparation
        |
        v
valid T
        |
        v
OnceLock::set

A failed parse leaves the cell empty because no value was published.

This design is especially useful when startup already has a natural place to report initialization errors. It also keeps retry policy explicit: the caller decides whether to fix the input and try initialization again.

A panic does not poison OnceLock

OnceLock differs from synchronization types whose state can become poisoned.

If the closure passed to get_or_init panics, the panic propagates, but the cell remains uninitialized. A later call can attempt initialization again.

That behavior is useful, but it does not make panicking initialization harmless. The closure may have changed external state before it panicked, such as writing a file, registering a callback, or sending a request.

OnceLock controls publication of the stored Rust value. It does not roll back side effects performed by the initializer.

Prefer initialization functions that either build their result locally and then return it, or have deliberate recovery behavior for external side effects.

Do not initialize the same cell recursively

An initializer should not try to initialize the same OnceLock again.

Conceptually, this is a cycle:

CELL.get_or_init(init)
        |
        v
      init
        |
        +----> CELL.get_or_init(...)

Reentrant initialization of the same cell is an error. Do not depend on a particular current outcome such as blocking or panicking; the API does not make that a portable program behavior.

This can happen indirectly when initialization calls a helper that eventually asks for the same global value. Keep initializer dependencies acyclic, or pass already-constructed dependencies into the initialization function.

OnceLock is not a replacement for mutable shared state

The value inside a OnceLock is published once, but that does not automatically make the value itself immutable.

For example, this type is legal:

use std::sync::{Mutex, OnceLock};

static QUEUE: OnceLock<Mutex<Vec<String>>> = OnceLock::new();

The OnceLock protects one-time installation of the Mutex. The Mutex protects later mutation of the vector. They solve different problems.

Use only OnceLock<T> when T can be shared through ordinary immutable references after initialization.

Use OnceLock<Mutex<T>>, OnceLock<RwLock<T>>, atomics, channels, or another synchronization design when the stored state genuinely needs to change concurrently.

Do not wrap a value in a mutex merely because initialization is concurrent. If the value becomes read-only after construction, OnceLock<T> already expresses that lifecycle more directly.

OnceLock and LazyLock encode different initialization policies

Rust also provides std::sync::LazyLock, which stores its initializer together with the value and runs that initializer on first access.

That is convenient when initialization needs no runtime argument:

use std::sync::LazyLock;

static GREETING: LazyLock<String> =
    LazyLock::new(|| "hello".to_owned());

OnceLock is a better fit when initialization may happen explicitly, when the initializer needs input supplied later, or when code needs to inspect whether a value has been installed.

There is also an important panic difference: OnceLock remains uninitialized if its get_or_init closure panics, while a LazyLock whose initialization closure panics becomes poisoned and future forced accesses panic.

Choose the type whose failure and initialization policy matches the application rather than treating them as interchangeable spelling.

Be careful with teardown and tests

A static OnceLock naturally lives for the lifetime of the process. That is ideal for process-wide configuration or immutable metadata, but it can complicate tests that want a fresh value for every case.

A local mutable OnceLock can be reset with take because an exclusive &mut self proves no other borrower is using it:

use std::sync::OnceLock;

let mut cell = OnceLock::new();

cell.set(10).unwrap();
assert_eq!(cell.take(), Some(10));
assert!(cell.get().is_none());

That pattern does not translate into casually resetting a shared static, because normal code cannot obtain exclusive mutable access to a static value while it is globally shared.

For testable application design, consider keeping process configuration in an owned application object and passing references to components. Reserve static one-time state for values that are truly process-global.

Common mistakes

Hiding configuration precedence inside lazy initialization

If multiple callers can provide different initialization inputs, “first caller wins” is a policy whether you intended it or not. Prefer an explicit startup owner when precedence matters.

Assuming a panic commits a partial value

get_or_init publishes a T only when initialization completes successfully. A panic leaves the cell uninitialized, although external side effects from the closure may remain.

Using get as a waiting loop

get is an observation operation. Polling it repeatedly wastes work and creates timing-sensitive code. Use an appropriate blocking synchronization design when another thread is responsible for readiness.

Treating one-time publication as one-time mutation

OnceLock<Mutex<T>> still contains mutable shared state. Reason separately about how the inner value changes after publication.

Making global state because OnceLock makes it easy

One-time globals can reduce plumbing, but they also hide dependencies and make tests less isolated. Prefer explicit ownership when the value belongs to a particular application instance rather than the whole process.

When OnceLock is a good fit

OnceLock works well when all of these are true:

  • one value should be installed at most once during normal shared use;
  • the value must be safely visible across threads;
  • construction cannot happen directly as a constant;
  • callers need either explicit installation or lazy first-use initialization;
  • the stored value is valid for the lifetime of its owner.

A simpler local variable is better when initialization has one obvious owner and the value can simply be passed where it is needed.

A mutex or another mutable-state primitive is better when the value itself must change throughout the program’s lifetime.

Conclusion

OnceLock is easiest to reason about as a one-way publication mechanism. The cell starts empty, one successful value becomes visible, and later readers share that value.

Use get_or_init when first use should construct the value. Use set when startup or another explicit owner should choose it. Keep fallible preparation and side effects deliberate, avoid recursive initialization, and do not confuse one-time publication with ongoing mutation.

The main design question is not whether OnceLock can store a global value. It is whether the value truly has one process-wide identity and one initialization transition. When that lifecycle matches the problem, OnceLock makes the invariant visible in the type.