Loading episodes…
0:00 0:00

The Python Singleton Pattern: A Visual Guide to Thread-Safe Objects

00:00
BACK TO HOME

The Python Singleton Pattern: A Visual Guide to Thread-Safe Objects

10xTeam December 28, 2025 12 min read

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:

  1. Configuration Management: Application settings are loaded once and accessed globally. A Singleton ensures all parts of the app read from the same configuration object.
  2. Logging: A single logger instance aggregates logs from various components into one file or stream, preventing file access conflicts.
  3. 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:

  1. Thread 1: Withdraws $7,000.
  2. 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 BankService instance, 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

  1. 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.
  2. 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.
  3. 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.


Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?