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

PatternCost on repeated accessNotes
Recompute on every callHighRepeats parsing, allocation, or compilation
Mutex<Option<T>>Medium to highLocking overhead on every access
OnceLock<T>LowOne-time setup, then cheap reads
lazy_static!LowSimilar 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:

  • Regex values
  • HashMap-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:

  1. the regex is compiled only once
  2. 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.

MethodUse caseBehavior
get()Read if already initializedReturns Option<&T>
set(value)Initialize from an external sourceSucceeds only once
get_or_init(f)Lazy initializationRuns closure once if needed
get_or_try_init(f)Fallible lazy initializationInitializes 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 &T or 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.

ToolStandard libraryFallible initNotes
OnceLockYesYesModern, simple, recommended for many cases
LazyLockYesNoGood for infallible lazy values
lazy_static!NoLimitedOlder macro-based approach
once_cell::OnceCellNoYesExternal 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.

Learn more with useful resources