Singleton (and the safe Pythonic substitutes)
When to use
- Rarely. You want one shared instance per process (e.g., expensive client/pool, metrics reporter, config).
- You need a single point of access and want to avoid recreating it.
Avoid when
- Tests need isolation, configs vary per request, or lifetime must be explicit. Prefer dependency injection.
Diagram (text)
App code ──> get_client() (cached factory)
│
returns same
instance each call
Python example (≤40 lines, type-hinted)
Use a cached factory (Pythonic), not the classic “Singleton class” hack.
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
@dataclass(frozen=True)
class SnowflakeConfig:
account: str = "acct"; user: str = "svc"; password: str = "secret"
class SnowflakeClient:
def __init__(self, cfg: SnowflakeConfig): self.cfg, self.closed = cfg, False
def run(self, sql: str) -> str: return f"{self.cfg.user}@{self.cfg.account}:{sql}"
def close(self) -> None: self.closed = True
@lru_cache(maxsize=1)
def get_client() -> SnowflakeClient:
return SnowflakeClient(SnowflakeConfig()) # load from env/secret in real code
def execute(sql: str) -> str:
return get_client().run(sql)
def shutdown() -> None:
c = get_client(); c.close(); get_client.cache_clear()
Tiny pytest (cements it)
def test_process_singleton_and_shutdown():
a = get_client(); b = get_client()
assert a is b
assert execute("SELECT 1").endswith("SELECT 1")
shutdown()
c = get_client()
assert a is not c and a.closed is True
Trade-offs & pitfalls
- Pros: One place to configure/construct; avoids repeated heavy setup; simple call sites.
- Cons: Hidden global state hurts tests; hard to vary per-request; lifecycle (close/reopen) can get messy; multiple processes/containers each have their “own singleton.”
- Pitfalls / anti-patterns:
- Classic GoF Singleton via
__new__/module globals—hard to test/mutate; import-order bugs. - Not providing a reset (
shutdown()), causing stale creds or leaked connections. - Assuming “one per system”—in reality it’s per process (serverless/multiprocess ⇒ many).
- Classic GoF Singleton via
Pythonic alternatives
- Dependency Injection (preferred): pass clients into constructors; build them in an app “compose” function.
- Cached factory (shown):
@lru_cache(maxsize=1)or@functools.cache; call.cache_clear()in tests/shutdown. - Request-scoped singletons: use
contextvars.ContextVaror awithcontext manager to have one per request. - Module constants/config: if it’s truly immutable data, just compute once at import.
- Libraries: Pydantic settings for env-driven config; connection pools managed by the driver.
Mini exercise
Add a get_metrics() cached factory returning a fake MetricsClient with inc(name) and close(). Wire shutdown() to close both clients and clear both caches. Write a test proving both are reused, then re-created after shutdown.
Checks (quick checklist)
- Prefer DI; reach for singletons only when truly shared and process-wide.
- If you use a cached factory, provide a shutdown/reset.
- No business logic in the factory; just construction.
- Tests verify reuse and clean teardown.
- Don’t assume one instance across processes—document the scope.




