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).

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.ContextVar or a with context 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.