How does once_cell::sync::OnceCell differ from std::sync::OnceLock introduced in Rust 1.70?

once_cell::sync::OnceCell and std::sync::OnceLock provide identical functionality—thread-safe one-time initialization of a value—but differ in availability, API naming, and feature scope. OnceLock is the standard library's adoption of the OnceCell pattern, stabilized in Rust 1.70 after years of the once_cell crate serving this need. The once_cell crate offers additional APIs beyond what's in std, including Lazy<T> for deferred initialization with a closure, race::OnceCell for non-thread-safe scenarios, and the sync::Lazy type. For projects on Rust 1.70+, OnceLock is the idiomatic choice for standard use cases, while once_cell remains valuable for additional features or supporting older Rust versions.

Basic OnceCell Usage

// Using once_cell crate
use once_cell::sync::OnceCell;
 
static INSTANCE: OnceCell<String> = OnceCell::new();
 
fn main() {
    // Get or initialize
    let value = INSTANCE.get_or_init(|| {
        println!("Initializing...");
        String::from("Hello, World!")
    });
    
    println!("{}", value);
    
    // Subsequent calls return existing value
    let value2 = INSTANCE.get_or_init(|| {
        panic!("This won't be called");
    });
    
    println!("{}", value2);
}

OnceCell provides thread-safe lazy initialization.

Basic OnceLock Usage

// Using std (Rust 1.70+)
use std::sync::OnceLock;
 
static INSTANCE: OnceLock<String> = OnceLock::new();
 
fn main() {
    // Get or initialize
    let value = INSTANCE.get_or_init(|| {
        println!("Initializing...");
        String::from("Hello, World!")
    });
    
    println!("{}", value);
    
    // Subsequent calls return existing value
    let value2 = INSTANCE.get_or_init(|| {
        panic!("This won't be called");
    });
    
    println!("{}", value2);
}

OnceLock provides identical functionality with different naming.

API Comparison

use once_cell::sync::OnceCell;
use std::sync::OnceLock;
 
fn main() {
    // OnceCell API
    let cell: OnceCell<i32> = OnceCell::new();
    cell.get();                    // Option<&T>
    cell.get_or_init(|| 42);       // &T
    cell.get_mut();                // Option<&mut T>
    cell.get_unchecked();          // &T (unsafe)
    cell.set(42);                  // Result<(), T>
    cell.take();                   // Option<T>
    cell.into_inner();             // Option<T>
    
    // OnceLock API (same functionality, different names)
    let lock: OnceLock<i32> = OnceLock::new();
    lock.get();                    // Option<&T>
    lock.get_or_init(|| 42);       // &T
    lock.get_mut();                // Option<&mut T>
    lock.get_unchecked();          // &T (unsafe)
    lock.set(42);                  // Result<(), T>
    lock.take();                   // Option<T>
    lock.into_inner();             // Option<T>
    
    // APIs are essentially identical
}

Both provide the same core operations with equivalent semantics.

The Lazy Type Difference

// once_cell provides Lazy<T> for deferred initialization
use once_cell::sync::Lazy;
 
static CONFIG: Lazy<Vec<String>> = Lazy::new(|| {
    let mut config = Vec::new();
    config.push("setting1".to_string());
    config.push("setting2".to_string());
    config
});
 
fn main() {
    // CONFIG initializes on first access
    println!("Before access");
    println!("Config: {:?}", *CONFIG);  // Initializes here
    println!("After access");
    
    // std doesn't have Lazy<T>
    // Workaround in std:
    use std::sync::OnceLock;
    static CONFIG_STD: OnceLock<Vec<String>> = OnceLock::new();
    
    // Manual get_or_init each time
    let config = CONFIG_STD.get_or_init(|| {
        vec!["setting1".to_string(), "setting2".to_string()]
    });
}

Lazy<T> provides automatic initialization; OnceLock requires explicit get_or_init.

Lazy Initialization Pattern Comparison

use once_cell::sync::Lazy;
use std::sync::OnceLock;
 
// once_cell::Lazy - declarative, automatic
static DB_POOL: Lazy<DbPool> = Lazy::new(|| {
    DbPool::connect("localhost:5432")
});
 
// std::sync::OnceLock - explicit initialization
static DB_POOL_STD: OnceLock<DbPool> = OnceLock::new();
 
fn get_pool() -> &'static DbPool {
    DB_POOL_STD.get_or_init(|| DbPool::connect("localhost:5432"))
}
 
struct DbPool;
impl DbPool {
    fn connect(_addr: &str) -> Self { DbPool }
}
 
fn main() {
    // Lazy: access directly
    let _pool = &*DB_POOL;
    
    // OnceLock: use helper or get_or_init each time
    let _pool = get_pool();
}

Lazy is more ergonomic for static lazy values.

Unsync Variants

// once_cell provides unsync (single-threaded) variants
use once_cell::unsync::OnceCell as UnsyncOnceCell;
use once_cell::unsync::Lazy as UnsyncLazy;
 
fn main() {
    // Single-threaded OnceCell (no synchronization overhead)
    let cell: UnsyncOnceCell<String> = UnsyncOnceCell::new();
    cell.set("value".to_string()).unwrap();
    
    // Single-threaded Lazy
    let lazy: UnsyncLazy<Vec<i32>> = UnsyncLazy::new(|| {
        vec![1, 2, 3]
    });
    
    println!("{:?}", *lazy);
    
    // std::sync::OnceLock is always thread-safe
    // No unsync variant in std (use Cell<Option<T>> or similar)
}

once_cell::unsync provides single-threaded variants without atomics overhead.

TryInsert Capability

use once_cell::sync::OnceCell;
use std::sync::OnceLock;
 
fn main() {
    // Both support try_insert (nightly/unstable in std)
    let cell: OnceCell<i32> = OnceCell::new();
    
    // once_cell: try_insert is stable
    match cell.try_insert(42) {
        Ok(&value) => println!("Inserted: {}", value),
        Err((_current, attempted)) => {
            println!("Already set to {}, tried {}", _current, attempted);
        }
    }
    
    // std::sync::OnceLock::set provides similar functionality
    let lock: OnceLock<i32> = OnceLock::new();
    match lock.set(42) {
        Ok(()) => println!("Set successfully"),
        Err(_) => println!("Already set"),
    }
}

Both support checking if already initialized when setting.

Thread Safety Implementation

use std::sync::OnceLock;
use once_cell::sync::OnceCell;
use std::thread;
 
fn main() {
    // Both use similar internal synchronization
    // OnceLock uses std::sync::Once internally
    // OnceCell uses a custom lock-free implementation
    
    static CELL: OnceCell<i32> = OnceCell::new();
    static LOCK: OnceLock<i32> = OnceLock::new();
    
    let h1 = thread::spawn(|| {
        CELL.get_or_init(|| {
            println!("OnceCell initializing");
            42
        })
    });
    
    let h2 = thread::spawn(|| {
        LOCK.get_or_init(|| {
            println!("OnceLock initializing");
            42
        })
    });
    
    h1.join().unwrap();
    h2.join().unwrap();
    
    // Both guarantee:
    // 1. Initialization happens exactly once
    // 2. All threads see the initialized value
    // 3. Initialization is thread-safe
}

Both provide the same thread-safety guarantees.

Migration from OnceCell to OnceLock

// Before: using once_cell
use once_cell::sync::OnceCell;
 
static OLD_STYLE: OnceCell<String> = OnceCell::new();
 
// After: using std
use std::sync::OnceLock;
 
static NEW_STYLE: OnceLock<String> = OnceLock::new();
 
// Migration is straightforward:
// 1. Change import
// 2. Change type name
// 3. API is compatible
 
// For Lazy, you need to keep using once_cell or create wrapper
use once_cell::sync::Lazy;  // Keep this
 
static STILL_NEEDED: Lazy<String> = Lazy::new(|| "value".to_string());
 
// Or create your own Lazy wrapper around OnceLock:
struct MyLazy<T> {
    cell: OnceLock<T>,
    init: fn() -> T,
}
 
impl<T> MyLazy<T> {
    fn new(init: fn() -> T) -> Self {
        MyLazy { cell: OnceLock::new(), init }
    }
    
    fn get(&self) -> &T {
        self.cell.get_or_init(|| (self.init)())
    }
}
 
impl<T: 'static> std::ops::Deref for MyLazy<T> {
    type Target = T;
    fn deref(&self) -> &T {
        self.get()
    }
}

Migration is simple except for Lazy which requires additional work.

Performance Characteristics

use once_cell::sync::OnceCell;
use std::sync::OnceLock;
use std::time::Instant;
 
fn main() {
    // Both have similar performance characteristics
    // First access: initialization cost + synchronization
    // Subsequent accesses: fast read (atomic check)
    
    const ITERATIONS: u32 = 1_000_000;
    
    static CELL: OnceCell<i32> = OnceCell::new();
    static LOCK: OnceLock<i32> = OnceLock::new();
    
    // Initialize both
    CELL.get_or_init(|| 42);
    LOCK.get_or_init(|| 42);
    
    // Benchmark reads
    let start = Instant::now();
    for _ in 0..ITERATIONS {
        let _ = CELL.get();
    }
    let cell_time = start.elapsed();
    
    let start = Instant::now();
    for _ in 0..ITERATIONS {
        let _ = LOCK.get();
    }
    let lock_time = start.elapsed();
    
    println!("OnceCell: {:?}", cell_time);
    println!("OnceLock: {:?}", lock_time);
    // Performance is nearly identical
}

Both have minimal overhead after initialization.

When to Use Each

// Use std::sync::OnceLock when:
// 1. You're on Rust 1.70+
// 2. You only need basic one-time initialization
// 3. You want to avoid external dependencies
// 4. Standard library is preferred
 
static CONFIG: OnceLock<Config> = OnceLock::new();
 
// Use once_cell::sync::OnceCell when:
// 1. Supporting Rust < 1.70
// 2. You need Lazy<T>
// 3. You need unsync variants
// 4. You need race::OnceCell (no interior mutability)
 
// Use once_cell::sync::Lazy when:
// 1. You want declarative static initialization
// 2. You want automatic initialization on access
// 3. The initialization closure captures environment
 
static COMPLEX: Lazy<Vec<String>> = Lazy::new(|| {
    let mut v = Vec::new();
    for i in 0..100 {
        v.push(format!("item_{}", i));
    }
    v
});

Choose based on Rust version and feature needs.

The race Module

// once_cell provides race module for non-blocking variants
use once_cell::race;
 
fn main() {
    // race::OnceBox: for boxed values, no interior mutability
    static BOXED: race::OnceBox<String> = race::OnceBox::new();
    BOXED.set(Box::new("value".to_string())).unwrap();
    
    // race::OnceRef: for references
    static REF: race::OnceRef<str> = race::OnceRef::new();
    REF.set("value").unwrap();
    
    // std doesn't have these specialized variants
    // They're useful when you need:
    // - Non-blocking reads after initialization
    // - No interior mutability guarantees
    // - Specialized memory management
}

The race module provides specialized non-blocking variants.

Complete Feature Matrix

use once_cell::sync::{OnceCell, Lazy};
use once_cell::unsync::{OnceCell as UnsyncOnceCell, Lazy as UnsyncLazy};
use once_cell::race::{OnceBox, OnceRef};
use std::sync::OnceLock;
 
fn main() {
    // === std::sync::OnceLock ===
    // Thread-safe one-time initialization
    // Stable in Rust 1.70+
    // Methods: new, get, get_or_init, get_mut, get_unchecked, set, take, into_inner
    
    // === once_cell::sync::OnceCell ===
    // Identical to OnceLock
    // Available in older Rust versions
    // Same methods as OnceLock
    
    // === once_cell::sync::Lazy ===
    // Thread-safe automatic initialization
    // NO EQUIVALENT IN STD
    // Implements Deref for automatic access
    
    // === once_cell::unsync::OnceCell ===
    // Single-threaded, no atomics
    // NO EQUIVALENT IN STD
    // Use Cell<Option<T>> for similar (but different) behavior
    
    // === once_cell::unsync::Lazy ===
    // Single-threaded automatic initialization
    // NO EQUIVALENT IN STD
    
    // === once_cell::race::OnceBox ===
    // Non-blocking, for boxed values
    // NO EQUIVALENT IN STD
    
    // === once_cell::race::OnceRef ===
    // Non-blocking, for references
    // NO EQUIVALENT IN STD
}

once_cell provides more variants than std.

Synthesis

Core functionality: OnceCell and OnceLock provide identical thread-safe one-time initialization with equivalent APIs.

Availability:

  • OnceLock: Standard library, Rust 1.70+
  • OnceCell: External crate, works on older Rust versions

API differences: Essentially none—same methods, same semantics, different names.

once_cell advantages:

  • Lazy<T>: Automatic initialization on access (no explicit get_or_init)
  • unsync module: Single-threaded variants without atomics overhead
  • race module: Non-blocking variants for specialized use cases
  • Backward compatibility with older Rust versions

OnceLock advantages:

  • No external dependencies
  • Standard library guarantees stability
  • Idiomatic for new projects on Rust 1.70+
  • Simpler Cargo.toml

Migration path: For basic use, replace OnceCell with OnceLock by changing imports and type names. For Lazy, either keep using once_cell or implement your own wrapper.

Key insight: OnceLock standardizes the most common use case of OnceCell—thread-safe one-time initialization. The once_cell crate remains valuable for its additional types (Lazy, unsync variants, race module) that aren't in std. For simple lazy statics on Rust 1.70+, OnceLock is the right choice. For declarative initialization or single-threaded scenarios, once_cell provides ergonomics that std doesn't yet match.