What are the trade-offs between once_cell::sync::OnceCell and std::sync::OnceLock for one-time initialization?

once_cell::sync::OnceCell and std::sync::OnceLock provide thread-safe one-time initialization, but differ in API surface, feature set, and ecosystem integration. OnceLock is the standard library's stabilized subset of OnceCell functionality—available in Rust 1.70+ without external dependencies. OnceCell from the once_cell crate offers additional methods like get_mut, get_unchecked, take, and into_inner that enable mutable access and value extraction. The trade-off is between dependency-free standard library availability (OnceLock) versus richer API and backward compatibility (OnceCell).

What is One-Time Initialization?

// Problem: Initialize a value exactly once, thread-safely
// Multiple threads may attempt initialization
// Only one should succeed; others should see the initialized value
 
// Without synchronization (BUGGY):
static mut VALUE: Option<String> = None;
 
fn get_value() -> &'static str {
    unsafe {
        if VALUE.is_none() {
            VALUE = Some("initialized".to_string()); // DATA RACE!
        }
        VALUE.as_ref().unwrap()
    }
}
 
// With OnceLock/OnceCell (CORRECT):
use std::sync::OnceLock;
 
static VALUE: OnceLock<String> = OnceLock::new();
 
fn get_value() -> &'static str {
    VALUE.get_or_init(|| "initialized".to_string())
}

One-time initialization ensures a value is computed exactly once and safely accessed from multiple threads.

Basic OnceLock Usage

use std::sync::OnceLock;
 
static CONFIG: OnceLock<Config> = OnceLock::new();
 
#[derive(Debug)]
struct Config {
    name: String,
    value: i32,
}
 
fn get_config() -> &'static Config {
    CONFIG.get_or_init(|| {
        // This closure runs exactly once
        Config {
            name: "default".to_string(),
            value: 42,
        }
    })
}
 
fn main() {
    println!("{:?}", get_config());
    println!("{:?}", get_config()); // Uses cached value
}

OnceLock::get_or_init initializes the value on first access and returns a reference.

Basic OnceCell Usage

use once_cell::sync::OnceCell;
 
static CONFIG: OnceCell<Config> = OnceCell::new();
 
#[derive(Debug)]
struct Config {
    name: String,
    value: i32,
}
 
fn get_config() -> &'static Config {
    CONFIG.get_or_init(|| {
        Config {
            name: "default".to_string(),
            value: 42,
        }
    })
}
 
fn main() {
    println!("{:?}", get_config());
}

OnceCell::get_or_init works identically to OnceLock for basic initialization.

Core API Comparison

use std::sync::OnceLock;
use once_cell::sync::OnceCell;
 
// Both provide:
// - new() -> Self
// - get(&self) -> Option<&T>
// - set(&self, value: T) -> Result<(), T>
// - get_or_init<F: FnOnce() -> T>(&self, f: F) -> &T
// - get_or_try_init<F: FnOnce() -> Result<T, E>>(&self, f: F) -> Result<&T, E>
 
fn core_api_comparison() {
    // Initialization
    let lock: OnceLock<String> = OnceLock::new();
    let cell: OnceCell<String> = OnceCell::new();
    
    // Get value (returns None if uninitialized)
    assert!(lock.get().is_none());
    assert!(cell.get().is_none());
    
    // Set value (returns Err if already set)
    assert!(lock.set("value".to_string()).is_ok());
    assert!(cell.set("value".to_string()).is_ok());
    
    // Get initialized value
    assert!(lock.get().is_some());
    assert!(cell.get().is_some());
    
    // get_or_init
    let lock2: OnceLock<i32> = OnceLock::new();
    let cell2: OnceCell<i32> = OnceCell::new();
    
    assert_eq!(*lock2.get_or_init(|| 42), 42);
    assert_eq!(*cell2.get_or_init(|| 42), 42);
}

Both types share the same core API for getting and setting values.

OnceCell-Exclusive Methods

use once_cell::sync::OnceCell;
 
fn oncecell_exclusive_methods() {
    let cell: OnceCell<String> = OnceCell::new();
    cell.set("value".to_string()).unwrap();
    
    // get_mut: Get mutable reference (requires exclusive access)
    if let Some(value) = cell.get_mut() {
        value.push_str("_modified");
    }
    
    // take: Remove and return the value
    let value = cell.take();
    assert_eq!(value, Some("value_modified".to_string()));
    assert!(cell.get().is_none()); // Now empty
    
    // into_inner: Consume and return the value
    cell.set("new_value".to_string()).unwrap();
    let inner = cell.into_inner();
    assert_eq!(inner, Some("new_value".to_string()));
}

OnceCell provides get_mut, take, and into_inner for mutable access and value extraction.

get_mut: Mutable Access

use once_cell::sync::OnceCell;
 
fn get_mut_example() {
    let mut cell: OnceCell<Vec<i32>> = OnceCell::new();
    cell.set(vec![1, 2, 3]).unwrap();
    
    // Requires &mut self, so exclusive access is guaranteed
    if let Some(vec) = cell.get_mut() {
        vec.push(4);
        vec.push(5);
    }
    
    assert_eq!(cell.get(), Some(&vec![1, 2, 3, 4, 5]));
    
    // OnceLock does NOT have get_mut
    // Standard library chose minimal API
}

get_mut allows modifying the value with exclusive access—OnceLock doesn't support this.

take: Value Extraction

use once_cell::sync::OnceCell;
 
fn take_example() {
    let cell: OnceCell<String> = OnceCell::new();
    cell.set("value".to_string()).unwrap();
    
    // Remove and return the value
    let value = cell.take();
    assert_eq!(value, Some("value".to_string()));
    
    // Cell is now empty
    assert!(cell.get().is_none());
    
    // Can set again
    cell.set("new_value".to_string()).unwrap();
    assert_eq!(cell.get(), Some(&"new_value".to_string()));
}

take removes and returns the value, resetting the cell—useful for re-initialization.

into_inner: Consume and Extract

use once_cell::sync::OnceCell;
 
fn into_inner_example() {
    let cell: OnceCell<String> = OnceCell::new();
    cell.set("value".to_string()).unwrap();
    
    // Consume the cell and get the inner value
    let inner = cell.into_inner();
    assert_eq!(inner, Some("value".to_string()));
    
    // cell is now consumed and inaccessible
    
    // With uninitialized cell:
    let empty: OnceCell<String> = OnceCell::new();
    assert_eq!(empty.into_inner(), None);
}

into_inner consumes the cell and returns its contents—useful for cleanup.

OnceLock Standard Library Integration

use std::sync::OnceLock;
 
fn standard_library_benefits() {
    // No external dependency required
    // Available in Rust 1.70+
    
    // Consistent with std types
    static GLOBAL: OnceLock<String> = OnceLock::new();
    
    // Works with all std synchronization primitives
    use std::sync::Mutex;
    static COMPLEX: OnceLock<Mutex<Vec<i32>>> = OnceLock::new();
    
    let guard = COMPLEX.get_or_init(|| Mutex::new(vec![1, 2, 3])).lock().unwrap();
    println!("{:?}", *guard);
}

OnceLock is built into the standard library—no dependency needed.

Version Compatibility

// once_cell::sync::OnceCell
// - Works on older Rust versions (1.36+ for sync module)
// - Requires external dependency
// - Richer API (get_mut, take, into_inner)
// - Unstable features behind feature flags
 
// std::sync::OnceLock
// - Requires Rust 1.70+
// - No external dependency
// - Minimal API (core functionality only)
// - Always stable, no feature flags
 
// Migration path:
// - New projects: use OnceLock for simpler cases
// - Need get_mut/take/into_inner: use OnceCell
// - Support older Rust: use OnceCell

Choose based on Rust version requirements and API needs.

Static vs Instance Usage

use std::sync::OnceLock;
use once_cell::sync::OnceCell;
 
// Static usage (both work identically)
static STATIC_LOCK: OnceLock<String> = OnceLock::new();
static STATIC_CELL: OnceCell<String> = OnceCell::new();
 
fn static_usage() {
    STATIC_LOCK.get_or_init(|| "lock".to_string());
    STATIC_CELL.get_or_init(|| "cell".to_string());
}
 
// Instance usage
struct Config {
    name: OnceLock<String>,
    value: OnceLock<i32>,
}
 
impl Config {
    fn new() -> Self {
        Self {
            name: OnceLock::new(),
            value: OnceLock::new(),
        }
    }
    
    fn get_name(&self) -> &str {
        self.name.get_or_init(|| "default".to_string())
    }
}
 
// OnceCell allows reconfiguration via take
struct ReconfigurableConfig {
    name: OnceCell<String>,
}
 
impl ReconfigurableConfig {
    fn reconfigure(&mut self, new_name: String) {
        // OnceCell: can take and set again
        self.name.take();
        self.name.set(new_name).ok();
    }
}

Both work for static and instance usage; OnceCell supports reconfiguration patterns.

Thread Safety

use std::sync::OnceLock;
use std::thread;
 
fn thread_safety() {
    static VALUE: OnceLock<i32> = OnceLock::new();
    
    let handles: Vec<_> = (0..10)
        .map(|i| {
            thread::spawn(move || {
                // All threads see the same value
                // Only one initialization happens
                let value = VALUE.get_or_init(|| {
                    println!("Thread {} initializing", i);
                    i // Only one thread's value wins
                });
                *value
            })
        })
        .collect();
    
    let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    
    // All results are the same (whichever thread won the race)
    assert!(results.iter().all(|&r| r == results[0]));
}

Both OnceLock and OnceCell use atomic operations for thread-safe initialization.

Performance Characteristics

use std::sync::OnceLock;
use once_cell::sync::OnceCell;
 
// Both use similar internal implementation:
// - Atomic flag for initialization state
// - Spin lock or blocking during initialization
// - No overhead after initialization (direct pointer access)
 
// The implementations are nearly identical in performance
// Differences are in API surface, not runtime behavior
 
// Memory layout comparison:
// OnceLock<T>: stores T directly + state flag
// OnceCell<T>: stores Option<T> + state flag
 
fn performance_notes() {
    // After initialization, get() is a simple atomic load
    // No synchronization overhead for reads
    
    // During initialization, threads may spin or block
    // Implementation depends on std version and once_cell version
    
    // Both are optimized for the "already initialized" case
    // Initialization overhead is amortized across all accesses
}

Both types have near-identical performance; differences are in API, not implementation.

Lazy Static Pattern

use std::sync::OnceLock;
 
// Pattern for lazy initialization
struct Database {
    connection: String,
}
 
impl Database {
    fn connect() -> Self {
        println!("Connecting...");
        Self {
            connection: "connected".to_string(),
        }
    }
}
 
fn lazy_pattern() {
    static DB: OnceLock<Database> = OnceLock::new();
    
    // Database::connect() only called on first access
    let db = DB.get_or_init(|| Database::connect());
    println!("{}", db.connection);
}
 
// Compare with lazy_static! macro (external crate)
// lazy_static! creates similar behavior with macro syntax
// OnceLock/OnceCell use explicit initialization

Both types support the lazy initialization pattern common in Rust applications.

Error Handling During Initialization

use std::sync::OnceLock;
use std::sync::Mutex;
 
// get_or_try_init for fallible initialization
fn fallible_init() -> Result<&'static String, &'static str> {
    static VALUE: OnceLock<Result<String, String>> = OnceLock::new();
    
    let result = VALUE.get_or_init(|| {
        // Simulate fallible initialization
        let config = std::env::var("CONFIG_PATH");
        match config {
            Ok(path) => Ok(format!("Loaded from {}", path)),
            Err(_) => Err("CONFIG_PATH not set".to_string()),
        }
    });
    
    // Can't easily retry with different result
    // OnceLock stores whatever was first computed
    result.as_ref().map_err(|e| e.as_str())
}
 
// Better pattern for fallible initialization:
struct LazyResult<T, E> {
    result: OnceLock<Result<T, E>>,
}
 
impl<T, E> LazyResult<T, E> {
    fn get_or_try_init<F>(&self, f: F) -> Result<&T, &E>
    where
        F: FnOnce() -> Result<T, E>,
    {
        match self.result.get() {
            Some(Ok(value)) => Ok(value),
            Some(Err(error)) => Err(error),
            None => {
                let result = f();
                self.result.set(result).ok();
                self.get_or_try_init(|| panic!("already set"))
            }
        }
    }
}

Both support get_or_try_init for fallible initialization.

Unsynchronized Variants

// Both crates also offer unsynchronized variants:
 
use once_cell::unsync::OnceCell as UnsyncOnceCell;
// std::sync::OnceLock has no unsync variant in std
 
fn unsync_example() {
    let cell: UnsyncOnceCell<i32> = UnsyncOnceCell::new();
    cell.set(42).unwrap();
    assert_eq!(cell.get(), Some(&42));
    
    // Unsynchronized variant is not thread-safe
    // Use only in single-threaded contexts
    // Lower overhead than sync version
}
 
// std doesn't provide an unsync OnceLock
// Use once_cell::unsync::OnceCell for that case

once_cell provides an unsynchronized variant; std does not.

Comparison Table

Feature OnceLock (std) OnceCell (once_cell)
new() ✓ ✓
get() ✓ ✓
set() ✓ ✓
get_or_init() ✓ ✓
get_or_try_init() ✓ ✓
get_mut() ✗ ✓
take() ✗ ✓
into_inner() ✗ ✓
get_unchecked() ✗ ✓ (unsafe)
Unsynchronized variant ✗ ✓ (unsync::OnceCell)
External dependency ✗ ✓
Minimum Rust version 1.70+ 1.36+ (sync), 1.31+ (unsync)

Migration Examples

// Migration from once_cell to std::sync::OnceLock
 
// Before (once_cell):
use once_cell::sync::OnceCell;
static CACHE: OnceCell<String> = OnceCell::new();
fn get_cache() -> &'static String {
    CACHE.get_or_init(|| "cached".to_string())
}
 
// After (std):
use std::sync::OnceLock;
static CACHE: OnceLock<String> = OnceLock::new();
fn get_cache() -> &'static String {
    CACHE.get_or_init(|| "cached".to_string())
}
 
// But if you use OnceCell-exclusive features:
// You MUST keep using once_cell
 
fn needs_oncecell() {
    use once_cell::sync::OnceCell;
    let mut cell: OnceCell<String> = OnceCell::new();
    cell.set("value".to_string()).unwrap();
    
    // OnceLock doesn't have get_mut
    cell.get_mut().map(|s| s.push_str("_modified"));
    
    // OnceLock doesn't have take
    let value = cell.take();
    println!("{:?}", value);
    
    // Must stay with OnceCell for these operations
}

Migration is straightforward for basic usage; OnceCell is required for advanced patterns.

Real-World Example: Singleton Configuration

use std::sync::OnceLock;
use std::env;
 
struct AppConfig {
    database_url: String,
    api_key: String,
    debug_mode: bool,
}
 
impl AppConfig {
    fn load() -> Self {
        Self {
            database_url: env::var("DATABASE_URL")
                .unwrap_or_else(|_| "localhost:5432".to_string()),
            api_key: env::var("API_KEY")
                .expect("API_KEY must be set"),
            debug_mode: env::var("DEBUG")
                .map(|v| v == "true")
                .unwrap_or(false),
        }
    }
}
 
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
 
fn config() -> &'static AppConfig {
    CONFIG.get_or_init(AppConfig::load)
}
 
fn main() {
    // Configuration loaded once on first access
    println!("DB: {}", config().database_url);
    println!("Debug: {}", config().debug_mode);
}

Singleton pattern with OnceLock ensures configuration loads exactly once.

Real-World Example: Plugin Registry

use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::Mutex;
 
type PluginFactory = fn() -> Box<dyn Plugin>;
 
trait Plugin {
    fn name(&self) -> &str;
    fn execute(&self);
}
 
static PLUGINS: OnceCell<Mutex<HashMap<String, PluginFactory>>> = OnceCell::new();
 
fn register_plugin(name: &str, factory: PluginFactory) {
    let plugins = PLUGINS.get_or_init(|| Mutex::new(HashMap::new()));
    plugins.lock().unwrap().insert(name.to_string(), factory);
}
 
fn create_plugin(name: &str) -> Option<Box<dyn Plugin>> {
    let plugins = PLUGINS.get_or_init(|| Mutex::new(HashMap::new()));
    plugins.lock().unwrap().get(name).map(|f| f())
}
 
// Plugins can be registered at startup
// Registry is initialized on first registration or use

OnceCell enables lazy initialization of mutable registries.

Real-World Example: Cache with Reconfiguration

use once_cell::sync::OnceCell;
use std::time::Instant;
 
struct Cache<T> {
    value: T,
    timestamp: Instant,
    ttl_secs: u64,
}
 
impl<T: Clone> Cache<T> {
    fn new(value: T, ttl_secs: u64) -> Self {
        Self {
            value,
            timestamp: Instant::now(),
            ttl_secs,
        }
    }
    
    fn is_valid(&self) -> bool {
        Instant::now().duration_since(self.timestamp).as_secs() < self.ttl_secs
    }
}
 
struct CachedValue<T> {
    cell: OnceCell<Cache<T>>,
    compute: fn() -> T,
    ttl_secs: u64,
}
 
impl<T: Clone> CachedValue<T> {
    fn new(compute: fn() -> T, ttl_secs: u64) -> Self {
        Self {
            cell: OnceCell::new(),
            compute,
            ttl_secs,
        }
    }
    
    fn get(&self) -> T {
        // Check if cache is valid
        if let Some(cache) = self.cell.get() {
            if cache.is_valid() {
                return cache.value.clone();
            }
        }
        
        // Recompute (OnceCell doesn't allow re-setting, so we need get_mut)
        // This requires &mut self, or we use a different pattern
        self.cell.get_or_init(|| {
            Cache::new((self.compute)(), self.ttl_secs)
        }).value.clone()
    }
    
    // With get_mut, we can invalidate:
    fn invalidate(&mut self) {
        self.cell.take();
    }
}
 
// OnceLock can't support invalidate pattern without external mutex

OnceCell's take method enables cache invalidation patterns that OnceLock cannot support.

Real-World Example: Testing with Static State

use once_cell::sync::OnceCell;
use std::sync::Mutex;
 
// Global state for testing
static TEST_CONFIG: OnceCell<Mutex<String>> = OnceCell::new();
 
fn setup_test_config() {
    TEST_CONFIG.get_or_init(|| Mutex::new("default".to_string()));
}
 
fn get_test_config() -> String {
    TEST_CONFIG.get()
        .expect("Config not initialized")
        .lock()
        .unwrap()
        .clone()
}
 
// In tests, we can reset the config:
#[cfg(test)]
fn reset_test_config() {
    // Only works with OnceCell, not OnceLock
    TEST_CONFIG.take();
}
 
#[test]
fn test_something() {
    setup_test_config();
    // ... test code ...
    reset_test_config(); // Clean up for other tests
}

take enables test isolation with shared static state.

Synthesis

Decision guide:

Requirement Choose
No external dependencies OnceLock
Rust 1.70+ only OnceLock
Need get_mut OnceCell
Need take (reset) OnceCell
Need into_inner OnceCell
Support Rust < 1.70 OnceCell
Need unsynchronized variant OnceCell (unsync)
Simple lazy initialization Either (prefer OnceLock)
Static singleton Either (prefer OnceLock)
Reconfigurable static OnceCell
Test isolation OnceCell

Key insight: std::sync::OnceLock provides a dependency-free, standard library implementation for the common case of one-time initialization with immutable access. It's the right choice for most new code targeting Rust 1.70+. once_cell::sync::OnceCell extends this with methods for mutable access (get_mut), value extraction (take, into_inner), and unsafe unchecked access (get_unchecked), enabling patterns like cache invalidation, reconfiguration, and test isolation that require resetting or consuming the stored value. The trade-off is accepting an external dependency. For code that needs these features, OnceCell remains essential; for simpler lazy initialization, OnceLock is the modern, dependency-free choice. The once_cell crate also provides an unsynchronized variant (unsync::OnceCell) for single-threaded contexts, which has no standard library equivalent.