How does once_cell::sync::Lazy::get_or_try_init enable fallible lazy initialization with custom error handling?

once_cell::sync::Lazy::get_or_try_init provides a way to lazily initialize a value when the initialization process might fail, allowing you to handle initialization errors gracefully rather than panicking. Unlike the standard Lazy::force() or Deref implementations that expect infallible initialization, get_or_try_init returns a Result that propagates initialization errors to the caller. This is essential for scenarios where initialization involves operations that can fail—like parsing configuration files, establishing database connections, or performing I/O—and where you want to retry initialization or take alternative action on failure rather than crashing the application. The method ensures thread-safe, race-free initialization where the initialization closure runs exactly once, even under concurrent access attempts.

Basic Lazy Initialization

use once_cell::sync::Lazy;
 
// Standard Lazy with infallible initialization
static CONFIG: Lazy<String> = Lazy::new(|| {
    // This cannot fail - must always succeed
    String::from("default configuration")
});
 
fn main() {
    // Access via Deref
    println!("Config: {}", *CONFIG);
    
    // Or via force() - same effect
    println!("Config: {}", CONFIG.force());
}

Standard Lazy expects initialization that always succeeds, accessed via Deref or force().

The Problem with Fallible Initialization

use once_cell::sync::Lazy;
use std::fs;
 
// Problem: How to handle initialization that can fail?
static CONFIG: Lazy<String> = Lazy::new(|| {
    // This can fail! But Lazy::new() requires fn() -> T
    // We can't return Result from here
    fs::read_to_string("config.txt")
        .expect("Failed to read config - will panic!")
});
 
fn main() {
    // If config.txt doesn't exist, this panics
    // There's no way to handle the error gracefully
    println!("Config: {}", *CONFIG);
}

Standard Lazy cannot handle initialization failures gracefully—it panics on error.

Using get_or_try_init

use once_cell::sync::Lazy;
use std::fs;
 
// Lazy that can fail during initialization
static CONFIG: Lazy<String> = Lazy::new(|| {
    // This closure returns String, but initialization might fail
    // We use get_or_try_init to handle this
    fs::read_to_string("config.txt")
        .expect("Default initialization")
});
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // get_or_try_init allows fallible initialization
    // The initialization closure returns Result<T, E>
    
    // Use a Lazy<Result<T, E>> pattern for fallible init
    static FALLIBLE_CONFIG: Lazy<Result<String, std::io::Error>> = Lazy::new(|| {
        fs::read_to_string("config.txt")
    });
    
    // Access with error handling
    match FALLIBLE_CONFIG.force().as_ref() {
        Ok(config) => println!("Config loaded: {}", config),
        Err(e) => {
            println!("Failed to load config: {}", e);
            // Handle the error gracefully
            println!("Using default configuration");
        }
    }
    
    // Or use get_or_try_init with Lazy<OnceCell<T>>
    use once_cell::sync::OnceCell;
    
    static LAZY_CONFIG: OnceCell<String> = OnceCell::new();
    
    let config = LAZY_CONFIG.get_or_try_init(|| {
        fs::read_to_string("config.txt")
    })?;
    
    println!("Config: {}", config);
    
    Ok(())
}

OnceCell::get_or_try_init is the primary way to handle fallible initialization.

Lazy with Result Type

use once_cell::sync::Lazy;
use std::fs;
 
// Common pattern: Lazy<Result<T, E>>
static DATABASE_URL: Lazy<Result<String, std::io::Error>> = Lazy::new(|| {
    fs::read_to_string("database_url.txt")
});
 
static PARSED_CONFIG: Lazy<Result<Config, ConfigError>> = Lazy::new(|| {
    let content = fs::read_to_string("config.toml")?;
    parse_config(&content)
});
 
#[derive(Debug)]
struct Config {
    port: u16,
    host: String,
}
 
#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(String),
}
 
impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self {
        ConfigError::Io(e)
    }
}
 
fn parse_config(content: &str) -> Result<Config, ConfigError> {
    // Simplified parsing
    let port = content.lines()
        .find(|l| l.starts_with("port="))
        .and_then(|l| l.strip_prefix("port="))
        .and_then(|v| v.parse().ok())
        .unwrap_or(8080);
    
    let host = content.lines()
        .find(|l| l.starts_with("host="))
        .and_then(|l| l.strip_prefix("host="))
        .map(String::from)
        .unwrap_or_else(|| "localhost".to_string());
    
    Ok(Config { port, host })
}
 
fn main() {
    // Check if initialization succeeded
    match PARSED_CONFIG.force() {
        Ok(config) => {
            println!("Config: {}:{}", config.host, config.port);
        }
        Err(e) => {
            eprintln!("Failed to load config: {:?}", e);
            // Use defaults or exit
        }
    }
}

A common pattern wraps the result in Result<T, E> to capture initialization errors.

OnceCell for Explicit Fallible Initialization

use once_cell::sync::OnceCell;
use std::fs;
 
// OnceCell with get_or_try_init is cleaner for fallible cases
static CONFIG: OnceCell<String> = OnceCell::new();
 
fn get_config() -> Result<&'static str, std::io::Error> {
    // get_or_try_init runs the closure only once
    // Subsequent calls return the cached value
    CONFIG.get_or_try_init(|| {
        println!("Initializing config...");
        fs::read_to_string("config.txt")
    })
    .map(|s| s.as_str())
}
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // First call - runs initialization
    let config = get_config()?;
    println!("Config: {}", config);
    
    // Subsequent calls - return cached value
    let config2 = get_config()?;
    println!("Config again: {}", config2);
    
    Ok(())
}

OnceCell::get_or_try_init is designed for fallible lazy initialization.

Thread Safety Guarantees

use once_cell::sync::OnceCell;
use std::sync::Arc;
use std::thread;
 
static RESOURCE: OnceCell<String> = OnceCell::new();
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize with multiple threads racing
    let mut handles = vec![];
    
    for i in 0..5 {
        let handle = thread::spawn(move || {
            let result = RESOURCE.get_or_try_init(|| {
                println!("Thread {} initializing...", i);
                thread::sleep(std::time::Duration::from_millis(100));
                
                // Only one thread runs this
                if i == 3 {
                    // Simulate initialization failure
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::Other,
                        "Simulated failure"
                    ));
                }
                
                Ok(format!("Initialized by thread {}", i))
            });
            
            (i, result.is_ok(), result.ok().map(String::as_str))
        });
        handles.push(handle);
    }
    
    for handle in handles {
        let (thread_id, success, value) = handle.join().unwrap();
        println!("Thread {}: success={}, value={:?}", thread_id, success, value);
    }
    
    Ok(())
}

get_or_try_init ensures the closure runs only once, even with concurrent access.

Error Handling Strategies

use once_cell::sync::OnceCell;
use std::fs;
 
static CONFIG: OnceCell<String> = OnceCell::new();
 
fn get_config_or_default() -> &'static str {
    // Strategy 1: Default on error
    CONFIG.get_or_try_init(|| {
        fs::read_to_string("config.txt")
    })
    .unwrap_or_else(|_| {
        // Return a static default
        CONFIG.get_or_init(|| "default config".to_string())
    })
    .as_str()
}
 
static DATABASE: OnceCell<Database> = OnceCell::new();
 
struct Database {
    url: String,
}
 
impl Database {
    fn connect(url: &str) -> Result<Self, String> {
        // Simulated connection
        if url.is_empty() {
            Err("Empty URL".to_string())
        } else {
            Ok(Database { url: url.to_string() })
        }
    }
}
 
fn get_database() -> Result<&'static Database, String> {
    // Strategy 2: Propagate error to caller
    DATABASE.get_or_try_init(|| {
        let url = std::env::var("DATABASE_URL")
            .unwrap_or_else(|_| "localhost:5432".to_string());
        Database::connect(&url)
    })
}
 
fn main() {
    // Default on error
    let config = get_config_or_default();
    println!("Config: {}", config);
    
    // Propagate error
    match get_database() {
        Ok(db) => println!("Connected to: {}", db.url),
        Err(e) => eprintln!("Failed to connect: {}", e),
    }
}

Different strategies for handling initialization errors depending on use case.

Retry After Failure

use once_cell::sync::OnceCell;
use std::sync::Mutex;
use std::fs;
 
// For retry capability, use a different pattern
struct RetryableInit<T> {
    cell: OnceCell<T>,
    init_failed: Mutex<bool>,
}
 
impl<T> RetryableInit<T> {
    const fn new() -> Self {
        Self {
            cell: OnceCell::new(),
            init_failed: Mutex::new(false),
        }
    }
    
    fn get_or_try_init<F, E>(&self, f: F) -> Result<&T, E>
    where
        F: FnOnce() -> Result<T, E>,
    {
        // Check if we already have a value
        if let Some(val) = self.cell.get() {
            return Ok(val);
        }
        
        // Check if previous attempt failed
        {
            let failed = self.init_failed.lock().unwrap();
            if *failed {
                // Allow retry by proceeding
            }
        }
        
        let result = self.cell.get_or_try_init(f);
        
        if result.is_err() {
            *self.init_failed.lock().unwrap() = true;
            // Note: OnceCell doesn't support retry by default
            // This is a simplified pattern
        }
        
        result
    }
}
 
// Simpler approach: Use Lazy<Result<T, E>> and check
static FALLIBLE_RESOURCE: once_cell::sync::Lazy<Result<String, std::io::Error>> = 
    once_cell::sync::Lazy::new(|| {
        fs::read_to_string("resource.txt")
    });
 
fn main() {
    // Check result each time (initialization happens once)
    match FALLIBLE_RESOURCE.force() {
        Ok(resource) => println!("Resource: {}", resource),
        Err(e) => {
            eprintln!("Failed: {}", e);
            // Can't retry - Lazy already initialized
            // Would need to use a different pattern for retries
        }
    }
}

OnceCell doesn't natively support retry after failure; alternative patterns are needed.

Comparing Lazy and OnceCell Approaches

use once_cell::sync::{Lazy, OnceCell};
 
// Approach 1: Lazy<Result<T, E>>
// - Initialization happens on first access
// - Error is cached (can't retry)
// - Access via .force() or Deref
 
static LAZY_CONFIG: Lazy<Result<String, std::io::Error>> = Lazy::new(|| {
    std::fs::read_to_string("config.txt")
});
 
// Approach 2: OnceCell<T> with get_or_try_init
// - More explicit control
// - Clearer error handling
// - Same caching behavior
 
static ONCE_CONFIG: OnceCell<String> = OnceCell::new();
 
fn get_config() -> Result<&'static str, std::io::Error> {
    ONCE_CONFIG.get_or_try_init(|| {
        std::fs::read_to_string("config.txt")
    }).map(|s| s.as_str())
}
 
// Approach 3: OnceCell<Result<T, E>>
// - Can check if initialization was attempted
// - Error is still cached
 
static ONCE_RESULT: OnceCell<Result<String, std::io::Error>> = OnceCell::new();
 
fn get_config_result() -> &'static Result<String, std::io::Error> {
    ONCE_RESULT.get_or_init(|| {
        std::fs::read_to_string("config.txt")
    })
}
 
fn main() {
    // Lazy<Result> approach
    match LAZY_CONFIG.force() {
        Ok(config) => println!("Lazy config: {}", config),
        Err(e) => eprintln!("Lazy error: {}", e),
    }
    
    // OnceCell with get_or_try_init approach
    match get_config() {
        Ok(config) => println!("OnceCell config: {}", config),
        Err(e) => eprintln!("OnceCell error: {}", e),
    }
    
    // OnceCell<Result> approach
    match get_config_result() {
        Ok(config) => println!("OnceCell result: {}", config),
        Err(e) => eprintln!("OnceCell result error: {}", e),
    }
}

Different patterns for different error handling needs.

Practical Example: Database Connection

use once_cell::sync::OnceCell;
use std::time::Duration;
 
struct ConnectionPool {
    url: String,
    // Simplified - real pool would have actual connections
}
 
impl ConnectionPool {
    fn connect(url: &str, timeout: Duration) -> Result<Self, String> {
        // Simulate connection that might fail
        if url.is_empty() {
            return Err("Empty connection URL".to_string());
        }
        
        if url.contains("unreachable") {
            return Err("Host unreachable".to_string());
        }
        
        // Simulate connection delay
        std::thread::sleep(Duration::from_millis(10));
        
        Ok(ConnectionPool { url: url.to_string() })
    }
    
    fn query(&self, sql: &str) -> String {
        format!("Result from {}: {}", self.url, sql)
    }
}
 
static DB_POOL: OnceCell<ConnectionPool> = OnceCell::new();
 
fn get_pool() -> Result<&'static ConnectionPool, String> {
    DB_POOL.get_or_try_init(|| {
        let url = std::env::var("DATABASE_URL")
            .unwrap_or_else(|_| "localhost:5432/mydb".to_string());
        
        let timeout = Duration::from_secs(5);
        
        ConnectionPool::connect(&url, timeout)
    })
}
 
fn query_database(sql: &str) -> Result<String, String> {
    let pool = get_pool()?;
    Ok(pool.query(sql))
}
 
fn main() {
    match query_database("SELECT * FROM users") {
        Ok(result) => println!("Query result: {}", result),
        Err(e) => {
            eprintln!("Database error: {}", e);
            // Application can continue without database
            // Or exit, or use fallback
        }
    }
}

Fallible initialization is essential for resources that might not be available at startup.

Practical Example: Configuration Parsing

use once_cell::sync::OnceCell;
use std::collections::HashMap;
 
#[derive(Debug)]
struct AppConfig {
    database_url: String,
    api_keys: HashMap<String, String>,
    max_connections: usize,
}
 
#[derive(Debug)]
enum ConfigError {
    IoError(std::io::Error),
    ParseError(String),
}
 
impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self {
        ConfigError::IoError(e)
    }
}
 
impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ConfigError::IoError(e) => write!(f, "IO error: {}", e),
            ConfigError::ParseError(s) => write!(f, "Parse error: {}", s),
        }
    }
}
 
impl std::error::Error for ConfigError {}
 
fn parse_config(content: &str) -> Result<AppConfig, ConfigError> {
    let mut config = AppConfig {
        database_url: String::new(),
        api_keys: HashMap::new(),
        max_connections: 10,
    };
    
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        
        let parts: Vec<&str> = line.splitn(2, '=').collect();
        if parts.len() != 2 {
            return Err(ConfigError::ParseError(format!("Invalid line: {}", line)));
        }
        
        match parts[0] {
            "database_url" => config.database_url = parts[1].to_string(),
            "max_connections" => {
                config.max_connections = parts[1].parse()
                    .map_err(|_| ConfigError::ParseError("Invalid max_connections".to_string()))?;
            }
            key if key.starts_with("api_key_") => {
                let name = key.strip_prefix("api_key_").unwrap();
                config.api_keys.insert(name.to_string(), parts[1].to_string());
            }
            _ => {} // Ignore unknown keys
        }
    }
    
    if config.database_url.is_empty() {
        return Err(ConfigError::ParseError("Missing database_url".to_string()));
    }
    
    Ok(config)
}
 
static CONFIG: OnceCell<AppConfig> = OnceCell::new();
 
fn get_config() -> Result<&'static AppConfig, ConfigError> {
    CONFIG.get_or_try_init(|| {
        let content = std::fs::read_to_string("app.conf")
            .map_err(ConfigError::from)?;
        parse_config(&content)
    })
}
 
fn main() {
    match get_config() {
        Ok(config) => {
            println!("Database: {}", config.database_url);
            println!("Max connections: {}", config.max_connections);
        }
        Err(e) => {
            eprintln!("Failed to load config: {}", e);
            std::process::exit(1);
        }
    }
}

Complex initialization with multiple failure modes benefits from get_or_try_init.

Synthesis

When to use get_or_try_init:

Scenario Reason
File I/O Files might not exist or be readable
Network connections Remote resources might be unavailable
Configuration parsing Config might be malformed
Resource acquisition External resources might fail
Validation-required init Input might be invalid

Lazy vs OnceCell for fallible init:

Pattern Access Error Handling Retry
Lazy<Result<T, E>> force() or deref Check Result No
OnceCell::get_or_try_init Method call Propagates No
Custom wrapper Custom Custom Possible

Key characteristics:

Property Description
Thread-safe Safe for concurrent access
Runs once Initialization closure executes exactly once
Caches result Success or failure is cached
No retry Once failed, stays failed (in OnceCell)
Error propagation Returns Result<&T, E> to caller

Key insight: get_or_try_init bridges the gap between lazy initialization's convenience and fallible operations' reality. Pure lazy initialization assumes success, which works for in-memory computations but breaks down for I/O, parsing, or any operation that can fail. By returning a Result, get_or_try_init lets callers decide how to handle initialization failures—whether to use defaults, exit gracefully, or attempt alternative initialization paths. The thread-safety guarantee ensures that even with multiple threads racing to initialize, the closure runs exactly once and all threads receive the same result. The main limitation to understand is that once initialization fails, the OnceCell caches that failure—subsequent calls will return the same error without retrying. If retry is needed, a different pattern (like storing an Option in a Mutex or using a custom wrapper) becomes necessary.