Facebook Pixel

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:

ApproachLazyLocking costNotes
Eager initializationNoNoneThe 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 accessorYesEvery callLock the whole accessor. Correct, but every caller pays for the lock even after the instance already exists.
Double-checked lockingYesFirst call onlyCheck 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)YesNoneHold 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)NoNoneA 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.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro