How to create a global with lazy initialisation
Short answer: Put the value in a static behind std::sync::LazyLock — static SETTINGS: LazyLock<String> = LazyLock::new(|| expensive_build()); — and read it through &*SETTINGS or just &SETTINGS: the closure runs the first time anyone looks, once, on any thread. When the code that decides the value should live at a specific point instead of on first read, use std::sync::OnceLock and call get_or_init (or set) yourself. Both are in the standard library since Rust 1.80 and 1.70 respectively — no crate, no lazy_static! macro, no once_cell.
Sooner or later every program accumulates one value that all of it needs: a configuration struct, a database pool, a cached client. The natural home is a static, because a static has a fixed address for the whole run and every function can reach it. But a static comes with a constraint that stops most people on the first attempt.
Why the plain static refuses
Section titled “Why the plain static refuses”Here is the version that does not compile — a global built by calling a function:
static APP_NAME: String = String::from("demo");
fn main() { println!("{APP_NAME}");}error[E0015]: cannot call non-const associated function `<String as From<&str>>::from` in statics --> src/main.rs:1:27 |1 | static APP_NAME: String = String::from("demo"); | ^^^^^^^^^^^^^^^^^^^^ | = note: calls in statics are limited to constant functions, tuple structs and tuple variants = note: consider wrapping this expression in `std::sync::LazyLock::new(|| ...)`
For more information about this error, try `rustc --explain E0015`.The reason is not fussiness. A static initializer has to be computable at compile time, because the value exists before main does — before any function call could run. String::from allocates at runtime, so the compiler rejects it. (The project for the output above is named global-lazy; the last line names whatever the crate is called.)
And the compiler has already suggested the fix in that = note: line. std::sync::LazyLock is the standard library’s answer to exactly this error: keep the type in a static, run the expensive build later, on first use.
OnceLock: initialisation where you choose
Section titled “OnceLock: initialisation where you choose”OnceLock<T> is a cell that starts empty, can be written exactly once, and hands out &T afterwards. Its most convenient method is get_or_init: give it a closure, and the first caller’s closure result becomes the value forever — everyone else gets that one:
use std::sync::OnceLock;
static GREETING: OnceLock<String> = OnceLock::new();
fn greeting() -> &'static str { GREETING.get_or_init(|| { std::env::var("APP_GREETING").unwrap_or_else(|_| "hello, world".to_string()) })}
fn main() { println!("{}", greeting());}hello, worldRun it with the variable set and the closure picks that up instead:
APP_GREETING="hi from env" cargo runhi from envThe return type is worth a second look: greeting() returns &'static str without a single lifetime annotation, because OnceLock lives in a static — its address is fixed for the whole program, so a reference into it is valid forever. That is the payoff for putting the value in a static instead of passing it around: the accessor signature becomes a promise.
get_or_init takes the closure only until someone fills the cell. The other direction is set, for a program that decides the value once, early, and wants everything after that to read it:
use std::sync::OnceLock;
static SETTINGS: OnceLock<String> = OnceLock::new();
fn main() { println!("{:?}", SETTINGS.set("first".to_string())); println!("{:?}", SETTINGS.set("second".to_string())); println!("{}", SETTINGS.get().unwrap());}Ok(())Err("second")firstset returns the value back inside Err if the cell is already full — it does not overwrite, and it does not panic. The second call loses, the first wins, and code further down reads it with get().unwrap(), which is honest about the one state where it could be empty: before anyone called set.
LazyLock: the same thing, one line shorter
Section titled “LazyLock: the same thing, one line shorter”When the initialiser should simply run on first use — the common case — OnceLock plus a helper function is more ceremony than needed. LazyLock<T> is the cell and the initialiser fused: the closure lives in the static declaration itself, and reading the value runs it if needed:
use std::sync::LazyLock;
static PRIMES: LazyLock<Vec<u64>> = LazyLock::new(|| { (2..100).filter(|&n| (2..n).all(|d| n % d != 0)).collect()});
fn main() { println!("{} primes below 100", PRIMES.len()); println!("{:?}", &PRIMES[..5]);}25 primes below 100[2, 3, 5, 7, 11]No accessor function, no get_or_init call at the read site — PRIMES.len() looks like an ordinary field access, and the sieve inside the closure runs when that line first executes. LazyLock implements Deref, so method calls and indexing go straight through to the contained value.
The initialiser really does run once
Section titled “The initialiser really does run once”“Once” is the whole promise, and it is checkable. Put a counter in the initialiser, read the global several times, and count:
use std::sync::LazyLock;use std::sync::atomic::{AtomicUsize, Ordering};
static INIT_CALLS: AtomicUsize = AtomicUsize::new(0);
static DB_URL: LazyLock<String> = LazyLock::new(|| { INIT_CALLS.fetch_add(1, Ordering::Relaxed); "postgres://localhost/app".to_string()});
fn main() { let a = &*DB_URL; let b = &*DB_URL; assert_eq!(a, b); println!("initialiser ran {} time(s)", INIT_CALLS.load(Ordering::Relaxed));}initialiser ran 1 time(s)Two reads, one initialisation. The guarantee holds across threads too — get_or_init and the lazy deref coordinate through atomic state, so if two threads reach the empty cell at the same moment, one runs the closure while the other waits for the same result. That is also why the types inside need to be Send + Sync: a LazyLock or OnceLock in a static is shared by every thread in the program, and the compiler holds the value to that standard. A plain Rc<Vec<u64>> behind a LazyLock does not compile; Vec<u64> does.
Which one to reach for
Section titled “Which one to reach for”LazyLock when the value is a pure function of things the closure can already see, and “on first use” is the right moment. OnceLock when the value arrives from outside — environment, arguments, a file read in main — and set or get_or_init lets the code that knows the moment do the writing. The two compose with everything else in this chapter: the tracing subscriber these articles initialised was a global too, and the config article after this one builds exactly the kind of value that ends up behind one of these cells.