How do I work with Once Cell for Lazy Initialization in Rust?

Walkthrough

Once Cell provides cell types for lazy initialization and one-time assignment. Unlike std::cell::OnceCell (available since Rust 1.70), the once_cell crate offers additional features like thread-safe lazy static initialization without macros.

Key concepts:

  • OnceCell — Single-threaded cell for one-time assignment
  • Lazy — Cell for lazy initialization (stored, computed once)
  • sync::OnceCell — Thread-safe version of OnceCell
  • sync::Lazy — Thread-safe lazy static initialization

When to use Once Cell:

  • Global static variables with runtime initialization
  • Configuration loaded once
  • Singleton patterns
  • Memoization of expensive computations
  • Lazy-loaded resources

When NOT to use Once Cell:

  • Values that change frequently
  • Simple const initialization
  • When you need multiple assignments

Code Examples

Basic OnceCell

use once_cell::unsync::OnceCell;
 
fn main() {
    // Single-threaded once cell
    let cell = OnceCell::new();
    
    // Get returns None if not set
    assert!(cell.get().is_none());
    
    // Set the value
    cell.set("Hello".to_string()).unwrap();
    
    // Get returns Some after set
    assert_eq!(cell.get(), Some(&"Hello".to_string()));
    
    // Cannot set again
    assert!(cell.set("World".to_string()).is_err());
}

Lazy Initialization

use once_cell::unsync::Lazy;
 
fn main() {
    // Lazy initialization - closure runs on first access
    let expensive = Lazy::new(|| {
        println!("Computing...");
        42
    });
    
    println!("Before access");
    println!("Value: {}", *expensive);  // "Computing" printed here
    println!("Value: {}", *expensive);  // No recomputation
}

Thread-Safe OnceCell

use once_cell::sync::OnceCell;
use std::thread;
 
static GLOBAL: OnceCell<String> = OnceCell::new();
 
fn main() {
    let handles: Vec<_> = (0..5)
        .map(|i| {
            thread::spawn(move || {
                // All threads see the same value
                GLOBAL.get_or_init(|| format!("Thread {} was first", i))
            })
        })
        .collect();
    
    for handle in handles {
        println!("{}", handle.join().unwrap());
    }
    
    // All threads report the same value
    println!("Final: {:?}", GLOBAL.get());
}

Global Static with Lazy

use once_cell::sync::Lazy;
use std::collections::HashMap;
 
// Global configuration loaded once
static CONFIG: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
    let mut m = HashMap::new();
    m.insert("host", "localhost");
    m.insert("port", "8080");
    m.insert("debug", "true");
    m
});
 
fn main() {
    // First access initializes
    println!("Host: {}", CONFIG.get("host").unwrap());
    
    // Subsequent accesses reuse the same value
    println!("Port: {}", CONFIG.get("port").unwrap());
}

Expensive Computation

use once_cell::sync::Lazy;
 
struct Config {
    name: String,
    version: String,
}
 
impl Config {
    fn load() -> Self {
        println!("Loading config...");
        Config {
            name: "MyApp".to_string(),
            version: "1.0.0".to_string(),
        }
    }
}
 
static APP_CONFIG: Lazy<Config> = Lazy::new(Config::load);
 
fn get_config() -> &'static Config {
    &APP_CONFIG
}
 
fn main() {
    println!("Starting app");
    let config = get_config();
    println!("Name: {}", config.name);
}

Database Pool Singleton

use once_cell::sync::Lazy;
use std::sync::Arc;
 
// Simulated connection pool
struct DbPool {
    connections: Vec<String>,
}
 
impl DbPool {
    fn new() -> Self {
        println!("Creating pool...");
        DbPool {
            connections: vec!["conn1".to_string(), "conn2".to_string()],
        }
    }
    
    fn get(&self) -> Option<&String> {
        self.connections.first()
    }
}
 
static DB_POOL: Lazy<Arc<DbPool>> = Lazy::new(|| {
    Arc::new(DbPool::new())
});
 
fn main() {
    // Pool created on first use
    let conn = DB_POOL.get().unwrap();
    println!("Got connection: {}", conn);
}

Get or Init with Arguments

use once_cell::sync::OnceCell;
 
static CACHE: OnceCell<Vec<String>> = OnceCell::new();
 
fn get_or_compute(items: &[&str]) -> &'static Vec<String> {
    CACHE.get_or_init(|| {
        items.iter().map(|s| s.to_uppercase()).collect()
    })
}
 
fn main() {
    let result = get_or_compute(&["hello", "world"]);
    println!("{:?}", result);
    
    // Subsequent calls return same cached value
    let result2 = get_or_compute(&["different", "args"]);
    println!("{:?}", result2);  // Same as before
}

With Mutex for Mutable Global

use once_cell::sync::Lazy;
use std::sync::Mutex;
 
static COUNTER: Lazy<Mutex<i32>> = Lazy::new(|| Mutex::new(0));
 
fn increment() -> i32 {
    let mut guard = COUNTER.lock().unwrap();
    *guard += 1;
    *guard
}
 
fn main() {
    println!("{}", increment());  // 1
    println!("{}", increment());  // 2
    println!("{}", increment());  // 3
}

Regex Compilation

use once_cell::sync::Lazy;
use regex::Regex;
 
// Compile regex once
static HEX_REGEX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[0-9a-fA-F]+$").unwrap()
});
 
static EMAIL_REGEX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
});
 
fn is_hex(s: &str) -> bool {
    HEX_REGEX.is_match(s)
}
 
fn is_valid_email(s: &str) -> bool {
    EMAIL_REGEX.is_match(s)
}
 
fn main() {
    println!("Is hex: {}", is_hex("deadbeef"));
    println!("Is email: {}", is_valid_email("test@example.com"));
}

Lazy with Fallible Initialization

use once_cell::sync::OnceCell;
use std::fs;
 
static FILE_CONTENTS: OnceCell<Result<String, std::io::Error>> = OnceCell::new();
 
fn get_file_contents() -> &'static Result<String, std::io::Error> {
    FILE_CONTENTS.get_or_init(|| {
        fs::read_to_string("config.txt")
    })
}
 
fn main() {
    match get_file_contents() {
        Ok(contents) => println!("Contents: {}", contents),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Comparing with std::sync::OnceLock

use once_cell::sync::OnceCell;
use std::sync::OnceLock;  // Standard library equivalent (Rust 1.70+)
 
// once_cell version
static ONCE_CELL: OnceCell<i32> = OnceCell::new();
 
// std version
static ONCE_LOCK: OnceLock<i32> = OnceLock::new();
 
fn main() {
    ONCE_CELL.set(42).ok();
    ONCE_LOCK.set(42).ok();
    
    println!("once_cell: {:?}", ONCE_CELL.get());
    println!("std: {:?}", ONCE_LOCK.get());
}

Unsync vs Sync

use once_cell::unsync::{OnceCell, Lazy};  // Single-threaded
use once_cell::sync::{OnceCell as SyncOnceCell, Lazy as SyncLazy};  // Thread-safe
 
fn main() {
    // Unsync - single-threaded only
    let unsync_cell: OnceCell<i32> = OnceCell::new();
    let unsync_lazy: Lazy<i32> = Lazy::new(|| 42);
    
    // Sync - thread-safe
    let sync_cell: SyncOnceCell<i32> = SyncOnceCell::new();
    let sync_lazy: SyncLazy<i32> = SyncLazy::new(|| 42);
    
    println!("Unsync: {:?}", unsync_cell.get());
    println!("Sync: {:?}", sync_cell.get());
}

Nested Lazy Initialization

use once_cell::sync::Lazy;
use std::collections::HashMap;
 
struct Cache {
    data: HashMap<String, String>,
}
 
impl Cache {
    fn new() -> Self {
        println!("Creating cache...");
        Cache {
            data: HashMap::new(),
        }
    }
}
 
static CACHE: Lazy<Cache> = Lazy::new(Cache::new);
 
static API_ENDPOINTS: Lazy<Vec<&'static str>> = Lazy::new(|| {
    vec!["/api/users", "/api/posts", "/api/comments"]
});
 
fn main() {
    println!("Endpoints: {:?}", *API_ENDPOINTS);
}

OnceCell in Struct

use once_cell::unsync::OnceCell;
 
struct Parser {
    source: String,
    parsed: OnceCell<Vec<String>>,
}
 
impl Parser {
    fn new(source: String) -> Self {
        Parser {
            source,
            parsed: OnceCell::new(),
        }
    }
    
    fn get_parsed(&self) -> &Vec<String> {
        self.parsed.get_or_init(|| {
            self.source.split_whitespace()
                .map(|s| s.to_string())
                .collect()
        })
    }
}
 
fn main() {
    let parser = Parser::new("Hello World Rust".to_string());
    
    // Parsing happens on first call
    let parsed = parser.get_parsed();
    println!("{:?}", parsed);
    
    // Second call returns cached result
    let parsed2 = parser.get_parsed();
    println!("{:?}", parsed2);
}

Try Get and Try Init

use once_cell::sync::OnceCell;
 
static MAYBE_VALUE: OnceCell<String> = OnceCell::new();
 
fn main() {
    // try_get returns Option
    match MAYBE_VALUE.get() {
        Some(v) => println!("Already set: {}", v),
        None => println!("Not set yet"),
    }
    
    // get_mut returns Option for modification
    // Only works with unsync OnceCell
}

Initialization Order

use once_cell::sync::Lazy;
 
static FIRST: Lazy<i32> = Lazy::new(|| {
    println!("Initializing FIRST");
    1
});
 
static SECOND: Lazy<i32> = Lazy::new(|| {
    println!("Initializing SECOND");
    *FIRST + 1
});
 
fn main() {
    println!("Starting");
    println!("Second: {}", *SECOND);  // Initializes SECOND, which initializes FIRST
    println!("First: {}", *FIRST);   // Already initialized
}

Race on First Access

use once_cell::sync::OnceCell;
use std::thread;
use std::sync::Arc;
 
fn main() {
    let cell = Arc::new(OnceCell::new());
    
    let handles: Vec<_> = (0..3)
        .map(|i| {
            let cell = Arc::clone(&cell);
            thread::spawn(move || {
                let value = cell.get_or_init(|| {
                    println!("Thread {} initializing", i);
                    thread::sleep(std::time::Duration::from_millis(100));
                    format!("Initialized by {}", i)
                });
                println!("Thread {} got: {}", i, value);
            })
        })
        .collect();
    
    for handle in handles {
        handle.join().unwrap();
    }
}

With Arc for Shared State

use once_cell::sync::Lazy;
use std::sync::Arc;
use std::collections::HashSet;
 
static BLOCKLIST: Lazy<Arc<HashSet<&'static str>>> = Lazy::new(|| {
    let mut set = HashSet::new();
    set.insert("spam.com");
    set.insert("malware.net");
    Arc::new(set)
});
 
fn is_blocked(domain: &str) -> bool {
    BLOCKLIST.contains(domain)
}
 
fn main() {
    println!("Blocked: {}", is_blocked("spam.com"));
    println!("Allowed: {}", is_blocked("example.com"));
}

Clear OnceCell (Not Supported)

use once_cell::unsync::OnceCell;
 
fn main() {
    let mut cell = OnceCell::new();
    cell.set(42).unwrap();
    
    // OnceCell doesn't support clearing
    // To "reset", create a new cell:
    let cell = OnceCell::new();
    cell.set(100).unwrap();
    
    // Or use Option<T> if you need mutability
    let mut flexible: Option<i32> = None;
    flexible = Some(42);
    flexible = Some(100);  // Can change
}

Summary

Once Cell Key Imports:

// Single-threaded
use once_cell::unsync::{OnceCell, Lazy};
 
// Thread-safe
use once_cell::sync::{OnceCell, Lazy};

Main Types:

Type Thread Safety Use Case
unsync::OnceCell No One-time assignment
unsync::Lazy No Lazy initialization
sync::OnceCell Yes Thread-safe one-time assignment
sync::Lazy Yes Thread-safe lazy static

Key Methods:

// OnceCell
cell.get();                          // Option<&T>
cell.set(value);                     // Result<(), T>
cell.get_or_init(|| value);          // &T
 
// Lazy
Lazy::new(|| value);                 // Create lazy
&*lazy_value;                        // Access and initialize

Comparison with Alternatives:

Feature once_cell lazy_static std (1.70+)
No macro needed āœ… āŒ āœ…
Thread-safe āœ… āœ… āœ…
Nested in function āœ… āŒ āœ…
Sync types āœ… āœ… āœ… (OnceLock)
Async init āŒ āŒ āœ… (OnceLock)

Key Points:

  • Use sync::Lazy for global statics
  • Use sync::OnceCell for optional globals
  • Use unsync types for single-threaded code
  • Initialization happens on first access
  • Thread-safe types handle concurrent access
  • Cannot reset/clear after initialization
  • Prefer std::sync::OnceLock if Rust 1.70+
  • Great for regex, config, pools, caches