Design Patterns in Rust – Singleton Pattern

The singleton pattern gives you the benefits of global access while maintaining control over how that global state is created, accessed, and modified. It’s essentially “global variables done right” for cases where you genuinely need global state.
To demo this, we’ll code an example to connect to a database. This is just to make the example more real world. You can use Docker, or your own database if you have one, and know how to connect to it with Rust.
We will useOnceLock
to safely initialize the Mutex<Database>
once at runtime, after calling the async constructor.

OnceLock<Mutex<Database>>
Skip ahead if you know all this, it will involve some tinkering with docker compose, postgres as well, which will be brief but useful
! By default, PgPool
from sqlx
has a pool size of 5 connections.
For more examples, check out this site, it’s got some great examples: https://refactoring.guru/design-patterns/singleton/rust/example
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: demo123
POSTGRES_DB: app
POSTGRES_USER: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
docker compose up -d postgres
docker compose exec postgres psql -U postgres -d app -c "
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (name, email) VALUES
('Alice Johnson', 'alice@example.com'),
('Bob Smith', 'bob@example.com'),
('Charlie Brown', 'charlie@example.com')
ON CONFLICT (email) DO NOTHING;
"
❯ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6ced1b1d666d postgres:15 "docker-entrypoint.s…" 49 minutes ago Up 49 minutes (healthy) 0.0.0.0:5432->5432/tcp, :::5432->5432/tcp youtube_videos-postgres-1
Code
[package]
name = "singleton"
version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
tokio = { version = "1.46.1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }
Analogy:
Think of OnceCell like a bank vault that:
Only allows one deposit, ever (.set)
Allows multiple withdrawals (.get)
If the vault is empty and you try to withdraw → panic!
If someone tries to deposit again → error!
use sqlx::PgPool;
use sqlx::Row;
use std::sync::OnceLock;
use tokio::sync::Mutex;
static DATABASE: OnceLock<Mutex<Database>> = OnceLock::new();
pub struct Database {
pool: PgPool,
}
impl Database {
pub async fn new() -> Result<Self, sqlx::Error> {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://postgres:demo123@localhost/app".to_string());
// POSTGRES_PASSWORD=demo123
let pool = PgPool::connect(&database_url).await?;
Ok(Database { pool })
}
pub fn get_pool(&self) -> &PgPool {
&self.pool
}
}
// Better pattern for async singleton initialization:
pub async fn initialize_database() -> Result<(), sqlx::Error> {
let db = Database::new().await?;
DATABASE.set(Mutex::new(db)).map_err(|_| sqlx::Error::Configuration("Database already initialized".into()))?;
Ok(())
}
pub fn get_database_sync() -> &'static Mutex<Database> {
DATABASE.get().expect("Database not initialized. Call initialize_database() first.")
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the database first
initialize_database().await?;
// Now you can use the database
let db = get_database_sync();
let db_lock = db.lock().await;
// Use the database pool
let pool = db_lock.get_pool();
// Example query
let rows = sqlx::query("SELECT id, name, email FROM users")
.fetch_all(pool)
.await?;
for row in rows {
let id: i32 = row.get("id");
let name: String = row.get("name");
let email: Option<String> = row.get("email");
println!("User: {} - {} - {:?}", id, name, email);
}
Ok(())
}
The key insight:
Mutex
is for thread-safe access to data- If you’re not moving the database connection to different threads, you only need
Mutex
When you DO need Arc:
- Passing the database to spawned async tasks
- Sharing between multiple threads
- Cloning references to pass around
When you DON’T need Arc:
- Simple single-threaded usage
- All access happens from the same context
- Tutorial/learning scenarios
This version is much cleaner for teaching the singleton pattern! The OnceLock<Mutex<Database>>
gives you:
- Singleton behavior – only one instance ever created
- Thread safety – safe to access from multiple threads
- Simplicity – no unnecessary complexity
Why do we need OnceLock?
OnceLock
solves the initialization problem – ensuring our database connection is created exactly once, even if multiple parts of code call get_database()
simultaneously.
Here’s what would happen without OnceLock
:
// BAD - Without OnceLock
static mut DATABASE: Option<Mutex<Database>> = None;
pub async fn get_database() -> &'static Mutex<Database> {
unsafe {
if DATABASE.is_none() {
// PROBLEM: What if two threads reach this point at the same time?
let db = Database::new().await.expect("Failed to connect");
DATABASE = Some(Mutex::new(db));
}
DATABASE.as_ref().unwrap()
}
}
The race condition:
- Thread A calls
get_database()
, seesDATABASE
isNone
- Thread B calls
get_database()
, also seesDATABASE
isNone
- Both threads try to create a database connection!
- We end up with two connections instead of one (breaking the singleton pattern)
What OnceLock
does:
- Guarantees the initialization closure runs exactly once
- Handles all the thread synchronization for us
- Subsequent calls just return the already-created instance
When you need Arc
, you’d still use OnceLock
for the same initialization safety reasons.
When to use the Mutex
Keep the Mutex<Database>
only if:
- You’re mutating state inside
Database
that isn’t thread-safe, - Or you want to guard certain operations (like doing one query at a time).
If not — ditch the Mutex and use OnceLock<Database>
directly. It’s cleaner and faster.