Singleton and Its Traps
Some resources should have exactly one instance: a connection pool, a configuration manager, a metrics collector. Creating two separate database connection pools in the same process wastes connections and may cause subtle routing bugs. Loading configuration from disk twice means two objects can drift out of sync. The naive fix — passing the shared object everywhere — feels cumbersome, so developers reach for the Singleton: a class that enforces exactly one instance and provides a global access point.
The Singleton is worth understanding both for its implementation and, more importantly, for its known drawbacks — which are examined in the section below.
The implementation
The class creates its own instance the first time it is accessed and returns the same instance every time thereafter. The sequence below shows that the second caller receives the same object without triggering a second construction.
1import threading
2
3class ConfigManager:
4 _instance: "ConfigManager | None" = None
5 _lock = threading.Lock()
6
7 def __new__(cls) -> "ConfigManager":
8 if cls._instance is None:
9 with cls._lock:
10 if cls._instance is None: # double-checked locking
11 cls._instance = super().__new__(cls)
12 cls._instance._settings = cls._load("/etc/app/config.yaml")
13 return cls._instance
14
15 @staticmethod
16 def _load(path: str) -> dict[str, str]:
17 # real implementation reads from disk
18 return {"db_host": "db.internal", "log_level": "INFO"}
19
20 def get(self, key: str) -> str:
21 return self._settings[key]
22
23 def reload(self) -> None:
24 with self.__class__._lock:
25 self._settings = self._load("/etc/app/config.yaml")
26
27# Every call returns the same object.
28cfg = ConfigManager()
29print(cfg.get("db_host")) # "db.internal"
Thread safety
The naive lazy version — check whether the instance exists, and create it if it does not — is a data race. Two threads can run the check before either has assigned, both construct an instance, and one silently overwrites the other. Any caller still holding the discarded instance now talks to an object the rest of the program cannot see. The common implementations close that gap in different ways:
| Approach | Lazy | Locking cost | Notes |
|---|---|---|---|
| Eager initialization | No | None | The runtime builds the instance when the class loads, before any thread can race. Wasteful only if the instance is expensive and may never be used. |
| Synchronized accessor | Yes | Every call | Lock the whole accessor. Correct, but every caller pays for the lock even after the instance already exists. |
| Double-checked locking | Yes | First call only | Check without the lock, take the lock on the first miss, then check again inside it. The field must be volatile (or the language equivalent), or a thread can observe a half-constructed object through reordering — the classic trap. |
| Holder idiom (Java) | Yes | None | Hold the instance in a static inner class; the JVM runs that class's initializer exactly once, on first access. The idiomatic Java answer. |
| Enum (Java) | No | None | A one-element enum is a singleton the language guarantees, and it is safe against reflection and serialization as well. |
The Java example above uses the holder idiom and the Python example uses double-checked locking,
so both are already thread-safe. In an interview, the expected move is to notice the race in the
naive version and offer one correct alternative with its trade-off: eager initialization and the
holder idiom are clean answers, while double-checked locking with volatile shows that you
understand the memory-model subtlety. Reasoning about the memory model in more depth is a bonus,
not a requirement.
Common drawbacks
A hidden dependency occurs when OrderService calls ConfigManager.getInstance() inside
its body. That dependency is invisible to anyone reading the constructor signature. A new
engineer cannot tell by looking at OrderService that it requires a loaded config; the class
appears self-contained but relies on global state.
Testing becomes harder as a result. To test OrderService with a different configuration —
say, a test config that points at a sandbox database — you cannot simply pass a different
object. You have to reach into the singleton and swap its internals, or reset the _instance
field between tests, or use a mocking framework that can intercept static calls. Each approach
is more fragile than passing a plain object to the constructor.
Global mutable state introduces all the hazards of a global variable. A singleton that can
be mutated (a cache, a metrics collector with a flush() method, a config that can be
reloaded at runtime) allows any code anywhere to change the state, making the order of
operations unpredictable and race conditions subtle.
Language caveat: Python modules are natural singletons
In Python, a module is loaded exactly once per interpreter process and cached in sys.modules.
If you need one shared config object, the idiomatic approach is to create it at module level:
1# config.py — the module itself is the singleton.
2# Python's import system guarantees this executes exactly once.
3_settings: dict[str, str] = {"db_host": "db.internal", "log_level": "INFO"}
4
5def get(key: str) -> str:
6 return _settings[key]
7
8# Any other file: import config; config.get("db_host")
9# No class, no _instance field, no locking boilerplate needed.
This is idiomatic Python. Reaching for a Singleton class in Python almost always adds complexity without benefit, unless you need lazy initialization or you are writing a library that must work inside other frameworks that do not share your module cache.
When NOT to use this
In most cases, the problems the Singleton addresses — needing exactly one instance and needing it accessible throughout the application — are better solved with dependency injection: create the shared object once at application startup and pass it into every class that needs it via the constructor.
1# Prefer this over a Singleton.
2# The dependency is visible, testable, and replaceable.
3
4class OrderService:
5 def __init__(self, config: ConfigManager) -> None:
6 self._config = config # passed in, not fetched globally
7
8# At startup:
9config = ConfigManager()
10order_service = OrderService(config)
11
12# In tests: pass a fake config — no global state to reset.
13fake_config = FakeConfigManager({"db_host": "localhost"})
14order_service = OrderService(fake_config)
Dependency injection makes the dependency visible, allows substituting a test double without touching global state, and keeps the code straightforward to reason about. Use the Singleton only when the shared object genuinely cannot be injected — for example, in legacy systems, third-party libraries that offer no other integration point, or platform APIs that are themselves global by design.