The global value problem
You are building a configuration system. You need a maximum connection limit that never changes. You also need a shared request counter that increments every time a user hits an endpoint. In most languages, you would just declare two global variables and move on. Rust forces you to pick a lane. The language splits global values into two completely different tools. One gets copied into your binary like a macro. The other gets a permanent parking spot in memory. Mixing them up triggers confusing compiler errors and hides performance traps.
Constants versus statics in plain English
Think of const as a recipe instruction. The recipe says "add two cups of flour." Every time you follow the recipe, you write "two cups" on your notepad. The value is duplicated wherever you need it. There is no single physical cup of flour sitting on the counter. You just carry the number around.
Think of static as a physical measuring cup bolted to the kitchen counter. There is exactly one. Every part of your program points to that same location. If you pour water into it, everyone looking at the counter sees the water. If you take it out, the counter is empty for everyone.
Rust uses this distinction to enforce safety and performance. const values are inlined. The compiler replaces every usage with the literal value during compilation. They do not occupy a unique memory address. static values live at a fixed address for the entire lifetime of the program. They carry the 'static lifetime, which literally means "lives as long as the binary runs."
How the compiler actually handles them
Here is the minimal setup. You define a constant for a configuration limit and a static for a shared string.
/// Configuration limit. The compiler copies this value everywhere it appears.
const MAX_CONNECTIONS: u32 = 100;
/// Shared string. Lives at one fixed address in the binary's read-only data section.
static APP_NAME: &str = "RustFaq";
/// Demonstrates the difference between inlined values and fixed addresses.
fn main() {
// Inlining happens here. limit holds the literal value 100.
let limit = MAX_CONNECTIONS;
// Taking the address works because APP_NAME has a real memory location.
let name_ptr = std::ptr::addr_of!(APP_NAME);
println!("{:p}", name_ptr);
}
Watch what happens under the hood. When the compiler sees MAX_CONNECTIONS, it does not allocate memory for it. It treats it like a macro replacement. Every function that uses it gets its own copy of the number 100. This means you cannot take a reference to a const directly. The compiler has nowhere to point. If you try to write &MAX_CONNECTIONS, you get a temporary value that lives only for that expression. The temporary gets dropped at the end of the statement, which often triggers E0716 (temporary value dropped while borrowed) if you try to store the reference.
The static variable gets a different treatment. The linker places it in the .rodata segment of the executable. It gets a real address. You can take pointers to it. You can pass it to C libraries that expect a fixed memory location. The tradeoff is that the value must be fully known at compile time. You cannot call a function to generate it. You cannot read a file. You cannot use String::from. The compiler needs the exact bytes before the program starts.
Convention aside: both const and static use SCREAMING_SNAKE_CASE. This is a community standard that signals "this value is global and immutable by design." Stick to it so other Rust developers instantly recognize the scope.
Trust the inlining. If you need the value, the compiler will paste it there. If you need an address, you asked for the wrong tool.
When you actually need a static
Read-only statics are rare in modern Rust. You usually reach for const instead. The real reason static exists is mutable global state. You might need a shared counter, a logging buffer, or a feature flag that flips at runtime.
Mutable globals break Rust's core safety guarantee. The borrow checker cannot track who is reading or writing a global variable across threads. If two threads write to the same memory location simultaneously, you get a data race. Rust prevents this by forcing you to wrap mutable statics in synchronization primitives.
use std::sync::Mutex;
/// A shared counter. The Mutex guarantees only one thread mutates it at a time.
static REQUEST_COUNT: Mutex<u32> = Mutex::new(0);
/// Increments the global counter safely.
fn increment_counter() {
// Locking acquires exclusive access. The borrow checker sees a local &mut reference.
let mut count = REQUEST_COUNT.lock().unwrap();
*count += 1;
}
The Mutex wraps the value in a lock. You must call .lock() to get a guard. The guard implements DerefMut, which gives you a safe, locally scoped mutable reference. The borrow checker treats it like any other reference. When the guard drops, the lock releases. This pattern keeps global mutation safe without unsafe.
Sometimes you cannot initialize a static at compile time. Maybe you need to read a configuration file, or parse a command-line argument. Raw static variables reject runtime initialization. You get a compiler error telling you the expression is not a constant. The standard library solves this with OnceLock.
use std::sync::OnceLock;
/// Holds a String that will be initialized exactly once at runtime.
static CONFIG_PATH: OnceLock<String> = OnceLock::new();
/// Returns a reference to the lazily initialized configuration path.
fn load_config() -> &'static str {
// get_or_init runs the closure once, stores the result, and returns a reference.
CONFIG_PATH.get_or_init(|| {
// Simulate reading from disk or environment.
"/etc/app/config.yaml".to_string()
})
}
OnceLock starts empty. The first call to get_or_init runs your closure, stores the result, and locks the slot forever. Subsequent calls skip the closure and return the stored reference immediately. It is thread-safe and zero-cost after initialization. The community prefers get_or_init for one-liners and explicit set calls when you need to separate initialization from access. Pick the form that matches your control flow.
Lazy initialization is the bridge between compile-time guarantees and runtime flexibility. Use it when the value cannot exist before the program starts.
The hidden cost of mutable globals
Global state introduces friction into your architecture. Every function that reads or writes a static implicitly depends on shared memory. That dependency is invisible to the function signature. You cannot see it in the parameter list. You have to read the implementation to know it touches global state.
This breaks referential transparency. A function that uses a static counter returns different results on different calls, even with identical inputs. Testing becomes harder because you must reset the global state between tests. Concurrency testing becomes a nightmare because race conditions depend on thread scheduling.
The compiler cannot optimize across global boundaries. When a function reads a static, the compiler must assume the value could change at any time. It cannot cache the value in a register. It must load it from memory every time. This creates cache pressure and hurts performance in tight loops.
Isolate global access behind a small API. Wrap the static in a module. Expose functions that handle the locking or initialization internally. Keep the raw static private. This gives you the convenience of global state without leaking it across your entire codebase.
Keep globals behind a wall. Expose behavior, not memory addresses.
Pitfalls and compiler rejections
Global variables trip up developers who come from languages with loose memory models. The compiler will catch you, but the error messages require a shift in perspective.
The most common mistake is trying to mutate a static directly. You might write static COUNTER: u32 = 0; and then try COUNTER += 1;. The compiler rejects this immediately. Unwrapped mutable statics are illegal in safe Rust. You must add static mut COUNTER: u32 = 0; to even compile, and that forces you into an unsafe block to read or write it. The unsafe keyword tells the compiler you are taking responsibility for thread safety and aliasing rules. You should almost never do this. Wrap it in Mutex, RwLock, or an atomic type like AtomicU32 instead.
Another trap is assuming const and static are interchangeable because they look similar. They are not. If you pass a const to a C function that expects a pointer, the compiler complains. C needs a real memory address. const has none. You must use static for FFI boundaries. Conversely, if you use static for a simple configuration number, you waste memory and lose the performance benefit of inlining. The compiler cannot optimize across function boundaries as easily when it has to load from a fixed address.
You will also hit initialization limits. const and static require constant expressions. You cannot call std::env::var(), you cannot use Vec::new(), you cannot call trait methods. The compiler evaluates these at compile time. If it cannot prove the value is ready before main runs, it stops. The error usually points to the initialization line and says the expression is not a constant. Switch to OnceLock or a lazy crate when you need runtime setup.
Treat the compiler's rejection as a feature. It is forcing you to declare your intent. Do you want a copy everywhere, or do you want a shared location? Answer that question before writing the code.
Choosing between const and static
Pick the right tool based on memory layout and mutability requirements.
Use const when you need a value known at compile time and want the compiler to inline it everywhere. Use const for mathematical constants, configuration limits, and default parameters. Use const when you do not care about memory addresses and want the smallest possible binary footprint.
Use static when you need a single, fixed memory address that lives for the entire program. Use static for FFI boundaries where C or another language expects a pointer to a stable location. Use static when you need a read-only value that must have a unique address for debugging or profiling tools.
Use static with Mutex, RwLock, or atomic types when you need mutable global state that multiple threads can access safely. Use static with OnceLock when the value requires runtime initialization but should only be computed once. Use static mut only when you are writing low-level system code, implementing a custom allocator, or interfacing with hardware registers that cannot be wrapped in safe abstractions.
Default to const. It is faster, safer, and easier for the optimizer to handle. Reach for static only when the address or mutability requirement forces your hand.