LazyLock and OnceLock
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,
LazyLockis poisoned for the rest of the process (unrecoverable). Fail fallible work before installing into aLazyLock, or useOnceLock+Resultyou 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
- Heavy work on first request path. First HTTP request paying for a huge init surprises latency SLOs — warm
LazyLockduring startup (let _ = &*STATIC;) if the cost matters. - Panic inside
LazyLockinit. Poison is permanent for that static. Prefer fallible setup withOnceLock. - Storing
Mutexinside when you only needed one-time init. After init, readers of an immutableTdo not need a mutex. Add synchronization only for post-init mutation. - Assuming
Dropruns forstatic LazyLock. It does not at process exit. Flush external resources explicitly if required. - Using
once_cellorlazy_staticin new code on 1.80+. Prefer std unless you need an API those crates still uniquely provide.
Try this
- Build Case 1 (or a std-only
LazyLock<HashMap<…>>) and print addresses of&*EMAILacross two threads withstd::thread::scope— they must match. - Call
CFG.settwice in Case 2’ssetvariant; assert the second call isErr. - Replace Case 3’s
OnceLockwith aLazyLockthatexpects on missing env — then run withoutDATABASE_URLand observe the panic/poison behavior. - Skim
std::sync::LazyLockandOnceLockforget_mut/take(where available on your MSRV) before using them in libraries.
Sources
std::sync::LazyLock(1.80+)std::sync::OnceLock(1.70+)std::cell::LazyCell/OnceCell