Rust const and static: a practical guide for developers
Global values are unavoidable in production systems—whether you're storing configuration, caching expensive computations, or managing shared state. Rust gives us two distinct mechanisms for this: const and static, and choosing between them fundamentally changes how your program behaves at compile time and runtime. Getting this right matters because the wrong choice can lead to performance issues, memory overhead, or subtle bugs that only appear under load.
Understanding const: compile-time inlining
A const value is not a variable—it's a compile-time constant that gets inlined wherever it appears. The compiler treats const declarations as templates: every use of the constant is replaced with its literal value during compilation, and no storage is allocated for it at runtime.
This inlining behavior is powerful but has constraints. You can only assign literal values or const functions to a const, never the result of a runtime computation. Types must be Copy or have a const fn constructor.
const MAX_CONNECTIONS: u32 = 1024;
const DB_TIMEOUT_MS: u64 = 5000;
const FEATURES: &[&str] = &["auth", "cache", "queue"];
// Every usage gets inlined
fn validate_pool_size(size: u32) -> bool {
size <= MAX_CONNECTIONS
}
fn connect_timeout() -> std::time::Duration {
std::time::Duration::from_millis(DB_TIMEOUT_MS)
}We often use const for configuration that never changes: protocol constants, limits, defaults, and magic numbers. The benefit is zero runtime cost—these values are baked into your binary. The downside is you cannot modify them without recompiling.
In a recent e-commerce project, we used const for rate limits and pagination sizes. Since these rarely changed and needed maximum performance, inlining was the right choice. Had we stored them as static instead, we'd have added an extra memory indirection on every access.
Understanding static: fixed-address storage
A static value, by contrast, is allocated once at program startup and maintains a fixed memory address for the entire lifetime of the process. Think of it as a global variable with Rust's safety guarantees.
Unlike const, a static can be initialized with runtime computations (via functions), and it exists as an actual piece of data in memory. This means you can take references to it, and those references remain valid for the entire program.
static APP_NAME: &str = "OrderService";
static VERSION: &str = env!("CARGO_PKG_VERSION");
// Initialized at startup, address is fixed
static METRICS_BUFFER: std::sync::Mutex<Vec<u32>> =
std::sync::Mutex::new(Vec::new());
fn log_metric(value: u32) {
if let Ok(mut buf) = METRICS_BUFFER.lock() {
buf.push(value);
}
}Use static when you need a single shared reference that persists across the entire program lifetime. Logging buffers, connection pools, and metric collectors are classic examples. The memory cost is minimal—one allocation per static—and access is direct.
On a ride-hailing platform, we stored a shared metrics accumulator as a static Mutex<HashMap>. Workers could push data to it without allocation, and a background thread periodically drained it. Storing this as const would have been impossible; we needed mutable state with a stable address.
Mutable statics: the danger zone
Rust allows static mut declarations, but they are unsafe by design. Any read or write to a mutable static requires an unsafe block because the compiler cannot guarantee thread safety or data race freedom.
static mut COUNTER: u32 = 0;
fn increment() {
unsafe {
COUNTER += 1; // Undefined behavior if multiple threads access this
}
}This pattern is a red flag in production code. Multiple threads can access the same mutable static simultaneously, leading to race conditions, torn writes, and memory corruption. We've seen production incidents from exactly this mistake—a counter that looked threadsafe in isolated tests but failed spectacularly under concurrent load.
Instead, wrap your mutable state in a Mutex, RwLock, or atomic type inside a static. This gives you safety guarantees while maintaining a single point of access.
use std::sync::Mutex;
static COUNTER: Mutex<u32> = Mutex::new(0);
fn increment() {
if let Ok(mut guard) = COUNTER.lock() {
*guard += 1; // Thread-safe
}
}
// For simple counters, use atomics:
use std::sync::atomic::{AtomicU32, Ordering};
static FAST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn increment_fast() {
FAST_COUNTER.fetch_add(1, Ordering::Relaxed);
}The Mutex approach is safer; the AtomicU32 approach is faster. Choose based on your contention profile. In a trading system handling thousands of transactions per second, we switched from Mutex to atomic counters and saw a 30% latency improvement.
OnceLock: lazy initialization without unsafe
Many production systems need to initialize a global value on first access, not at startup. Configuration from environment variables, database connections, or expensive computations fall into this category. The traditional approach involved a static mut flag and unsafe code, which was cumbersome and error-prone.
Rust 1.70 introduced std::sync::OnceLock, solving this elegantly. It guarantees that a value is initialized exactly once, safely, without unsafe code.
use std::sync::OnceLock;
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
fn get_config() -> &'static AppConfig {
CONFIG.get_or_init(|| {
AppConfig::from_env() // Expensive computation happens here
})
}
struct AppConfig {
db_url: String,
api_key: String,
}
impl AppConfig {
fn from_env() -> Self {
AppConfig {
db_url: std::env::var("DB_URL").unwrap_or_default(),
api_key: std::env::var("API_KEY").unwrap_or_default(),
}
}
}The first call to get_or_init runs the closure and stores the result. Subsequent calls return a reference to that cached value. Thread safety is guaranteed—if two threads call get_or_init simultaneously, only one closure executes, and both receive the same initialized value.
In a SaaS platform, we used OnceLock for database connection pools. Connecting on startup would slow down deployment; connecting lazily on the first request kept boot time fast. The added complexity from OnceLock was trivial compared to managing manual initialization flags.
For even more advanced scenarios, the once_cell crate (which inspired the standard library) offers Lazy for computed values and sync::Lazy for shared access, with broader feature support in stable Rust versions.
Common mistakes
- Mixing const and static for configuration: If your value changes between builds or deployments, use
staticwithOnceLock, notconst.constrequires recompilation. - Forgetting interior mutability: A
static Stringcannot be modified after initialization. Wrap mutable types inMutexorRwLockif you need to change them. - Using static for short-lived state: Thread-local data, request-scoped values, and temporary buffers belong in function locals or thread-local storage, not static globals. Static globals persist for the entire program lifetime.
- Assuming const is free: While inlining avoids runtime lookups, it increases binary size. For large data structures,
static &[u8]is more efficient thanconst. - Initializing statics with async code:
OnceLock::get_or_initblocks the current thread. For async initialization, use a dedicated tokio task or initialize in a setup phase before spawning workers.
Key takeaways
constis inlined at compile time, costing zero runtime overhead but requiring literal or const-function initialization.staticallocates storage once at startup with a fixed address, suitable for shared state like buffers and pools.- Never use
static mutwithout wrapping inMutex,RwLock, or atomic types; the safety cost is not worth the minor performance gain. OnceLockis the idiomatic pattern for lazy global initialization—use it instead of manual unsafe flags.- Choose
constfor compile-time constants,staticfor shared runtime state, andOnceLockfor deferred initialization.






Comments
Loading comments…