How to Use Once and OnceLock for One-Time Initialization

Use OnceLock for safe, thread-safe one-time initialization that returns a reference to the initialized value.

The one-time setup problem

You are building a CLI tool that connects to a database. The connection setup involves reading credentials from a file, negotiating TLS, and warming up a connection pool. It takes half a second. You want that to happen exactly once, the first time someone actually needs the database. If the user runs my-app --version, you do not want to waste time connecting. You need a mechanism that says, "Do this work once, remember the result, and hand it out forever after."

This pattern shows up everywhere. You have a logger that needs to open a file. You have a configuration parser that reads a JSON blob. You have a metric collector that registers with a remote server. In every case, the initialization is expensive or has side effects that must not repeat. You need lazy initialization that is thread-safe and guarantees the work runs exactly once.

Rust's standard library provides two tools for this: Once and OnceLock. Once is the older mechanism that only runs a closure. OnceLock is the modern successor that runs the closure and stores the result. OnceLock is the preferred choice for almost every use case because it eliminates the need for unsafe code to store the initialized value.

OnceLock: the modern answer

OnceLock<T> wraps a value of type T. It starts empty. The first time you ask for the value, it runs a closure you provide, stores the result, and returns a reference. Every subsequent call skips the closure and returns the stored reference immediately.

Think of OnceLock like a vending machine that stocks itself. The machine starts empty. When the first customer presses a button, the machine goes to the warehouse, grabs the item, stocks the shelf, and hands the item to the customer. The next customer presses the button and gets the item instantly from the shelf. The warehouse trip never happens again.

use std::sync::OnceLock;

// Static items are initialized at compile time.
// OnceLock::new() is const, so this works without runtime cost.
static CONFIG: OnceLock<String> = OnceLock::new();

fn get_config() -> &'static str {
    // get_or_init runs the closure exactly once.
    // It returns a reference to the stored value.
    CONFIG.get_or_init(|| {
        // This block runs only on the first call.
        // Simulate expensive work like reading a file.
        String::from("database_url=postgres://localhost/mydb")
    })
}

fn main() {
    // First call: runs the closure, stores the String.
    println!("Config: {}", get_config());

    // Second call: returns the stored reference instantly.
    println!("Config again: {}", get_config());
}

The closure passed to get_or_init captures no arguments. It must produce the value of type T. The return type of get_or_init is &T, which means you get a reference to the value stored inside the OnceLock. Since the OnceLock is static, the reference has a 'static lifetime. You can hand this reference to any function or thread, and it will remain valid for the entire program.

Convention aside: always use OnceLock::new() for static initialization. It is a const function, so the compiler generates the empty state at build time. You never pay a runtime cost to create the OnceLock itself. The cost only appears when you first access the value.

How OnceLock works under the hood

When you call get_or_init, OnceLock performs a fast check using atomic operations. If the value is already initialized, it returns the reference immediately. This fast path has almost zero overhead. It is as cheap as reading a pointer.

If the value is not initialized, OnceLock acquires an internal lock. Only one thread can hold this lock at a time. The thread that acquires the lock runs your closure. If the closure succeeds, the thread stores the result, releases the lock, and returns the reference. If the closure panics, OnceLock marks itself as poisoned. The lock is released, but the state is now broken.

Other threads that called get_or_init while the first thread was running will block on the lock. When the lock is released, they see the result. If the first thread succeeded, they get the reference. If the first thread panicked, they panic immediately. This prevents multiple threads from running the initialization code simultaneously. It also prevents threads from using a partially initialized value if the setup fails.

Thread safety is built in. OnceLock<T> implements Sync as long as T implements Send and Sync. This means you can share a static OnceLock across threads without wrapping it in a Mutex. The synchronization happens inside OnceLock. You do not need to manage locks manually.

The old way: Once and unsafe

Before OnceLock existed, the standard library only had Once. Once can run a closure exactly once, but it does not store any value. If you need to store the result of the initialization, you have to manage storage yourself. This almost always requires unsafe code.

Here is how you would implement the same config loader using Once. You need a static mut to hold the value, and you need unsafe blocks to read and write it.

use std::sync::Once;
use std::cell::RefCell;

static INIT: Once = Once::new();
// We need a place to store the result.
// RefCell allows interior mutability, but we still need unsafe for static mut.
static mut CONFIG: Option<String> = None;

fn get_config_old() -> &'static str {
    INIT.call_once(|| {
        // call_once guarantees this runs once, but it cannot return a value.
        // We must write to a static variable.
        unsafe {
            CONFIG = Some(String::from("database_url=postgres://localhost/mydb"));
        }
    });

    // We must read the static variable.
    // This is unsafe because the compiler cannot guarantee CONFIG is initialized.
    unsafe {
        CONFIG.as_ref().expect("Config not initialized")
    }
}

This code is fragile. The unsafe blocks hide bugs. If you forget to initialize CONFIG before reading it, you get undefined behavior. If you accidentally drop the value, you get a dangling pointer. The compiler cannot help you. OnceLock solves this by storing the value safely inside the lock. The borrow checker ensures you never access uninitialized memory.

Use Once only when you truly do not need to store a value. For example, you might use Once to print a startup message or to register a signal handler. If you need to store a result, OnceLock is the safe choice. Reach for Once when you are implementing a safe abstraction that manages its own internal state via unsafe, and you need to guarantee a setup routine runs once. For application code, OnceLock is the standard.

Realistic example: a logger with file handle

A common use case is a logger that writes to a file. You want to open the file once and reuse the handle. You also want to support a mode where the logger is disabled. OnceLock handles this cleanly.

use std::sync::OnceLock;
use std::fs::File;
use std::io::Write;

static LOGGER: OnceLock<File> = OnceLock::new();

fn log_message(msg: &str) {
    // get_or_init opens the file if it is not already open.
    let file = LOGGER.get_or_init(|| {
        File::create("app.log").expect("Failed to create log file")
    });

    // Write to the file.
    // The write might fail, but the file handle remains valid.
    let _ = writeln!(file, "{}", msg);
}

fn main() {
    log_message("Application started");
    log_message("Processing request");
}

In this example, File::create runs only once. The File handle is stored inside LOGGER. Every call to log_message gets a reference to the same File. The file stays open for the lifetime of the program. When the program exits, the OnceLock drops the File, which flushes and closes it.

Sometimes you compute the value in a different function and want to store it later. OnceLock provides a set method for this. set takes a value and stores it if the lock is empty. It returns a Result indicating success or failure. This is useful when initialization happens asynchronously or in a separate module.

use std::sync::OnceLock;

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

fn load_cache() -> Result<(), &'static str> {
    let data = vec!["item1".to_string(), "item2".to_string()];

    // set returns Ok if the value was stored.
    // It returns Err if another thread already initialized the lock.
    CACHE.set(data).map_err(|_| "Cache already initialized")
}

fn get_cache() -> &'static [String] {
    // get returns Option<&T>.
    // Use this when you want to check if the value exists without initializing.
    CACHE.get().map(|v| v.as_slice()).unwrap_or(&[])
}

The get method returns Option<&T>. This allows you to check if the value is initialized without triggering the closure. Use get when you need to distinguish between "not initialized" and "initialized with a default". Use get_or_init when you always want a value and are willing to pay the initialization cost if needed.

Pitfalls and compiler errors

Panicking inside the closure is the biggest pitfall. If the closure passed to get_or_init panics, OnceLock marks itself as poisoned. The next call to get_or_init will panic immediately, propagating the failure. This is intentional. It prevents the program from continuing with a broken state. If your initialization can fail, handle the error inside the closure. Use expect or unwrap to turn errors into panics, or handle the error gracefully and return a fallback value.

If you try to store a type that is not Send or Sync in a static OnceLock, the compiler rejects the code. OnceLock requires the value to be safe to share across threads.

use std::sync::OnceLock;
use std::rc::Rc;

// This fails to compile.
// Rc is not Sync, so it cannot be shared across threads.
static BAD_LOCK: OnceLock<Rc<String>> = OnceLock::new();

The compiler produces E0277 (trait bound not satisfied). The error message tells you that Rc<String> does not implement Sync. To fix this, use Arc<String> instead, which is thread-safe. Or use Rc inside a Mutex if you need interior mutability, though that defeats the purpose of OnceLock.

Another error occurs if you try to move a value out of the OnceLock. OnceLock only gives you references. You cannot take ownership of the stored value. This is by design. The value lives inside the OnceLock for the duration of the program. If you need ownership, clone the value or use a different data structure.

use std::sync::OnceLock;

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

fn bad_usage() {
    let _value = DATA.get_or_init(|| String::from("hello"));
    // _value is &String. You cannot move it.
    // This would cause E0507 if you tried to move out of the reference.
}

Trust the borrow checker here. If you need ownership, your design might be wrong. OnceLock is for sharing references, not for transferring ownership. If you need to reset the value or take it out, OnceLock is not the right tool. Use a Mutex<Option<T>> instead, though you lose the one-time guarantee.

Decision: Once vs OnceLock vs LazyLock

Use OnceLock when you need to initialize a value once and share a reference to it across threads. Use OnceLock when you want thread-safe lazy initialization without writing unsafe code. Use OnceLock when you need to control the initialization timing manually, such as when the value depends on runtime arguments or external state.

Use Once when you only need to run a side effect once and do not need to store a value. Use Once when you are building a safe abstraction that manages its own internal state via unsafe, and you need to guarantee a setup routine runs exactly once. Reach for Once only when OnceLock cannot express your requirements.

Use LazyLock when you want the compiler to handle the OnceLock boilerplate for you. LazyLock is a macro-like type that initializes a value at first access with minimal syntax. Use LazyLock when the initialization is simple and does not depend on runtime arguments. Use LazyLock when you prefer concise code over explicit control.

Counter-intuitive but true: OnceLock is often faster than LazyLock in hot paths because get_or_init has a highly optimized fast path that checks the atomic state without any function call overhead. LazyLock adds a tiny layer of indirection. In practice, the difference is negligible, but OnceLock gives you more control.

Don't fight the compiler here. Reach for OnceLock for one-time initialization. It is safe, fast, and standard. If you find yourself writing unsafe to store a value in a Once, switch to OnceLock. If you find yourself writing a Mutex<Option<T>> just to initialize once, switch to OnceLock. The tool exists to solve this problem. Use it.

Where to go next