DEV Community

Gabriel Araujo
Gabriel Araujo

Posted on

Why Your Python Singleton is Probably a Code Smell (And What to Do Instead)

A practical guide to identifying when Singleton hurts more than it helps, with real code examples

The Singleton Seduction

Picture this: You're building a Python application and need a configuration manager. "Perfect!" you think, "This sounds like a job for the Singleton pattern!" After all, you only want one configuration object, right?

If this sounds familiar, you're not alone. The Singleton pattern is often the first "advanced" design pattern developers learn, and it seems to solve so many problems. But here's the thing: in most cases, your Singleton is probably making your code worse, not better.

Let me show you why with a real example, and more importantly, what to do instead.

The "Perfect" Singleton Implementation

Let's start with what seems like a solid Singleton implementation using Python metaclasses:

import json
import os
from typing import Any

class MetaSingleton(type):
    __instances = {}

    def __call__(cls, *args: Any, **kwargs: Any) -> Any:
        if cls not in cls.__instances:
            cls.__instances[cls] = super().__call__(*args, **kwargs)
        return cls.__instances[cls]

class ConfigurationManager(metaclass=MetaSingleton):
    def __init__(self) -> None:
        if not hasattr(self, "initialized"):
            self.configs = {}
            self.load_config()
            self.initialized = True

    def load_config(self) -> None:
        """Load configurations from JSON file"""
        try:
            pathfile = os.path.abspath("./src/config")
            self._check_folder_exists(pathfile)

            with open(f"{pathfile}/config.json", "r") as json_file:
                self.configs = json.load(json_file)
        except (json.JSONDecodeError, IOError) as e:
            print(f"Error loading configuration: {e}")
            self.configs = {}

    def get_config(self, key: str) -> Any:
        if key not in self.configs:
            raise Exception(f"Configuration not found: {key}")
        return self.configs[key]

    def set_config(self, key: str, value: Any):
        self.configs[key] = value
Enter fullscreen mode Exit fullscreen mode

This looks pretty good, right? Thread-safe, elegant use of metaclasses, automatic file management. What could go wrong?

The Hidden Trap: Action at a Distance

Here's where things get interesting. Let's use our configuration manager in two different parts of our application:

Database Controller (controller_db_example.py):

from src.driver.configuration_manager import ConfigurationManager

configuration = ConfigurationManager()

def get_products():
    """Simulate a database query with intentional config change"""
    # Oops! Someone modified the global config
    configuration.set_config("LOG_LEVEL", "Modified in DB controller")

    return [
        {"id": 1, "product": "SmartTV"},
        {"id": 2, "product": "Monitor LED"},
    ]
Enter fullscreen mode Exit fullscreen mode

Shopping Controller (controller_shopping_example.py):

from src.driver.configuration_manager import ConfigurationManager

configuration = ConfigurationManager()

def buy_item(item: str):
    # This will use the modified LOG_LEVEL from the DB controller!
    log_level = configuration.get_config("LOG_LEVEL")
    print(f"{log_level}: Purchasing item {item}...")
Enter fullscreen mode Exit fullscreen mode

Main Application (run.py):

from src.controller.controller_db_example import get_products
from src.controller.controller_shopping_example import buy_item

# Run the application
products = get_products()  # Modifies global config
print(products)

buy_item("clock")  # Uses the modified config!
Enter fullscreen mode Exit fullscreen mode

Output:

[{'id': 1, 'product': 'SmartTV'}, {'id': 2, 'product': 'Monitor LED'}]
Modified in DB controller: Purchasing item clock...
Enter fullscreen mode Exit fullscreen mode

The Problem: Spooky Action at a Distance

Notice what happened? The shopping controller's behavior was mysteriously affected by something that happened in a completely different module. This is what we call "action at a distance" - changes in one part of your code affecting behavior in another part with no obvious connection.

Why This Is Dangerous

  1. Hidden Dependencies: The shopping controller now implicitly depends on the database controller's execution order
  2. Debugging Nightmares: When logs show weird behavior, where do you even start looking?
  3. Testing Hell: How do you test the shopping controller in isolation?
  4. Race Conditions: In a multi-threaded environment, the behavior becomes unpredictable

The Pythonic Solution: Module-Level Instance

Instead of fighting Python's nature with complex Singleton patterns, let's work with it. Here's the most Pythonic approach - let Python's module system do the work:

# config_manager.py
import json
import os
from typing import Any
from types import MappingProxyType

class _ConfigurationManager:
    """Private configuration manager class"""

    def __init__(self) -> None:
        self.__configs: dict[str, Any] = {}
        self.load_config()

    def load_config(self) -> None:
        """Load configurations from JSON file"""
        try:
            pathfile = os.path.abspath("./src/config")
            self._check_folder_exists(pathfile)

            with open(f"{pathfile}/config.json", "r") as json_file:
                self.__configs = json.load(json_file)
        except (json.JSONDecodeError, IOError) as e:
            print(f"Error loading configuration: {e}")
            self.__configs = {}

    @property
    def configs(self) -> MappingProxyType[str, Any]:
        """Return read-only view of configurations"""
        return MappingProxyType(self.__configs)

    def get_config(self, key: str, default: Any = None) -> Any:
        """Get a configuration value"""
        return self.__configs.get(key, default)

    def has_config(self, key: str) -> bool:
        """Check if configuration exists"""
        return key in self.__configs

# Create single module-level instance
_configuration = _ConfigurationManager()

# Public API functions
def get_config(key: str, default: Any = None) -> Any:
    """Get configuration value"""
    return _configuration.get_config(key, default)

def get_all_configs() -> MappingProxyType[str, Any]:
    """Get all configurations (read-only)"""
    return _configuration.configs
Enter fullscreen mode Exit fullscreen mode

Usage:

# In your controllers
from config_manager import get_config

def buy_item(item: str):
    log_level = get_config("LOG_LEVEL", "INFO")
    print(f"{log_level}: Purchasing item {item}...")
Enter fullscreen mode Exit fullscreen mode

Why This Approach Rocks:

  • Zero magic: No metaclasses, no complex inheritance
  • Pythonic: Uses module system as intended
  • Import-time loading: Configuration loaded once when module imported
  • Easy testing: Can mock the module functions easily
  • Read-only by default: MappingProxyType prevents accidental mutations
  • Clean API: Simple functions instead of class instantiation

Refactoring Your Controllers

Now let's see how this fixes our original problem:

# controller_db_example.py
from config_manager import get_config, get_all_configs

def get_products():
    """Simulate a database query - but no more global state mutation!"""
    # Now we can only READ configuration, not modify it
    configs = get_all_configs()  # Read-only MappingProxyType
    database_url = get_config("DATABASE_URL", "localhost")

    print(f"Connecting to database: {database_url}")
    return [
        {"id": 1, "product": "SmartTV"},
        {"id": 2, "product": "Monitor LED"},
    ]
Enter fullscreen mode Exit fullscreen mode
# controller_shopping_example.py
from config_manager import get_config

def buy_item(item: str):
    """Buy an item - configuration is always consistent"""
    log_level = get_config("LOG_LEVEL", "INFO")
    print(f"{log_level}: Purchasing item {item}...")
Enter fullscreen mode Exit fullscreen mode
# run.py
from src.controller.controller_db_example import get_products
from src.controller.controller_shopping_example import buy_item

def main():
    # Configuration is loaded automatically when modules are imported
    products = get_products()
    print(products)

    buy_item("clock")  # Will use original LOG_LEVEL from config file

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Output:

Connecting to database: test.aws.br
[{'id': 1, 'product': 'SmartTV'}, {'id': 2, 'product': 'Monitor LED'}]
debug: Purchasing item clock...
Enter fullscreen mode Exit fullscreen mode

Notice how the shopping controller now consistently shows debug (from the config file) instead of the modified value. No more spooky action at a distance!

Testing: From Nightmare to Dream

Testing with Singleton (The Nightmare)

# Fragile, order-dependent tests
def test_buy_item_debug():
    config = ConfigurationManager()  # Global state!
    config.set_config("LOG_LEVEL", "DEBUG")
    buy_item("test")  # Hope no other test changed the config!

def test_buy_item_info():
    config = ConfigurationManager()  # Same global instance!
    config.set_config("LOG_LEVEL", "INFO")
    buy_item("test")  # This might fail if previous test ran first!
Enter fullscreen mode Exit fullscreen mode

Testing with Module-Level Instance (Much Better)

import pytest
from unittest.mock import patch
from src.controller.controller_shopping_example import buy_item

@patch('config_manager.get_config')
def test_buy_item_debug(mock_get_config):
    mock_get_config.return_value = "DEBUG"

    buy_item("test_item")  # Completely isolated!
    mock_get_config.assert_called_with("LOG_LEVEL", "INFO")

@patch('config_manager.get_config')
def test_buy_item_info(mock_get_config):
    mock_get_config.return_value = "INFO"

    buy_item("test_item")  # This test won't affect the previous one!
    mock_get_config.assert_called_with("LOG_LEVEL", "INFO")

# You can also use pytest fixtures for cleaner test setup
@pytest.fixture
def mock_config():
    with patch('config_manager.get_config') as mock:
        yield mock

def test_buy_item_with_fixture(mock_config):
    mock_config.return_value = "DEBUG"

    buy_item("laptop")
    mock_config.assert_called_once_with("LOG_LEVEL", "INFO")
Enter fullscreen mode Exit fullscreen mode

Key advantages:

  • ✅ Tests are completely isolated
  • ✅ No cleanup between tests needed
  • ✅ Can run in any order
  • ✅ Cleaner syntax with pytest
  • ✅ Fixtures make setup more reusable

When Singleton Might Still Be OK

Don't get me wrong - Singleton isn't always evil. It can be appropriate for:

  • Hardware interfaces (you only have one graphics card)
  • System-level resources (one logger per application)
  • Caches where global access is genuinely needed
  • Thread pools or connection pools

The key is: use it for resources that are genuinely unique and where global access is a feature, not a bug.

The Key Principles

  1. Module-Level Instances: Use Python's module system instead of complex Singleton patterns
  2. Immutable Configuration: Use MappingProxyType to prevent accidental mutations
  3. Import-Time Loading: Load configuration when the module is imported
  4. Test with Mocks: Use unittest.mock.patch for isolated testing

Conclusion

The next time you reach for Singleton, ask yourself:

  • Am I trying to avoid passing parameters?
  • Will this make testing harder?
  • Could this create hidden dependencies between modules?

If the answer to any of these is "yes," consider the module-level instance approach instead. Your future self (and your teammates) will thank you.

The goal isn't to avoid patterns altogether, but to use them when they genuinely improve your code, not just because they seem "fancy" or "enterprise-y."

Remember: Simple, explicit, and testable code is almost always better than clever code.


What's your experience with Singleton in Python? Have you run into similar testing issues? Share your thoughts in the comments!

Top comments (0)