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
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"},
]
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}...")
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!
Output:
[{'id': 1, 'product': 'SmartTV'}, {'id': 2, 'product': 'Monitor LED'}]
Modified in DB controller: Purchasing item clock...
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
- Hidden Dependencies: The shopping controller now implicitly depends on the database controller's execution order
- Debugging Nightmares: When logs show weird behavior, where do you even start looking?
- Testing Hell: How do you test the shopping controller in isolation?
- 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
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}...")
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:
MappingProxyTypeprevents 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"},
]
# 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}...")
# 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()
Output:
Connecting to database: test.aws.br
[{'id': 1, 'product': 'SmartTV'}, {'id': 2, 'product': 'Monitor LED'}]
debug: Purchasing item clock...
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!
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")
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
- Module-Level Instances: Use Python's module system instead of complex Singleton patterns
-
Immutable Configuration: Use
MappingProxyTypeto prevent accidental mutations - Import-Time Loading: Load configuration when the module is imported
-
Test with Mocks: Use
unittest.mock.patchfor 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)