
Optimizing Rust with `std::sync::OnceLock` for Lazy, One-Time Initialization
What OnceLock does
OnceLock<T> stores a value that may be written once and read many times. After initialization, reads are fast and require no locking in normal use. The first caller that initializes the value wins; all other callers observe the same value.
This makes it ideal for:
- expensive configuration parsing
- compiled regexes
- global caches with immutable contents
- static lookup data derived from runtime input
- one-time setup for clients or handles that are safe to share
Unlike a plain static, OnceLock supports runtime initialization. Unlike a Mutex<Option<T>>, it avoids locking on every read after initialization.
Basic shape
use std::sync::OnceLock;
static CONFIG: OnceLock<String> = OnceLock::new();
fn config() -> &'static String {
CONFIG.get_or_init(|| {
// Expensive computation happens once.
std::fs::read_to_string("app.conf").expect("failed to read config")
})
}The closure runs only the first time config() is called. Later calls return a shared reference to the stored string.
Why it is a performance optimization
The main win is not that initialization becomes faster; it is that you stop paying for it more than once.
Common costs avoided
| Pattern | Cost on repeated access | Notes |
|---|---|---|
| Recompute on every call | High | Repeats parsing, allocation, or compilation |
Mutex<Option<T>> | Medium to high | Locking overhead on every access |
OnceLock<T> | Low | One-time setup, then cheap reads |
lazy_static! | Low | Similar behavior, but OnceLock is in the standard library |
For read-heavy code paths, this matters a lot. A regex compiled once and reused across thousands of requests is a classic example. So is a parsed configuration object used by many worker threads.
When OnceLock is the right choice
Use OnceLock when all of these are true:
- the value is expensive to create
- the value can be shared immutably after creation
- initialization may be deferred until first use
- the value should be initialized at most once
Good candidates include:
RegexvaluesHashMap-based lookup tables- parsed environment configuration
- TLS roots or client settings
- static metrics labels or metadata
It is less suitable when:
- the value changes frequently
- you need per-thread state
- you want eviction or cache replacement
- initialization must be retried after failure
If you need mutation after initialization, consider wrapping the stored value in a synchronization primitive such as RwLock, but only if the mutation is truly necessary.
A practical example: compiled regular expressions
Suppose you validate log lines using a regex. Compiling the regex on every request is wasteful.
use std::sync::OnceLock;
use regex::Regex;
static LOG_LINE_RE: OnceLock<Regex> = OnceLock::new();
fn log_line_re() -> &'static Regex {
LOG_LINE_RE.get_or_init(|| {
Regex::new(r"^(INFO|WARN|ERROR)\s+\[(.+?)\]\s+(.*)$")
.expect("invalid regex")
})
}
fn parse_line(line: &str) -> Option<(&str, &str, &str)> {
let caps = log_line_re().captures(line)?;
Some((
caps.get(1)?.as_str(),
caps.get(2)?.as_str(),
caps.get(3)?.as_str(),
))
}This design has two advantages:
- the regex is compiled only once
- the hot path only performs matching, not setup
If your application processes many lines, the savings are substantial.
get_or_init vs set vs get
OnceLock offers a few methods, and choosing the right one helps keep code clear.
| Method | Use case | Behavior |
|---|---|---|
get() | Read if already initialized | Returns Option<&T> |
set(value) | Initialize from an external source | Succeeds only once |
get_or_init(f) | Lazy initialization | Runs closure once if needed |
get_or_try_init(f) | Fallible lazy initialization | Initializes only on success |
get_or_init
This is the most common choice. It is concise and ideal when initialization cannot reasonably fail or when failure is handled by panicking.
set
Use set when you already have the value and want to store it once, often during startup.
use std::sync::OnceLock;
static VERSION: OnceLock<String> = OnceLock::new();
fn init_version(v: String) {
VERSION.set(v).expect("version already initialized");
}get_or_try_init
If initialization can fail, prefer this over hiding the error in a panic.
use std::sync::OnceLock;
use regex::Regex;
static USER_RE: OnceLock<Regex> = OnceLock::new();
fn user_re() -> Result<&'static Regex, regex::Error> {
USER_RE.get_or_try_init(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$"))
}This is especially useful in libraries, where callers should be able to report errors cleanly.
Designing for immutable shared access
OnceLock works best when the stored value is immutable after initialization. That lets you return shared references and avoid further synchronization.
Good pattern
- build the value once
- store it in
OnceLock - expose
&Tor methods on&T
Less ideal pattern
- store a mutable container
- lock it repeatedly for every read
- mutate it frequently
If you need a cache with frequent updates, OnceLock alone is not enough. It is a one-time initializer, not a general cache manager.
A good rule: use OnceLock for the root object, not for every mutable sub-operation.
Initializing from runtime configuration
A common use case is reading environment variables or files only when needed.
use std::sync::OnceLock;
#[derive(Debug)]
struct AppConfig {
port: u16,
debug: bool,
}
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
fn config() -> &'static AppConfig {
CONFIG.get_or_init(|| {
let port = std::env::var("APP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080);
let debug = std::env::var("APP_DEBUG")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
AppConfig { port, debug }
})
}This pattern avoids parsing environment variables on every access. It also centralizes configuration logic in one place.
Best practice
Keep the initialization closure small and deterministic. If it does too much work, it becomes harder to test and reason about. If the setup is complex, move it into a dedicated function and call that from get_or_init.
Avoiding hidden contention
OnceLock is thread-safe, but that does not mean all usage is free. The first initialization can still become a bottleneck if many threads race to initialize the same value at once.
That is usually acceptable because it happens once. Still, there are a few practical tips:
- initialize early if startup latency matters
- avoid expensive blocking I/O inside the initializer if you can precompute earlier
- keep the initializer idempotent and side-effect free
- do not perform unrelated work in the closure
Example: eager warm-up
If the value is definitely needed, initialize it during startup rather than on the first request.
fn warm_up() {
let _ = config();
let _ = log_line_re();
}This moves the cost to startup and makes request latency more predictable.
OnceLock and testability
Global lazy state can make tests harder if it depends on process-wide environment or file system state. To keep tests clean:
- isolate the initialization logic in a function
- avoid hard-coding external paths in the closure
- prefer pure construction from passed-in data where possible
If you need test-specific values, consider separating the value builder from the global accessor.
use std::sync::OnceLock;
static SETTINGS: OnceLock<Settings> = OnceLock::new();
#[derive(Debug)]
struct Settings {
mode: String,
}
fn build_settings(mode: &str) -> Settings {
Settings {
mode: mode.to_string(),
}
}
fn settings() -> &'static Settings {
SETTINGS.get_or_init(|| build_settings("prod"))
}Now build_settings can be unit-tested independently without touching global state.
Common mistakes
1. Using OnceLock for mutable state
If the value changes often, OnceLock is the wrong abstraction. It is not a replacement for a cache, queue, or counter.
2. Putting too much work in the initializer
The closure should initialize one logical resource. If it also performs unrelated setup, debugging becomes harder.
3. Ignoring fallible initialization
If initialization can fail, use get_or_try_init or a separate startup phase. Do not force everything through unwrap() unless a crash is truly acceptable.
4. Rebuilding the same value elsewhere
If you already have a shared OnceLock, make sure all callers use it. A common performance bug is accidentally compiling the same regex in two different modules.
OnceLock vs other lazy initialization tools
Rust developers may also know lazy_static! or once_cell. OnceLock is the standard library’s built-in answer for one-time initialization.
| Tool | Standard library | Fallible init | Notes |
|---|---|---|---|
OnceLock | Yes | Yes | Modern, simple, recommended for many cases |
LazyLock | Yes | No | Good for infallible lazy values |
lazy_static! | No | Limited | Older macro-based approach |
once_cell::OnceCell | No | Yes | External crate with similar capabilities |
If you are starting a new project on a recent Rust version, OnceLock is usually the simplest choice.
Summary
std::sync::OnceLock is a practical performance tool for values that are expensive to create but cheap to reuse. It helps you move work out of hot paths, reduce repeated allocation and parsing, and share immutable data safely across threads.
Use it when you want:
- lazy initialization
- one-time setup
- cheap repeated reads
- thread-safe global access without repeated locking
For many Rust applications, that combination is enough to remove a surprising amount of overhead from the critical path.
