Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 098 / 100

OnceLock and LazyLock: a practical guide for developers

Lazy initialization in std.

Expona Admin6 min read

Opening: Lazy initialization in Rust

Rust's type system guarantees memory safety, but initializing expensive resources—like database connections, compiled regexes, or configuration parsers—at startup can slow down your application. OnceLock and LazyLock: a practical guide for developers walks you through two primitives from the standard library that solve exactly this problem, letting you defer initialization until the moment you actually need it.

We've all felt the pain: a service that takes three seconds to start even though it won't use half its dependencies for hours. In this guide, we'll show you how to initialize once and read many times using thread-safe primitives that were stabilized in Rust 1.70.

Initialize once, read many times

The core idea behind lazy initialization is simple: delay creating a resource until it's requested, then reuse that single instance forever. Both OnceLock and LazyLock enforce this pattern at compile time.

OnceLock is a synchronization primitive that holds a value that gets set at most once. Once a value is stored, it's immutable and can be read by any thread. Think of it as a write-once, read-many container:

rust
use std::sync::OnceLock;

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

fn get_config() -> &'static str {
    CONFIG.get_or_init(|| {
        // Expensive initialization happens here, only once
        std::env::var("APP_CONFIG")
            .unwrap_or_else(|_| "default".to_string())
    })
}

fn main() {
    println!("{}", get_config());
    println!("{}", get_config()); // No re-initialization
}

In this example, the closure passed to get_or_init runs exactly once, even if called from multiple threads. Subsequent calls return a reference to the initialized value without re-running the closure.

LazyLock is a wrapper around OnceLock that handles initialization more ergonomically. Instead of calling get_or_init every time, LazyLock defers to a function you provide at declaration time:

rust
use std::sync::LazyLock;

static REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"^[a-z0-9]+@[a-z0-9]+\.[a-z]+$")
        .expect("valid regex")
});

fn validate_email(email: &str) -> bool {
    REGEX.is_match(email)
}

The regex compiles on first use, not when the program starts. This is especially valuable in CLI tools or services that exit before validating any email addresses.

Thread-safe by design

Both primitives use atomic compare-and-swap operations under the hood, making them safe to share across threads without explicit locking. The first thread to call get_or_init runs the initializer; all others block briefly and wait for the result.

Here's a realistic scenario: multiple worker threads all need a database connection pool:

rust
use std::sync::LazyLock;

struct DbPool {
    // Imaginary pool implementation
    connections: Vec<String>,
}

impl DbPool {
    fn new() -> Self {
        println!("Initializing pool...");
        DbPool {
            connections: vec!["conn1".to_string(), "conn2".to_string()],
        }
    }
}

static DB_POOL: LazyLock<DbPool> = LazyLock::new(DbPool::new);

fn worker(id: usize) {
    let pool = &*DB_POOL;
    println!("Worker {} using pool with {} connections", 
        id, pool.connections.len());
}

fn main() {
    let handles: Vec<_> = (0..5)
        .map(|i| std::thread::spawn(move || worker(i)))
        .collect();

    for handle in handles {
        handle.join().unwrap();
    }
}

Run this and you'll see "Initializing pool..." printed exactly once, even though five threads all access DB_POOL. No mutexes, no Arc wrapping—the thread-safety is baked in.

If your initializer panics, the LazyLock will re-run the initializer on the next access attempt. This is different from some older libraries and gives you a chance to recover from transient failures.

Replaces lazy_static and once_cell

Before Rust 1.70, teams used the lazy_static crate or the once_cell crate for this pattern. Now that OnceLock and LazyLock are in the standard library, you can drop those dependencies and reduce your dependency tree.

If you're migrating existing code:

  • lazy_static!('static NAME: Type = value;') → static NAME: LazyLock<Type> = LazyLock::new(|| value);
  • once_cell::sync::Lazy → LazyLock (identical API in many cases)
  • once_cell::sync::OnceCell → OnceLock

The migration is straightforward because the standard library implementation is based on the proven once_cell design. For greenfield projects, use the standard library version without hesitation.

Great for config and regexes

Two patterns emerge repeatedly in production code:

Parsing configuration

Most applications read environment variables or configuration files once at startup. Using LazyLock lets you defer parsing until the config is actually needed:

rust
use std::sync::LazyLock;
use std::collections::HashMap;

#[derive(Debug)]
pub struct AppConfig {
    pub db_url: String,
    pub log_level: String,
    pub cache_ttl_secs: u64,
}

impl AppConfig {
    fn load() -> Self {
        println!("Loading config...");
        AppConfig {
            db_url: std::env::var("DATABASE_URL")
                .unwrap_or_else(|_| "postgres://localhost".to_string()),
            log_level: std::env::var("LOG_LEVEL")
                .unwrap_or_else(|_| "info".to_string()),
            cache_ttl_secs: std::env::var("CACHE_TTL")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(3600),
        }
    }
}

static CONFIG: LazyLock<AppConfig> = LazyLock::new(AppConfig::load);

fn get_db_url() -> &'static str {
    &CONFIG.db_url
}

If your service starts a REPL or interactive mode and never touches the database, the config parsing is never triggered. In a test environment, you can swap environment variables and tests will each see fresh config if they run in separate processes.

Compiling regexes

Regex compilation is expensive. Compiling the same regex a thousand times in a hot loop is wasteful. LazyLock makes the pattern clear and efficient:

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

static UUID_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
    ).expect("valid UUID regex")
});

fn is_valid_uuid(s: &str) -> bool {
    UUID_REGEX.is_match(s)
}

fn main() {
    assert!(is_valid_uuid("550e8400-e29b-41d4-a716-446655440000"));
    assert!(!is_valid_uuid("not-a-uuid"));
}

The regex is compiled on the first call to is_valid_uuid, then cached forever. This is the idiomatic pattern in Rust for regex-heavy code.

Common mistakes

Expecting early initialization

If you use LazyLock or OnceLock, initialization happens on first use, not on program startup. If you need to fail fast on config errors, call a function that accesses the lock early in main:

rust
fn main() {
    // Force initialization and fail if config is invalid
    let _ = &*CONFIG;
    
    // Rest of startup continues
}

Without this, a bad config might not be discovered until an hour into a long-running process.

Misusing OnceLock.set()

OnceLock has a set method that fails if the value is already set. Beginners sometimes try to use it like a mutable cell:

rust
static COUNTER: OnceLock<u32> = OnceLock::new();

fn increment() {
    // This won't compile; OnceLock is not mutable
    // COUNTER.set(COUNTER.get().unwrap() + 1); // ❌
}

If you need mutable state, use Mutex<T> or AtomicU32. OnceLock is for write-once semantics only.

Forgetting 'static lifetimes

Static items must have 'static lifetime. If your lazy value holds references to non-static data, the code won't compile. This is usually the right thing—it forces you to think about whether the reference will outlive the data it points to.

Key takeaways

  • LazyLock and OnceLock defer expensive initialization until first use, speeding up startup and reducing resource waste.
  • Both primitives are thread-safe by design; no mutexes or explicit synchronization needed for the initialization itself.
  • Use LazyLock when you know the initializer at compile time; use OnceLock when you need explicit control over when and how the value is set.
  • Common use cases are regex compilation, configuration parsing, and database connection pools.
  • These standard library primitives have replaced the lazy_static and once_cell crates; prefer them in new code.
  • Always ensure initialization happens early in your program if you want to fail fast on config errors.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles