Singleton and its alternatives
Exactly one instance, and why dependency injection is usually better.
01The intuition
The problem it solves
A program has one configuration, loaded from a file, that many classes need. The first version loads it wherever it is needed.
class Config:
def __init__(self):
with open("settings.json") as f:
self.values = json.load(f)
class Mailer:
def send(self, msg):
host = Config().values["smtp_host"] # reads the file again
...
class Report:
def render(self):
title = Config().values["school_name"] # and again
The file is read every time, which is slow and, worse, can produce different values if the file changes mid-run. The natural wish is "there should be exactly one Config". The singleton pattern makes a class responsible for ensuring that only one instance ever exists and for handing it out. It is also the pattern most often misused in interviews, so this article builds it and then shows the better alternative.
Build it one piece at a time
Step 1: cache the one instance on the class. The first call builds it; every later call returns the same object.
class Config:
_instance = None
@classmethod
def get(cls) -> "Config":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
with open("settings.json") as f:
self.values = json.load(f)
Step 2: make it safe when two threads call at once. Without a lock, two threads can both see None and build two instances. A lock around the check fixes that; checking twice avoids paying for the lock after the first time.
import threading
class Config:
_instance = None
_lock = threading.Lock()
@classmethod
def get(cls) -> "Config":
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
Step 3: notice what the singleton did to your tests. Mailer now calls Config.get() inside its method. A test that wants a different SMTP host must mutate the global instance, and tests that run in parallel fight over it. The hidden dependency is the real cost.
Step 4: keep "one instance" and drop "global access". Build the object once in main and hand it to whoever needs it. This is dependency injection. Oneness is a decision of the wiring code, not a property enforced by the class.
class Mailer:
def __init__(self, config: Config):
self._config = config
def send(self, msg):
host = self._config.values["smtp_host"]
def main():
config = Config() # created exactly once, here
mailer = Mailer(config)
report = Report(config)
def test_mailer_uses_host():
fake = Config.__new__(Config); fake.values = {"smtp_host": "test.local"}
assert Mailer(fake).host() == "test.local"
| Step | Instances at runtime | Thread safe | Mailer's dependency visible in its signature | Tests can substitute |
|---|---|---|---|---|
| Start | many | n/a | no | no |
| 1 | one, usually | no | no | only by mutating a global |
| 2 | one | yes | no | only by mutating a global |
| 4 | one, by wiring | yes | yes | yes |
Why it works
The classic singleton guarantees two things at once: at most one instance, and a global way to reach it. The first is often wanted; the second is what causes trouble, because every class that calls get() has a dependency you cannot see from its constructor and cannot replace. The double-checked lock is correct in Python because assignment of a reference is atomic and the second check runs under the lock.
Where it breaks: tests that leak state between each other through the global, two different configurations needed in one process (a migration tool reading two environments), and the hidden order-of-initialisation problems when singletons depend on each other. Each is solved by the injected version, which keeps the one-instance guarantee where it belongs, in the composition root.
The trade-off you must say out loud
Say both halves: "I want one instance; I do not want global access". If the interviewer asks for a singleton, implement the thread-safe form, then say what you would do in a real codebase and why, in one sentence about tests. The follow-up is usually "when is a true singleton justified", and the fair answers are a process-wide resource that genuinely cannot be duplicated, such as a hardware handle or a logging sink, and even then injected at the edges.
Where it shows up in the 100 Google problems
- Design a thread-safe singleton and justify the alternative: the whole question is steps 2 and 4 above, and the "justify" half is where the marks are.
- Design a logging framework: the root logger is one per process, built once and passed down, with a module-level accessor only as a convenience that tests can swap.
- Design a parking lot: a single
ParkingLotis the classic textbook singleton; the better answer is one lot constructed in main, because the test suite wants a fresh lot per test. - Design a blocking connection pool: one pool per database, created at startup and injected into every client, never fetched through a global.