Mastering `static` in Rust: A Comprehensive Guide
Mastering static in Rust: A Comprehensive Guide
Introduction
The static keyword in Rust defines a global variable that lives for the entire duration of the program and resides at a fixed memory location. Understanding when and how to use static is crucial for writing efficient, safe, and idiomatic Rust code.
What is static?
A static item declares a value that:
- Exists for the entire program’s lifetime (has the
'staticlifetime) - Occupies a single, fixed memory address
- Is stored in the program’s data segment
- Is initialized at compile-time or program startup
static MAX_CONNECTIONS: u32 = 100;
fn main() {
println!("Maximum connections: {}", MAX_CONNECTIONS);
}
static vs const: Critical Differences
Understanding the distinction between static and const is fundamental to making the right choice.
| Aspect | static |
const |
|---|---|---|
| Memory location | Has a single, fixed address in memory | No fixed address; inlined at each use site |
| Storage | Stored once in the binary’s data segment | Duplicated at every use location during compilation |
| Mutability | Can be mutable with static mut (unsafe) |
Always immutable; mut is not allowed |
| References | Can take references that point to the same location | Each reference may point to a different inlined copy |
| Drop behavior | Never dropped at program end | No drop semantics (no runtime existence) |
| Thread safety | Immutable statics must implement Sync |
No thread safety requirements (no shared state) |
Rule of Thumb
Always prefer const unless you specifically need a static. Use static only when you require a stable memory address, need interior mutability, or are storing large data that shouldn’t be duplicated.
When to Use static
Large Read-Only Data Structures
Use static for large lookup tables, embedded assets, or datasets where duplicating via const inlining would bloat the binary.
static PERIODIC_TABLE: [(u32, str); 118] = [
(1, "Hydrogen"),
(2, "Helium"),
// ... 116 more elements
];
fn get_element_name(atomic_number: u32) -> Option<'static str> {
PERIODIC_TABLE.iter()
.find(|(num, _)| *num == atomic_number)
.map(|(_, name)| *name)
}
Stable Memory Addresses
When interfacing with C code or other systems that require a stable pointer to data throughout the program’s lifetime.
static ERROR_MESSAGE: &str = "An error occurred";
// FFI function expecting a stable pointer
extern "C" {
fn register_error_handler(msg: *const u8);
}
fn setup() {
unsafe {
register_error_handler(ERROR_MESSAGE.as_ptr());
}
}
Global State with Interior Mutability
The safe, idiomatic way to manage mutable global state using synchronization primitives.
use std::sync::Mutex;
static GLOBAL_CONFIG: Mutex<Config> = Mutex::new(Config::new());
struct Config {
debug_mode: bool,
max_retries: u32,
}
impl Config {
const fn new() -> Self {
Config {
debug_mode: false,
max_retries: 3,
}
}
}
fn update_config(debug: bool, retries: u32) {
let mut config = GLOBAL_CONFIG.lock().unwrap();
config.debug_mode = debug;
config.max_retries = retries;
}
When NOT to Use static
Small Constants
For simple primitive values, always use const for better optimization opportunities.
// Good: Use const for simple values
const MAX_USERS: u32 = 1000;
const PI: f64 = 3.14159265359;
// Bad: Unnecessary static for simple values
static MAX_USERS_BAD: u32 = 1000; // Avoid this
Working Around Lifetime Errors
Don’t promote data to static just to satisfy the borrow checker. This usually indicates an architectural problem.
// This is actually valid, but it's a "Bad" example anyway.
// Bad: Promoting to static to avoid lifetime issues
static mut TEMP_BUFFER: Vec<u8> = Vec::new();
// Good: Pass ownership or use proper lifetimes
fn process_data(buffer: mut Vec<u8>) {
// Work with borrowed data
}
Mutable Global State Without Synchronization
Avoid static mut in modern Rust code. It bypasses Rust’s safety guarantees and is a common source of data races.
// Avoid: Unsafe mutable static
static mut COUNTER: i32 = 0;
fn increment() {
unsafe {
COUNTER += 1; // Data race if called from multiple threads
}
}
// Prefer: Safe alternative with atomic
use std::sync::atomic::{AtomicI32, Ordering};
static SAFE_COUNTER: AtomicI32 = AtomicI32::new(0);
fn safe_increment() {
SAFE_COUNTER.fetch_add(1, Ordering::SeqCst);
}
The 'static Lifetime vs static Items
These are two different concepts that often cause confusion.
static Items
A static item is a variable declaration with a fixed memory location.
static NAME: &str = "Rust"; // This is a static item
The 'static Lifetime
The 'static lifetime is one of the most misunderstood concepts in Rust. Let’s clarify the critical distinctions.
Common Misconceptions
Misconception 1: “T: 'static means T must live for the entire program”
Reality: T: 'static means “T contains no non-‘static references” - it does NOT mean T itself must exist forever.
use std::thread;
fn spawn_thread() {
// This String is created at runtime and will be dropped
let owned_string = String::from("I'm owned, not static!");
// ✅ This works! String satisfies T: 'static even though
// it's not a static item and will be dropped
thread::spawn(move || {
println!("{}", owned_string);
}).join().unwrap();
// owned_string is dropped here - it didn't live for the whole program!
}
Misconception 2: “&'static T and T: 'static are the same thing”
Reality: These are fundamentally different:
&'static T: An immutable reference that is valid for the entire program (must point to static data)T: 'static: A type bound meaning T contains no references with lifetimes shorter than'static
// 'static str - actual static reference
static STATIC_STR: 'static str = "I'm in the binary";
fn example() {
// T: 'static - owned type, satisfies the bound
let owned: String = String::from("I'm owned");
send_to_thread(owned); // ✅ String satisfies T: 'static
// Cannot do this - owned is not &'static
// let static_ref: &'static String = &owned; // ❌
}
fn send_to_thread<T: 'static>(t: T) {
std::thread::spawn(move || {
// Can use t here
});
}
What Actually Satisfies T: 'static?
All of these types satisfy T: 'static:
// ✅ Owned types (no internal references)
String
Vec<T>
Box<T>
HashMap<K, V>
i32, u64, bool, etc.
// ✅ References with 'static lifetime
'static str
'static [u8]
// ✅ Owned types containing 'static references
struct Config {
name: String, // owned
default: 'static str, // 'static reference
}
// ❌ Types with non-'static references DO NOT satisfy T: 'static
struct HasRef<'a> {
data: 'a str, // has lifetime parameter
}
Practical Example: Understanding Thread Bounds
use std::thread;
fn demonstrate_static_bound() {
// Owned data - satisfies T: 'static
let owned = String::from("owned");
thread::spawn(move || {
println!("{}", owned); // ✅
});
// Borrowed data with non-static lifetime
let local = String::from("local");
let borrowed: &str = &local;
// This would fail: &str here is NOT &'static str
// thread::spawn(move || {
// println!("{}", borrowed); // ❌ borrowed doesn't live long enough
// });
// But this works - we're moving the String, not borrowing
thread::spawn(move || {
println!("{}", local); // ✅ local is owned, satisfies T: 'static
});
}
Key Insight: T: 'static Allows Mutation and Dropping
Types bounded by 'static can be:
- Dynamically allocated at runtime
- Safely mutated
- Dropped before program ends
- Have different lifetimes at different call sites
fn drop_static_bound<T: 'static>(t: T) {
std::mem::drop(t); // ✅ Can drop T: 'static types
}
fn main() {
let mut s = String::from("mutable");
s.push_str(" and owned"); // ✅ Can mutate
drop_static_bound(s); // ✅ Can drop before program ends
// s is dropped here, way before program termination
println!("s was already dropped");
}
Memory Aid:
T: 'static= “T is bounded by'static” = “T can live at least as long as'static” = “T contains no short-lived references”&'static T= “Reference with'staticlifetime” = “This reference actually points to static data”
Const Promotion and Compiler Optimizations
The Rust compiler can automatically promote certain compile-time evaluable expressions to have 'static storage. This is called const promotion.
What Gets Promoted?
fn examples() {
// ✅ Promoted to 'static - literals are compile-time constants
let x: 'static i32 = 42;
let s: 'static str = "hello";
let b: 'static [u8] = b"bytes";
// ✅ Promoted - const expression
const MAX: i32 = 100;
let y: &'static i32 = &MAX;
// ❌ NOT promoted - runtime computation
let runtime_value = 42 + get_random_number();
// let z: &'static i32 = &runtime_value; // Error!
}
fn get_random_number() -> i32 { 42 }
Why This Matters
Const promotion explains why string literals and references to constants work seamlessly:
// This is why string literals "just work"
fn takes_static_str(s: 'static str) {
println!("{}", s);
}
fn main() {
takes_static_str("hello"); // ✅ "hello" promoted to 'static
}
Performance Implications
| Pattern | Runtime Cost | Memory |
|---|---|---|
const VALUE: i32 = 42; |
Zero - inlined everywhere | Duplicated at each use site |
static VALUE: i32 = 42; |
One-time initialization | Single memory location |
static CONFIG: LazyLock<T> |
Small sync overhead on first access | Single location, lazy |
Rule of Thumb: Use const for simple values (zero cost), static for large data or when you need a stable address.
Thread Safety and Sync
An immutable static’s type must implement the Sync trait to be safely accessible across threads.
use std::sync::Mutex;
// OK: Mutex<T> implements Sync when T: Send
static SHARED_DATA: Mutex<Vec<i32>> = Mutex::new(Vec::new());
// Error: Cell is not Sync
// static BAD: std::cell::Cell<i32> = std::cell::Cell::new(0);
Why the Sync Requirement Exists
The Sync trait indicates that a type is safe to reference from multiple threads simultaneously. Since static items have a 'static lifetime and can be accessed from any thread, they must be Sync.
// ✅ OK: i32 is Sync (safe to share across threads)
static NUM: i32 = 42;
// ✅ OK: Mutex<T> is Sync when T: Send
// Mutex provides interior mutability with thread-safe access
static DATA: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
// ✅ OK: String is Sync (immutable access only)
static TEXT: String = String::new();
// ❌ ERROR: Rc is NOT Sync (not thread-safe reference counting)
// static BAD: std::rc::Rc<i32> = std::rc::Rc::new(42); // Won't compile
// ❌ ERROR: Cell is NOT Sync (not thread-safe interior mutability)
// static ALSO_BAD: std::cell::Cell<i32> = std::cell::Cell::new(0); // Won't compile
Thread-Safe Alternatives
| Non-Sync Type | Thread-Safe Alternative |
|---|---|
Rc<T> |
Arc<T> (atomic reference counting) |
Cell<T> |
AtomicT or Mutex<T> |
RefCell<T> |
Mutex<T> or RwLock<T> |
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU32, Ordering};
// ✅ Thread-safe reference counting
static COUNTER_REF: std::sync::OnceLock<Arc<AtomicU32>> = std::sync::OnceLock::new();
// ✅ Thread-safe interior mutability
static CONFIG: Mutex<Option<String>> = Mutex::new(None);
fn main() {
COUNTER_REF.get_or_init(|| Arc::new(AtomicU32::new(0)));
let handle = std::thread::spawn(|| {
if let Some(counter) = COUNTER_REF.get() {
counter.fetch_add(1, Ordering::SeqCst);
}
});
handle.join().unwrap();
}
Mutable Statics: static mut
Accessing or modifying static mut requires an unsafe block because it can cause data races.
static mut UNSAFE_COUNTER: i32 = 0;
fn increment_unsafe() {
unsafe {
UNSAFE_COUNTER += 1; // All access requires unsafe
}
}
fn read_unsafe() -> i32 {
unsafe {
UNSAFE_COUNTER // Reading also requires unsafe
}
}
Why static mut is Problematic
- No automatic synchronization
- Easy to introduce data races in multi-threaded code
- Violates Rust’s core safety guarantees
- Should only be used in specific low-level scenarios (FFI, OS kernels)
Initialization and Drop Semantics
Compile-Time Initialization
Both const and static require constant initializers that can be evaluated at compile-time.
// OK: Compile-time evaluable
static COUNT: u32 = 42;
static NAME: &str = "Rust";
// Error: Runtime computation not allowed
// static RANDOM: u32 = rand::random();
No Drop on Program Exit
Static items are never dropped, even if they contain types with Drop implementations:
use std::sync::Mutex;
use std::fs::File;
static FILE_HANDLE: Mutex<Option<File>> = Mutex::new(None);
fn main() {
// File's Drop implementation will NEVER run
// The file descriptor leaks at program termination
// This is by design for statics
if let Ok(file) = File::create("test.txt") {
*FILE_HANDLE.lock().unwrap() = Some(file);
}
// When program exits, FILE_HANDLE is NOT dropped
// File::drop() is NOT called
// OS cleans up the file descriptor
}
Why this matters:
- Resources leak: File handles, network sockets, etc. won’t be cleaned up by Rust
- OS cleanup: The operating system will reclaim resources when the process exits
- Flush concerns: Buffered writers won’t flush! Explicitly flush before exit if needed
use std::io::Write;
use std::sync::Mutex;
static LOG: Mutex<Option<std::io::BufWriter[std::fs::File](std::fs::File)>> = Mutex::new(None);
fn main() {
// ... write to LOG ...
// ❌ BAD: Buffered data might not be written
// Drop won't run, buffer won't flush
// ✅ GOOD: Explicitly flush before exit
if let Some(writer) = LOG.lock().unwrap().as_mut() {
writer.flush().expect("Failed to flush log");
}
}
Safe Patterns for Global State
Pattern 1: Lazy Initialization
Use lazy initialization for expensive computations or when initialization requires runtime data.
use std::sync::OnceLock;
static EXPENSIVE_DATA: OnceLock<Vec<u64>> = OnceLock::new();
fn get_data() -> 'static Vec<u64> {
EXPENSIVE_DATA.get_or_init(|| {
// Computed only once, on first access
(0..1_000_000).map(|x| x * x).collect()
})
}
Pattern 1a: LazyLock vs OnceLock - Choosing the Right Tool
Both LazyLock and OnceLock were stabilized in Rust 1.80 (July 2024) and provide thread-safe lazy initialization, but they serve different use cases:
LazyLock: The initialization function is built into the type at declaration time. This is simpler and more ergonomic when you always initialize the same way.
use std::sync::LazyLock;
use std::collections::HashMap;
// Initializer is part of the declaration
static CONFIG: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
let mut map = HashMap::new(); // This is in a closure, not const context
map.insert("host".to_string(), "localhost".to_string());
map.insert("port".to_string(), "8080".to_string());
map
});
fn main() {
// Simply access it - initializer runs on first access
println!("{}", CONFIG.get("host").unwrap());
}
OnceLock: The initialization function is provided at runtime via get_or_init(). This is more flexible when different code paths might initialize differently.
use std::sync::OnceLock;
use std::collections::HashMap;
// No initializer at declaration
static CACHE: OnceLock<HashMap<String, String>> = OnceLock::new();
fn initialize_cache(from_file: bool) {
CACHE.get_or_init(|| {
let mut map = HashMap::new();
if from_file {
// Load from config file
map.insert("source".to_string(), "file".to_string());
} else {
// Use defaults
map.insert("source".to_string(), "default".to_string());
}
map
});
}
fn main() {
initialize_cache(false);
println!("{}", CACHE.get().unwrap().get("source").unwrap());
}
Quick Comparison:
| Aspect | LazyLock | OnceLock |
|---|---|---|
| Initializer | Defined at declaration | Provided at first get_or_init() call |
| Ergonomics | Simpler, fewer moving parts | More flexible, slightly verbose |
| Use when | Single, predetermined initialization | Multiple possible initialization paths |
| Common for | Config files, static caches | Runtime-determined initialization |
Note: Both replace the older lazy_static! macro and once_cell crate, which are now considered legacy patterns.
Pattern 2: Atomic Types
For simple counters or flags, use atomic types from std::sync::atomic.
use std::sync::atomic::{AtomicU64, Ordering};
static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0);
fn handle_request() {
REQUEST_COUNT.fetch_add(1, Ordering::Relaxed);
}
fn get_request_count() -> u64 {
REQUEST_COUNT.load(Ordering::Relaxed);
}
Pattern 3: Mutex or RwLock
For complex shared state, use Mutex or RwLock to ensure safe concurrent access.
use std::sync::RwLock;
use std::collections::HashMap;
use std::sync::OnceLock;
static CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
fn get_cached(key: &str) -> Option<String> {
let cache_lock = CACHE.get_or_init(|| RwLock::new(HashMap::new()));
let cache = cache_lock.read().unwrap();
cache.get(key).cloned()
}
fn set_cached(key: String, value: String) {
let mut cache = CACHE.write().unwrap();
cache.insert(key, value);
}
Pattern 4: Thread-Local Storage
For mutable per-thread state that doesn’t need to be shared across threads, use thread_local! instead of static with Mutex:
use std::cell::RefCell;
// Each thread gets its own independent counter
thread_local! {
static THREAD_COUNTER: RefCell<u32> = RefCell::new(0);
}
fn increment_thread_counter() {
THREAD_COUNTER.with(|counter| {
*counter.borrow_mut() += 1;
});
}
fn get_thread_counter() -> u32 {
THREAD_COUNTER.with(|counter| {
*counter.borrow()
})
}
fn main() {
use std::thread;
increment_thread_counter();
increment_thread_counter();
println!("Main thread counter: {}", get_thread_counter()); // 2
let handle = thread::spawn(|| {
increment_thread_counter();
println!("Spawned thread counter: {}", get_thread_counter()); // 1
});
handle.join().unwrap();
println!("Main thread counter still: {}", get_thread_counter()); // Still 2
}
When to use:
- Per-thread caches or buffers
- Thread-local random number generators
- Performance counters per thread
- Any mutable state that doesn’t need cross-thread coordination
Advantages over Mutex:
- No synchronization overhead
- No possibility of deadlocks
- Simpler mental model for thread-isolated state
Complete Examples
Example 1: Configuration Registry
static CONFIG: RwLock<HashMap<'static str, String>> = RwLock::new(HashMap::new());
use std::sync::{RwLock, OnceLock};
use std::collections::HashMap;
static CONFIG: OnceLock<RwLock<HashMap<&'static str, String>>> = OnceLock::new();
fn init_config() {
let config_lock = CONFIG.get_or_init(|| RwLock::new(HashMap::new()));
let mut config = config_lock.write().unwrap();
config.insert("app_name", "MyApp".to_string());
config.insert("version", "1.0.0".to_string());
}
fn get_config(key: &str) -> Option<String> {
CONFIG.get()?.read().unwrap().get(key).cloned()
}
fn main() {
init_config();
println!("{:?}", get_config("app_name"));
}
Example 2: Constant Lookup Table
static HTTP_STATUS_MESSAGES: [(u16, str); 5] = [
(200, "OK"),
(404, "Not Found"),
(500, "Internal Server Error"),
(403, "Forbidden"),
(401, "Unauthorized"),
];
fn get_status_message(code: u16) -> 'static str {
HTTP_STATUS_MESSAGES.iter()
.find(|(status, _)| *status == code)
.map(|(_, msg)| *msg)
.unwrap_or("Unknown")
}
Example 3: Thread-Safe Counter
use std::sync::atomic::{AtomicUsize, Ordering};
static UNIQUE_ID: AtomicUsize = AtomicUsize::new(1);
fn generate_id() -> usize {
UNIQUE_ID.fetch_add(1, Ordering::SeqCst)
}
fn main() {
let handles: Vec<_> = (0..10)
.map(|_| {
std::thread::spawn(|| {
let id = generate_id();
println!("Thread got ID: {}", id);
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
Decision Guide
Use this flowchart logic to decide between const, static, or alternatives:
- Is the value mutable?
- No → Go to step 2
- Yes → Go to step 4
- Is it a small, simple value (primitive or small struct)?
- Yes → Use
const - No → Go to step 3
- Yes → Use
- Do you need a stable memory address or is the data very large?
- Yes → Use
static - No → Use
const
- Yes → Use
- Do you need shared mutable state?
- Yes → Use
staticwithMutex/RwLock/Atomic - No → Consider passing ownership or using thread-local storage
- Yes → Use
- Is this for FFI or bare-metal programming?
- Yes →
static mutmay be appropriate (use with extreme caution) - No → Avoid
static mut; use safe alternatives
- Yes →
Common Mistakes and How to Avoid Them
Mistake 1: Using static for Simple Constants
// Wrong
static MAX_SIZE: usize = 1024;
// Right
const MAX_SIZE: usize = 1024;
Mistake 2: Forcing 'static Lifetime Unnecessarily
// Wrong: Unnecessarily requiring 'static
fn store_string(s: 'static str) {
/ / ...
}
// Right: Use appropriate lifetime
fn store_string<'a>(s: 'a str) {
// ...
}
Mistake 3: Using static mut Instead of Safe Alternatives
// Wrong: Unsafe and prone to data races
static mut COUNTER: i32 = 0;
// Right: Use atomic types
use std::sync::atomic::{AtomicI32, Ordering};
static COUNTER: AtomicI32 = AtomicI32::new(0);
Conclusion
The static keyword is a powerful tool in Rust, but it should be used judiciously. Remember these key principles:
- Prefer
constfor compile-time constants - Use
staticwhen you need a stable address or single storage location - Avoid
static mut; use interior mutability patterns instead - Understand the difference between
staticitems and the'staticlifetime - Always consider thread safety with the
Synctrait requirement - Use modern lazy initialization with
LazyLockandOnceLock(stabilized in Rust 1.80)
By following these guidelines, you’ll write safer, more idiomatic Rust code that leverages static appropriately while avoiding common pitfalls.