How do I work with Once and OnceLock for one-time initialization in Rust?

Walkthrough

Rust provides several primitives for one-time initialization — ensuring code runs exactly once, even in multi-threaded contexts. These are essential for lazy initialization, singletons, and setup code.

Key types in std::sync:

  • Once — Low-level primitive for running a closure exactly once
  • OnceLock<T> — Thread-safe container for one-time initialization of a value (stable since Rust 1.70)
  • LazyLock<T> — Combines OnceLock with a initialization function (stable since Rust 1.80)

For single-threaded code:

  • std::cell::OnceCell<T> — Single-threaded version of OnceLock

These primitives guarantee:

  • Initialization happens exactly once, even with concurrent access
  • All threads see the same initialized value
  • No deadlocks or race conditions during initialization

Code Examples

Basic Once Usage

use std::sync::Once;
use std::thread;
 
static INIT: Once = Once::new();
 
fn expensive_setup() {
    println!("Running expensive setup...");
    std::thread::sleep(std::time::Duration::from_millis(100));
    println!("Setup complete!");
}
 
fn main() {
    let mut handles = vec![];
    
    // Spawn 5 threads that all need the setup
    for i in 0..5 {
        let handle = thread::spawn(move || {
            // call_once ensures this runs exactly once
            INIT.call_once(|| {
                expensive_setup();
            });
            
            println!("Thread {} proceeding after setup", i);
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

Using OnceLock for Lazy Static Values

use std::sync::OnceLock;
use std::thread;
 
// Global configuration loaded once
static CONFIG: OnceLock<String> = OnceLock::new();
 
fn get_config() -> &'static String {
    CONFIG.get_or_init(|| {
        println!("Loading config...");
        // Simulate expensive loading
        std::thread::sleep(std::time::Duration::from_millis(50));
        "production-config".to_string()
    })
}
 
fn main() {
    let mut handles = vec![];
    
    for i in 0..3 {
        let handle = thread::spawn(move || {
            let config = get_config();
            println!("Thread {} got config: {}", i, config);
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    // Can also check if initialized
    if CONFIG.get().is_some() {
        println!("Config was initialized");
    }
}

OnceLock with Complex Types

use std::sync::OnceLock;
use std::collections::HashMap;
 
struct Database {
    connections: HashMap<String, String>,
}
 
impl Database {
    fn new() -> Self {
        println!("Creating database connection pool...");
        let mut connections = HashMap::new();
        connections.insert("primary".to_string(), "db://localhost:5432".to_string());
        connections.insert("replica".to_string(), "db://replica:5432".to_string());
        Self { connections }
    }
    
    fn get_connection(&self, name: &str) -> Option<&String> {
        self.connections.get(name)
    }
}
 
static DB: OnceLock<Database> = OnceLock::new();
 
fn get_database() -> &'static Database {
    DB.get_or_init(Database::new)
}
 
fn main() {
    // First access initializes
    let db = get_database();
    println!("Primary: {:?}", db.get_connection("primary"));
    
    // Subsequent accesses reuse the same instance
    let db2 = get_database();
    println!("Replica: {:?}", db2.get_connection("replica"));
    
    // Can also initialize with result
    let result: Result<i32, &str> = Ok(42);
    static RESULT: OnceLock<Result<i32, &str>> = OnceLock::new();
    RESULT.set(result).ok(); // Returns Err if already set
}

Using LazyLock (Rust 1.80+)

use std::sync::LazyLock;
use std::time::Instant;
 
// LazyLock combines OnceLock with initialization function
// The closure runs on first access
static START_TIME: LazyLock<Instant> = LazyLock::new(|| {
    println!("Capturing start time...");
    Instant::now()
});
 
static NUMBERS: LazyLock<Vec<i32>> = LazyLock::new(|| {
    println!("Generating numbers...");
    (1..=100).collect()
});
 
fn main() {
    println!("Before accessing START_TIME");
    
    // First access triggers initialization
    println!("Start time: {:?}", *START_TIME);
    
    // Already initialized, no closure runs
    println!("Start time again: {:?}", *START_TIME);
    
    // Can access like normal static
    println!("Numbers sum: {}", NUMBERS.iter().sum::<i32>());
}

Single-Threaded OnceCell

use std::cell::OnceCell;
 
// OnceCell is the single-threaded version
// Use when you don't need thread safety
 
struct Cache {
    value: OnceCell<String>,
}
 
impl Cache {
    fn new() -> Self {
        Self { value: OnceCell::new() }
    }
    
    fn get_or_compute(&self) -> &String {
        self.value.get_or_init(|| {
            println!("Computing cached value...");
            "expensive-computed-value".to_string()
        })
    }
}
 
fn main() {
    let cache = Cache::new();
    
    println!("First call:");
    println!("Value: {}", cache.get_or_compute());
    
    println!("\nSecond call:");
    println!("Value: {}", cache.get_or_compute());
    
    // Check if set
    println!("\nIs set? {}", cache.value.get().is_some());
    
    // Try to set (returns Err if already set)
    let result = cache.value.set("new value".to_string());
    println!("Set result: {:?}", result);
}

Initialization with get_or_try_init

use std::sync::OnceLock;
use std::fs;
 
static SETTINGS: OnceLock<String> = OnceLock::new();
 
fn load_settings() -> Result<&'static String, std::io::Error> {
    SETTINGS.get_or_try_init(|| {
        println!("Loading settings from file...");
        // Simulate file reading
        fs::read_to_string("/nonexistent/settings.toml")
            .or_else(|_| Ok("default=settings\nvalue=42".to_string()))
    })
}
 
fn main() {
    match load_settings() {
        Ok(settings) => println!("Settings loaded: {}", settings),
        Err(e) => println!("Failed to load settings: {}", e),
    }
    
    // Second call returns the cached value
    match load_settings() {
        Ok(settings) => println!("Cached settings: {}", settings.lines().count()),
        Err(_) => unreachable!(),
    }
}

Singleton Pattern with OnceLock

use std::sync::OnceLock;
use std::collections::HashMap;
 
pub struct Registry {
    items: HashMap<String, String>,
}
 
impl Registry {
    fn new() -> Self {
        let mut items = HashMap::new();
        items.insert("service_a".to_string(), "http://service-a:8080".to_string());
        items.insert("service_b".to_string(), "http://service-b:8080".to_string());
        Self { items }
    }
    
    pub fn get(&self, key: &str) -> Option<&String> {
        self.items.get(key)
    }
    
    pub fn all(&self) -> &HashMap<String, String> {
        &self.items
    }
}
 
// Global singleton instance
static REGISTRY: OnceLock<Registry> = OnceLock::new();
 
pub fn registry() -> &'static Registry {
    REGISTRY.get_or_init(Registry::new)
}
 
fn main() {
    // Access the singleton
    let reg = registry();
    println!("Service A: {:?}", reg.get("service_a"));
    
    // Same instance every time
    let reg2 = registry();
    println!("Same instance? {}", std::ptr::eq(reg, reg2));
}

Comparing Once, OnceLock, and LazyLock

use std::sync::{Once, OnceLock, LazyLock};
 
static ONCE: Once = Once::new();
static ONCE_LOCK: OnceLock<i32> = OnceLock::new();
static LAZY: LazyLock<i32> = LazyLock::new(|| {
    println!("LazyLock initializing...");
    42
});
 
fn main() {
    println!("=== Once ===");
    // Once only runs code, doesn't return a value
    ONCE.call_once(|| {
        println!("Once: running initialization");
        // Must store result elsewhere if needed
    });
    println!("Once: second call does nothing");
    ONCE.call_once(|| {
        println!("This won't print");
    });
    
    println!("\n=== OnceLock ===");
    // OnceLock stores and returns a value
    let value = ONCE_LOCK.get_or_init(|| {
        println!("OnceLock: initializing");
        100
    });
    println!("OnceLock value: {}", value);
    
    // get_or_init returns reference to stored value
    let value2 = ONCE_LOCK.get_or_init(|| {
        println!("This won't print");
        200
    });
    println!("OnceLock value again: {}", value2);
    
    println!("\n=== LazyLock ===");
    // LazyLock is initialized on first dereference
    println!("LazyLock value: {}", *LAZY);
    println!("LazyLock value again: {}", *LAZY);
    
    println!("\n=== Summary ===");
    println!("Once: Run code once, no value stored");
    println!("OnceLock: Store value, explicit get_or_init");
    println!("LazyLock: Store value, implicit initialization");
}

Thread-Safe Logger Initialization

use std::sync::OnceLock;
use std::fs::File;
use std::io::Write;
use std::time::SystemTime;
 
struct Logger {
    file: File,
}
 
impl Logger {
    fn new() -> Self {
        let file = File::create("app.log").expect("Failed to create log file");
        Self { file }
    }
    
    fn log(&mut self, message: &str) {
        let timestamp = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        writeln!(self.file, "[{}] {}", timestamp, message).unwrap();
    }
}
 
static LOGGER: OnceLock<std::sync::Mutex<Logger>> = OnceLock::new();
 
fn log(message: &str) {
    let logger = LOGGER.get_or_init(|| {
        std::sync::Mutex::new(Logger::new())
    });
    
    logger.lock().unwrap().log(message);
}
 
fn main() {
    std::thread::scope(|s| {
        s.spawn(|| {
            log("Message from thread 1");
        });
        s.spawn(|| {
            log("Message from thread 2");
        });
        s.spawn(|| {
            log("Message from thread 3");
        });
    });
    
    println!("Log file created. Check app.log");
}

Once with State Tracking

use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};
 
static INIT: Once = Once::new();
static IS_INITIALIZED: AtomicBool = AtomicBool::new(false);
 
fn initialize() {
    INIT.call_once(|| {
        println!("Running one-time initialization...");
        // Do initialization work
        std::thread::sleep(std::time::Duration::from_millis(50));
        IS_INITIALIZED.store(true, Ordering::SeqCst);
        println!("Initialization complete!");
    });
}
 
fn is_ready() -> bool {
    IS_INITIALIZED.load(Ordering::SeqCst)
}
 
fn main() {
    println!("Ready? {}", is_ready());
    
    initialize();
    
    println!("Ready? {}", is_ready());
    
    // Calling again does nothing
    initialize();
    
    println!("Ready? {}", is_ready());
}

Using once_cell Crate (for Older Rust)

// For Rust versions before 1.70, use the once_cell crate
// Add to Cargo.toml:
// [dependencies]
// once_cell = "1.18"
 
// The once_cell crate provides the same functionality:
// use once_cell::sync::Lazy;
// use once_cell::sync::OnceCell;
 
// Example with the standard library equivalents (Rust 1.70+):
use std::sync::OnceLock;
 
fn main() {
    // Equivalent to once_cell::sync::OnceCell
    static CELL: OnceLock<String> = OnceLock::new();
    
    CELL.set("Hello".to_string()).ok();
    println!("Cell value: {:?}", CELL.get());
    
    // For older Rust, the once_cell crate provides:
    // once_cell::sync::OnceCell (same as std::sync::OnceLock)
    // once_cell::sync::Lazy (same as std::sync::LazyLock)
    // once_cell::unsync::OnceCell (same as std::cell::OnceCell)
}

Summary

Type Thread Safe Use Case
Once Yes Run code exactly once, no value needed
OnceLock<T> Yes Store value, explicit initialization
LazyLock<T> Yes Store value, implicit initialization
OnceCell<T> No Single-threaded lazy storage

Key Methods:

Method Description
Once::call_once(&self, f) Run closure exactly once
OnceLock::get(&self) Get reference if initialized
OnceLock::get_or_init(&self, f) Get or initialize with closure
OnceLock::get_or_try_init(&self, f) Get or initialize with Result
OnceLock::set(&self, value) Set value, returns Err if already set
LazyLock::new(f) Create with initialization closure

When to Use Each:

Scenario Recommended Type
One-time setup without value Once
Lazy static value OnceLock or LazyLock
Singleton pattern OnceLock
Single-threaded cache OnceCell
Fallible initialization OnceLock with get_or_try_init

Key Points:

  • Use OnceLock for thread-safe one-time initialization (Rust 1.70+)
  • Use LazyLock for convenient static initialization (Rust 1.80+)
  • Use OnceCell for single-threaded scenarios
  • All types ensure initialization happens exactly once
  • For older Rust, the once_cell crate provides the same functionality