The Singleton pattern is a foundational creational design pattern. Its primary goal is simple yet powerful: ensure that a class has only one instance and provide a single, global point of access to it.
Why would you want to restrict a class to a single instance? This approach is crucial for managing resources, maintaining a consistent state, and optimizing performance across your entire application.
Let’s break down the core objectives and use cases with a mind map.
mindmap
root((Singleton Pattern))
Core Goal
Only ONE Instance
Global Access Point
Key Benefits
Memory Optimization
::icon(fa fa-memory)
Avoids creating redundant, resource-heavy objects.
Shared State Management
::icon(fa fa-cogs)
Maintains a consistent state across different parts of the application.
Controlled Resource Access
::icon(fa fa-database)
Manages access to exclusive resources like database connections or hardware.
Common Use Cases
Configuration Managers
Logging Services
Database Connection Pools
Hardware Interface Access
When to Use the Singleton Pattern
Imagine you have an object that’s expensive to create or holds a state that must be consistent everywhere. Creating new instances every time you need it would be wasteful and could lead to unpredictable behavior.
Here’s a visual comparison:
graph TD
subgraph "Without Singleton: High Memory & Inconsistent State"
direction LR
A[Component A] --> B(new Config())
C[Component C] --> D(new Config())
E[Component E] --> F(new Config())
end
subgraph "With Singleton: Optimized & Consistent"
direction LR
G[Component A] --> H{Singleton Instance}
I[Component C] --> H
J[Component E] --> H
end
style B fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#f9f,stroke:#333,stroke-width:2px
style F fill:#f9f,stroke:#333,stroke-width:2px
style H fill:#9cf,stroke:#333,stroke-width:2px
Common scenarios include:
- Configuration Management: Application settings are loaded once and accessed globally. A Singleton ensures all parts of the app read from the same configuration object.
- Logging: A single logger instance aggregates logs from various components into one file or stream, preventing file access conflicts.
- Database/Service Connections: Managing a connection pool with a Singleton prevents exhausting available connections and reuses existing ones efficiently.
The Problem: Race Conditions and State Corruption
The true power of the Singleton pattern shines in multi-threaded applications. Let’s explore a classic problem: managing a bank account.
Imagine two operations happening at the exact same time on an account with a balance of $10,000:
- Thread 1: Withdraws $7,000.
- Thread 2: Deposits $5,000.
The expected final balance is $10,000 - $7,000 + $5,000 = $8,000.
Let’s model this with a standard Python class.
# bank_service_v1.py
import threading
import time
class BankService:
def __init__(self):
self.balance = 10000
print(f"Initialized new BankService instance with balance: {self.balance}")
def withdraw(self, amount):
print(f"Request to withdraw ${amount}...")
if self.balance >= amount:
time.sleep(1) # Simulate network delay
self.balance -= amount
print(f"Withdrawal successful. New balance: ${self.balance}")
else:
print("Withdrawal failed: Insufficient funds.")
def deposit(self, amount):
print(f"Request to deposit ${amount}...")
time.sleep(1) # Simulate network delay
self.balance += amount
print(f"Deposit successful. New balance: ${self.balance}")
# --- Simulation ---
def run_simulation():
print("--- Running Simulation with Standard Class ---")
# Each thread creates its OWN instance of BankService
withdraw_thread = threading.Thread(target=BankService().withdraw, args=(7000,))
deposit_thread = threading.Thread(target=BankService().deposit, args=(5000,))
withdraw_thread.start()
deposit_thread.start()
withdraw_thread.join()
deposit_thread.join()
print("--- Simulation Finished ---\n")
run_simulation()
The disastrous output:
--- Running Simulation with Standard Class ---
Initialized new BankService instance with balance: 10000
Request to withdraw $7000...
Initialized new BankService instance with balance: 10000
Request to deposit $5000...
Withdrawal successful. New balance: $3000
Deposit successful. New balance: $15000
--- Simulation Finished ---
Both threads created their own BankService instance. Each instance started with a balance of $10,000, leading to a completely corrupted final state.
sequenceDiagram
participant T1 as Thread 1 (Withdraw)
participant T2 as Thread 2 (Deposit)
participant B1 as BankInstance1 (Balance: 10k)
participant B2 as BankInstance2 (Balance: 10k)
T1->>B1: new BankService()
T2->>B2: new BankService()
par
T1->>B1: withdraw(7000)
Note over B1: Reads balance (10k), calculates 3k
and
T2->>B2: deposit(5000)
Note over B2: Reads balance (10k), calculates 15k
end
B1-->>T1: Balance is now 3k
B2-->>T2: Balance is now 15k
A Failed Fix: The Misunderstood Lock
A common first thought is to add a lock. Let’s see what happens.
# bank_service_v2.py
import threading
import time
class BankService:
def __init__(self):
self.balance = 10000
+ self._lock = threading.Lock()
print(f"Initialized new BankService instance with balance: {self.balance}")
def withdraw(self, amount):
print(f"Request to withdraw ${amount}...")
+ with self._lock:
if self.balance >= amount:
time.sleep(1)
self.balance -= amount
print(f"Withdrawal successful. New balance: ${self.balance}")
else:
print("Withdrawal failed: Insufficient funds.")
def deposit(self, amount):
print(f"Request to deposit ${amount}...")
+ with self._lock:
time.sleep(1)
self.balance += amount
print(f"Deposit successful. New balance: ${self.balance}")
Running the simulation again yields the exact same incorrect result. Why?
[!WARNING] A lock only protects resources within the same object instance. Since each thread created its own
BankServiceinstance, each thread also had its own separate lock. The locks never conflicted, and the race condition persisted.
The Solution: A Thread-Safe Singleton
To fix this, we must ensure both threads operate on the one and only instance of BankService. We’ll implement a thread-safe Singleton using Python’s __new__ method and a class-level lock.
The __new__ dunder method is called to create an instance, while __init__ is called to initialize it. By controlling __new__, we can control the creation process itself.
# bank_service_singleton.py
import threading
import time
class BankService:
_instance = None
_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
# First check without a lock for performance
if not cls._instance:
# Acquire lock to ensure only one thread can create the instance
with cls._lock:
# Double-check locking: another thread might have created the
# instance while the current thread was waiting for the lock.
if not cls._instance:
print("Creating the one and only BankService instance...")
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# The initializer runs every time the class is called, even if an
# existing instance is returned. We use a flag to ensure the actual
# initialization logic runs only once.
if not hasattr(self, 'initialized'):
print(f"Initializing instance... Setting balance to 10000")
self.balance = 10000
self.initialized = True
def withdraw(self, amount):
# The lock is now on the methods of the shared instance
with self.__class__._lock:
print(f"Thread {threading.current_thread().name}: Request to withdraw ${amount}...")
if self.balance >= amount:
time.sleep(1)
self.balance -= amount
print(f"Thread {threading.current_thread().name}: Withdrawal successful. New balance: ${self.balance}")
else:
print(f"Thread {threading.current_thread().name}: Withdrawal failed: Insufficient funds.")
def deposit(self, amount):
with self.__class__._lock:
print(f"Thread {threading.current_thread().name}: Request to deposit ${amount}...")
time.sleep(1)
self.balance += amount
print(f"Thread {threading.current_thread().name}: Deposit successful. New balance: ${self.balance}")
# --- Simulation with Singleton ---
def run_singleton_simulation():
print("--- Running Simulation with Singleton Class ---")
# Both threads will get the SAME instance
withdraw_thread = threading.Thread(
target=BankService().withdraw, args=(7000,), name="WithdrawThread"
)
deposit_thread = threading.Thread(
target=BankService().deposit, args=(5000,), name="DepositThread"
)
# The order of start() can vary, but the lock will ensure correctness
withdraw_thread.start()
deposit_thread.start()
withdraw_thread.join()
deposit_thread.join()
final_balance = BankService().balance
print(f"--- Simulation Finished ---")
print(f"Final, correct balance: ${final_balance}")
run_singleton_simulation()
The correct output:
--- Running Simulation with Singleton Class ---
Creating the one and only BankService instance...
Initializing instance... Setting balance to 10000
Thread WithdrawThread: Request to withdraw $7000...
Thread WithdrawThread: Withdrawal successful. New balance: $3000
Thread DepositThread: Request to deposit $5000...
Thread DepositThread: Deposit successful. New balance: $8000
--- Simulation Finished ---
Final, correct balance: $8000
Success! The lock on the shared instance forces the operations to happen sequentially, preserving the integrity of the balance.
sequenceDiagram
participant T1 as Thread 1 (Withdraw)
participant T2 as Thread 2 (Deposit)
participant BS as BankService Singleton
T1->>BS: BankService()
Note over BS: Creates instance, balance=10k
T2->>BS: BankService()
Note over BS: Returns existing instance
par
T1->>BS: withdraw(7000)
activate BS
Note over BS: Lock Acquired by T1
Note over BS: Balance becomes 3k
deactivate BS
and
T2->>BS: deposit(5000)
Note over T2: Waits for lock...
end
T2->>BS: deposit(5000)
activate BS
Note over BS: Lock Acquired by T2
Note over BS: Balance becomes 8k
deactivate BS
The “Pythonic” Singleton: Just Use a Module
While the class-based approach is a great way to learn the pattern’s mechanics, the simplest and most Pythonic way to achieve a Singleton is to use a module.
In Python, modules are cached on their first import. Every subsequent import in any part of your application will access the exact same module object.
Here’s how you’d structure the bank service as a module:
bank_project/
├── main.py
└── services/
├── __init__.py
└── bank_service.py
# services/bank_service.py
import threading
# These variables are instantiated only once when the module is first imported.
_balance = 10000
_lock = threading.Lock()
print("Bank service module initialized.")
def withdraw(amount):
global _balance
with _lock:
# ... (logic is the same)
if _balance >= amount:
_balance -= amount
print(f"Withdrawal successful. New balance: ${_balance}")
def deposit(amount):
global _balance
with _lock:
# ... (logic is the same)
_balance += amount
print(f"Deposit successful. New balance: ${_balance}")
def get_balance():
with _lock:
return _balance
Any other file can now import and use this module, and they will all be sharing the same state (_balance) and the same lock.
# main.py
from services import bank_service
import threading
# Both threads use the same functions from the same imported module instance
threading.Thread(target=bank_service.withdraw, args=(7000,)).start()
threading.Thread(target=bank_service.deposit, args=(5000,)).start()
[!TIP] For most use cases in Python, the module-based approach is simpler, cleaner, and avoids the complexities of overriding
__new__. Use it whenever possible.
Downsides and Best Practices
The Singleton pattern is powerful, but it’s often criticized for being an “anti-pattern” if misused.
[!WARNING] The Dangers of Global State
- Tight Coupling: Components that use a Singleton are tightly coupled to it, making them difficult to test in isolation. You can’t easily swap the Singleton with a mock or a fake for unit tests.
- Hidden Dependencies: The dependency on a Singleton is hidden inside the code, not exposed in the function or class signature. This makes the code harder to reason about.
- Violates Single Responsibility Principle: The Singleton class is responsible for its own business logic and for managing its lifecycle, which violates the SRP.
For modern applications, consider using Dependency Injection (DI) frameworks. A DI container can be configured to manage the lifecycle of your objects, providing a “singleton scope” without forcing your business logic class to be aware of its own singleton nature. This leads to more decoupled and testable code.