Question
I want to create and use a struct that has only one instance in the entire program. In this case, it represents the OpenGL subsystem, and having multiple copies or passing it through many layers of code would make the design more confusing rather than simpler.
The singleton should be as efficient as possible. A direct static value does not seem practical because the struct contains a Vec, which has a destructor and cannot be trivially stored as a regular static value in the way I expect.
Another idea is to store an unsafe pointer in static storage and make it point to a heap-allocated instance. What is the safest and most convenient way to implement this in Rust while keeping the syntax reasonably concise?
For example, the kind of type I am thinking about is something like:
struct OpenGlSubsystem {
buffers: Vec<u32>,
}
How can I make a single global, mutable instance of a type like this in Rust?
Short Answer
By the end of this page, you will understand how global mutable state works in Rust, why plain mutable globals are restricted, and how to build a safe singleton using tools like OnceLock, Mutex, and LazyLock. You will also see when a singleton is appropriate, what trade-offs it introduces, and how real Rust codebases usually structure shared global state.
Concept
Rust is very careful about global mutable state because it is a common source of bugs in many languages.
A singleton is a value that exists only once in a program and is shared from a central place. In Rust, this is not usually implemented with a raw global mutable variable, because:
- mutable globals can cause data races
- initialization order can become hard to reason about
- cleanup and ownership become unclear
- unrestricted shared mutation breaks Rust's safety model
That is why Rust pushes you toward controlled global access.
Why a plain static mut is not ideal
You might think of writing something like this:
static mut OPENGL: OpenGlSubsystem = ...;
But this has several problems:
- accessing
static mutisunsafe - simultaneous access from multiple threads can be undefined behavior
- initialization can be awkward for types like
Vec - it bypasses Rust's usual ownership and borrowing checks
The safe Rust approach
The usual safe pattern is:
- store the singleton in a global container
- initialize it once
- guard mutable access with a synchronization type if needed
Common standard-library tools are:
Mental Model
Think of a singleton as a shared control room in a building.
- There is only one control room.
- Everyone who needs it goes to the same place.
- If only one person can change settings at a time, the room needs a lock on the door.
- The first person to arrive may also be responsible for setting it up.
In Rust terms:
- the control room is the singleton value
- the one-time setup is
OnceLockorLazyLock - the door lock is
Mutex - the rulebook that prevents chaos is Rust's ownership system
Without the lock, two people could change controls at once. In programming, that becomes corrupted state or a data race. Rust makes you use the lock instead of hoping everyone behaves.
Syntax and Examples
Core syntax
A modern safe pattern for a global mutable singleton in Rust is:
use std::sync::{LazyLock, Mutex};
struct OpenGlSubsystem {
buffers: Vec<u32>,
}
impl OpenGlSubsystem {
fn new() -> Self {
Self { buffers: Vec::new() }
}
fn add_buffer(&mut self, id: u32) {
self.buffers.push(id);
}
}
static OPENGL: LazyLock<Mutex<OpenGlSubsystem>> =
LazyLock::new(|| Mutex::new(OpenGlSubsystem::new()));
fn main() {
let mut gl = OPENGL.lock().unwrap();
gl.add_buffer(42);
println!("{:?}", gl.buffers);
}
What this does
Step by Step Execution
Consider this example:
use std::sync::{LazyLock, Mutex};
struct Counter {
value: i32,
}
static COUNTER: LazyLock<Mutex<Counter>> =
LazyLock::new(|| Mutex::new(Counter { value: 0 }));
fn increment() {
let mut counter = COUNTER.lock().unwrap();
counter.value += 1;
}
fn main() {
increment();
increment();
let counter = COUNTER.lock().unwrap();
println!("{}", counter.value);
}
Step by step
1. Program starts
COUNTER exists as a global definition, but Counter { value: 0 } is not created yet.
2. First call to increment()
This line runs:
Real World Use Cases
Global mutable singletons are used carefully in real programs when there is truly one shared resource or registry.
Common use cases
Rendering or graphics subsystem
A renderer may hold:
- GPU buffer IDs
- shader cache
- device state
- texture registry
This is close to your OpenGL example.
Global configuration loaded at startup
A program may load configuration once and keep it globally accessible.
use std::sync::OnceLock;
static CONFIG: OnceLock<String> = OnceLock::new();
Logger or metrics registry
Logging backends and metrics collectors are often initialized once and then reused throughout the app.
Shared cache
An application may keep a global in-memory cache of parsed templates, API tokens, or expensive computed values.
Plugin or command registry
A CLI or framework may store a global registry of commands, handlers, or extensions.
When not to use a singleton
Avoid a singleton if:
- the value can be passed as a normal parameter
- different tests need different instances
- different parts of the app should use different configurations
- global state will make dependencies hidden and hard to maintain
In many codebases, explicit dependency passing is better. A singleton is best reserved for truly global system-wide resources.
Real Codebase Usage
In real Rust projects, developers usually avoid raw singleton patterns and instead use a few practical patterns.
1. One-time initialization at startup
A subsystem is created once and stored globally:
static STATE: OnceLock<Mutex<AppState>> = OnceLock::new();
Then startup code calls set(...).
This is useful when initialization depends on command-line arguments, files, or environment variables.
2. Lazy global state
If the subsystem can be created with default logic, developers often write:
static STATE: LazyLock<Mutex<AppState>> = LazyLock::new(|| {
Mutex::new(AppState::new())
});
This avoids separate init code.
3. Guard clauses for initialization
Code often checks that global state exists before using it:
let state = STATE.get().expect("state not initialized");
That makes initialization errors fail early and clearly.
4. Narrow access functions
Instead of exposing the global directly everywhere, many projects wrap it in helper functions:
Common Mistakes
1. Using static mut directly
Broken example:
struct State {
value: i32,
}
static mut STATE: State = State { value: 0 };
fn increment() {
unsafe {
STATE.value += 1;
}
}
Why this is a problem:
- every access is
unsafe - concurrent access can cause undefined behavior
- Rust cannot protect you properly
Prefer:
use std::sync::{LazyLock, Mutex};
2. Forgetting synchronization for mutation
If multiple threads may access the singleton, mutation must be protected.
Broken idea:
use std::sync::OnceLock;
static STATE: OnceLock<MyType> = OnceLock::new();
This is fine for shared immutable access, but not for direct mutation from many places unless MyType itself handles safe interior mutability.
3. Holding the lock too long
Comparisons
| Approach | Safe? | Mutable? | Thread-safe? | Typical use |
|---|---|---|---|---|
static mut T | No, requires unsafe | Yes | No, unless manually synchronized | Rare, low-level code only |
static T | Yes | No, unless T has interior mutability | Depends on T | Constants and immutable globals |
OnceLock<T> | Yes | Only through T's own API | Depends on T |
Cheat Sheet
Quick patterns
Lazy global mutable singleton
use std::sync::{LazyLock, Mutex};
static STATE: LazyLock<Mutex<MyType>> =
LazyLock::new(|| Mutex::new(MyType::new()));
One-time explicit initialization
use std::sync::{Mutex, OnceLock};
static STATE: OnceLock<Mutex<MyType>> = OnceLock::new();
STATE.set(Mutex::new(MyType::new())).unwrap();
Accessing the singleton
let mut state = STATE.lock().unwrap();
or with OnceLock:
let mut state = STATE.get().unwrap().lock().();
FAQ
Is static mut a good way to make a singleton in Rust?
Usually no. It requires unsafe access and can easily lead to undefined behavior if used incorrectly.
What is the safest way to make a mutable global in Rust?
A common safe approach is LazyLock<Mutex<T>> or OnceLock<Mutex<T>>.
Why can't I just put a Vec in a simple global static and mutate it freely?
Because mutation of shared global state must be synchronized, and Rust prevents unsafely shared mutable access.
Should I use Mutex even if my type is only initialized once?
If the value is never mutated after initialization, you may not need Mutex. If it will be changed later, Mutex is a safe choice.
When should I use OnceLock instead of LazyLock?
Use OnceLock when initialization depends on runtime values or explicit startup logic. Use LazyLock when initialization can happen automatically on first access.
Is a singleton always bad design in Rust?
No, but it should be used only when there is truly one shared resource. Otherwise, passing dependencies explicitly is often clearer.
Mini Project
Description
Build a tiny global resource manager that simulates part of a rendering subsystem. It will store a list of loaded texture names in one shared global instance. This demonstrates safe global mutable state, lazy initialization, and controlled mutation through a mutex.
Goal
Create a single global texture registry that can be updated and read from different functions safely.
Requirements
- Create a
TextureRegistrystruct that stores texture names in aVec<String>. - Store exactly one global instance using a safe Rust pattern.
- Add a function to register a texture name.
- Add a function to list all registered textures.
- Demonstrate usage from
mainby adding multiple textures and printing them.
Keep learning
Related questions
Accessing Cargo Package Metadata in Rust
Learn how to read Cargo package metadata like version, name, and authors in Rust using compile-time environment macros.
Default Function Arguments in Rust: What to Use Instead
Learn how Rust handles default function arguments, why they are not supported, and practical patterns to achieve similar behavior.
Fixing Rust "linker 'cc' not found" on Debian in WSL
Learn why Rust shows "linker 'cc' not found" on Debian in WSL and how to fix it by installing the required C build tools.