LazyLock and OnceLock

Updated

October 31, 2026

LazyLock and OnceLock

Global or shared values often need one-time initialization: a compiled regex, a config snapshot, a connection pool handle. Before Rust 1.70 / 1.80 you reached for lazy_static, once_cell, or a hand-rolled Mutex<Option<T>>. Today the standard library covers the boring cases:

Type Module Stabilized Role
OnceLock<T> std::sync 1.70 Thread-safe cell: write once, then shared reads
LazyLock<T, F> std::sync 1.80 OnceLock + init closure; initializes on first access
OnceCell<T> / LazyCell<T> std::cell 1.70 / 1.80 Single-threaded cousins (no Sync)

Prefer LazyLock for static values with a pure init function. Prefer OnceLock when initialization needs arguments, fallible setup, or happens explicitly in main / a builder. This chapter sits next to Mutex and Arc because the shared handle is often wrapped in those types later — but lazy init itself is not a lock around every read after the first.

Mental model

first access / set()
        │
        ▼
  run init exactly once  ──►  store T
        │
        ▼
 later access  ──►  &T (no re-init)
  • Concurrent first accessors: one wins the init; others wait, then see the same T.
  • If the init closure panics, LazyLock is poisoned for the rest of the process (unrecoverable). Fail fallible work before installing into a LazyLock, or use OnceLock + Result you control.
  • static LazyLock<T> values are not dropped at process exit; the OS reclaims the memory. That is intentional and fine for process-lifetime data.

Worked examples

Case 1: LazyLock for a process-wide regex

use std::sync::LazyLock;
use regex::Regex;

static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").expect("static regex")
});

fn looks_like_email(s: &str) -> bool {
    EMAIL.is_match(s)
}

fn main() {
    assert!(looks_like_email("dev@example.com"));
    assert!(!looks_like_email("not-an-email"));
    println!("ok");
}

First call to looks_like_email compiles the pattern once. Later calls reuse the same Regex. The expect is acceptable only for patterns that are compile-time constants you control — never for user-supplied patterns inside a LazyLock init.

Add regex in Cargo.toml for this sample (regex = "1"). For a std-only sketch, replace the body with a pure function that builds a String or HashMap.

Case 2: OnceLock when init needs an argument

use std::sync::OnceLock;

struct AppConfig {
    api_root: String,
}

fn config() -> &'static AppConfig {
    static CFG: OnceLock<AppConfig> = OnceLock::new();
    CFG.get_or_init(|| AppConfig {
        api_root: std::env::var("API_ROOT").unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
    })
}

fn main() {
    println!("api_root={}", config().api_root);
    // Second call returns the same address
    assert!(std::ptr::eq(config(), config()));
}

get_or_init runs the closure only if empty. set / get let you initialize from main after CLI parsing:

use std::sync::OnceLock;

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

fn main() {
    let root = std::env::args().nth(1).unwrap_or_else(|| "/srv/app".into());
    CFG.set(root).expect("config set once");
    println!("{}", CFG.get().expect("configured"));
}

set returns Err(value) if the cell was already filled — use that to catch double-init bugs in tests.

Case 3: Fallible init without poisoning LazyLock

use std::sync::OnceLock;

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

fn init_db_url() -> Result<(), String> {
    let url = std::env::var("DATABASE_URL").map_err(|_| "DATABASE_URL missing".to_string())?;
    if !url.starts_with("postgres://") {
        return Err("DATABASE_URL must start with postgres://".into());
    }
    DB_URL.set(url).map_err(|_| "DB_URL already set".to_string())?;
    Ok(())
}

fn db_url() -> &'static str {
    DB_URL.get().expect("call init_db_url() before use")
}

fn main() {
    if let Err(e) = init_db_url() {
        eprintln!("startup failed: {e}");
        std::process::exit(1);
    }
    println!("using {}", db_url());
}

Validate and then set. Do not hide I/O errors inside LazyLock::new(|| …) if you need a clean startup failure path.

Case 4: Single-threaded LazyCell

use std::cell::LazyCell;

fn main() {
    let cache = LazyCell::new(|| {
        println!("computing");
        21 * 2
    });
    assert_eq!(*cache, 42); // prints "computing"
    assert_eq!(*cache, 42); // silent
}

Use LazyCell / OnceCell inside one thread (or inside a type that is already !Sync). Crossing threads wants LazyLock / OnceLock.

Choosing among the options

Situation Prefer
static value, infallible or expect-able init LazyLock
Init needs CLI/env parsed in main OnceLock + set
Fallible init with structured errors OnceLock + Result before set
Non-Sync local cache LazyCell / OnceCell
Mutate after init Not these cells — use Mutex / RwLock around the data, or rebuild

LazyLock implements Deref, so *LAZY and method calls work like a plain &T after init. OnceLock exposes get, get_or_init, set, and related helpers explicitly.

Pitfalls

  1. Heavy work on first request path. First HTTP request paying for a huge init surprises latency SLOs — warm LazyLock during startup (let _ = &*STATIC;) if the cost matters.
  2. Panic inside LazyLock init. Poison is permanent for that static. Prefer fallible setup with OnceLock.
  3. Storing Mutex inside when you only needed one-time init. After init, readers of an immutable T do not need a mutex. Add synchronization only for post-init mutation.
  4. Assuming Drop runs for static LazyLock. It does not at process exit. Flush external resources explicitly if required.
  5. Using once_cell or lazy_static in new code on 1.80+. Prefer std unless you need an API those crates still uniquely provide.

Try this

  1. Build Case 1 (or a std-only LazyLock<HashMap<…>>) and print addresses of &*EMAIL across two threads with std::thread::scope — they must match.
  2. Call CFG.set twice in Case 2’s set variant; assert the second call is Err.
  3. Replace Case 3’s OnceLock with a LazyLock that expects on missing env — then run without DATABASE_URL and observe the panic/poison behavior.
  4. Skim std::sync::LazyLock and OnceLock for get_mut / take (where available on your MSRV) before using them in libraries.

Sources