Engineering Atlas › Low-Level Design

Singleton pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The classic shape

The Gang of Four version hides the constructor and exposes a static accessor:

class FeatureFlags:
    _instance = None

    @classmethod
    def instance(cls):
        if cls._instance is None:
            cls._instance = FeatureFlags(load_flags_from_disk())
        return cls._instance

Every caller writes FeatureFlags.instance().enabled("new_checkout"). It works, and it creates three problems at once. The dependency is invisible: nothing in a function's signature says it reads flags. The instance is shared across tests: a test that flips a flag leaks into the next test, and the order tests run in starts to matter. And the check-then-create in instance() is a race: two threads can both see None and both build one.

What languages already give you

Most languages have a built-in "run this once" mechanism, and it is usually the better tool.

These remove the race and the boilerplate. They do not remove the hidden-global problem, because any code can still import the instance directly.

Keep "one", drop "global"

The fix is to separate how many from how it is reached. Create the instance once at startup and hand it to the things that need it:

func main() {
    cfg := config.Load()
    pool := db.NewPool(cfg.DSN, 20)       // the one pool
    flags := flags.New(cfg.FlagsPath)
    orders := orders.NewService(pool, flags)
    http.ListenAndServe(":8080", api.New(orders))
}

main is the only place that knows there is exactly one pool. orders.NewService declares that it needs a pool and flags; a test passes a fake pool and a flags object with two entries. The lifetime is still "one per process", but it is now a decision made in one file instead of a property baked into the type. A dependency-injection container does the same thing at larger scale: it registers a factory per service with a scope (singleton, per-request, transient) and builds the graph on demand, which is what the Practice section implements.

When a real singleton is fine

The warning signs are mutable state, I/O in the constructor, and any test that has to reset or monkey-patch the instance.

Reviewing agent-written code

Asked to "add a Redis cache", an agent will often write redis_client = Redis(host=os.environ["REDIS_HOST"]) at module top level and import it wherever needed. Importing the module in a unit test now needs Redis or an environment variable, and the tests fill up with mock.patch("app.cache.redis_client"), each one an admission that the dependency should have been a parameter. In review, look for module-level clients that do I/O, global statements, getInstance() accessors and tests that patch module attributes. Then ask for the concrete fix: "construct the client in main (or the app factory) and pass it to the service constructor."

The same shape elsewhere

A singleton with mutable state is the hot spot problem shape: one object every request touches, like a hot key in a cache or a counter row every transaction updates in Postgres. It is correct and slow under load, and the fixes match: shard the state (per-core counters, striped locks) or make it immutable and swap it atomically.

Down the stack

Layer 1The contract

A once-initialised value promises: the initialiser runs at most once per scope (process, module, container) when it succeeds; every caller sees the fully constructed object, never a half-built one; initialisation happens-before every use, in the memory-model sense. The failure contract varies and is worth testing. Go's sync.Once marks itself done even if the function panics, so later calls do nothing and the value stays zero. sync.OnceValue re-panics with the same value on every call. A Python module that raises during import is removed from sys.modules, so the next import runs it again. Decide which one you want: "retry on failure" is right for a network client, "fail forever" is right for corrupt configuration.

Layer 2The mechanism

Go's sync.Once is a 32-bit done flag plus a Mutex. Do first does an atomic load of done; if set, it returns immediately. Otherwise it takes the slow path: lock, check done again, call f, then store done = 1 with a deferred atomic store before unlocking. Other goroutines that arrive during f block on the mutex and then see done set. This is double-checked locking done correctly, because the flag is atomic.

Python's import system holds a per-module lock (_ModuleLock in importlib._bootstrap) while the module body runs, and inserts the module into sys.modules before executing it, which is why circular imports see a partially initialised module rather than deadlocking. Java's lazy holder idiom (static class Holder { static final X INSTANCE = new X(); }) rides on the class-initialisation lock that JLS section 12.4.2 requires the JVM to take. Node resolves a specifier to a filename and checks require.cache before reading the file; ES modules keep a module map keyed by URL. The catch in both: two installed copies of a package (say, two versions in nested node_modules) are two modules and therefore two singletons, the "dual package hazard" in Node's docs.

Layer 3The machine

The fast path is cheap. On x86-64 an acquire load is an ordinary MOV, because the hardware's total store order already gives acquire semantics; on ARM64 it is an LDAR. Either way it is about 1 ns when the line is in L1. The slow path runs once.

The cost appears when the shared object has mutable state. A mutex-guarded counter updated by 32 cores moves its cache line between cores on every update, 50 to 100 ns per transfer, so throughput stops scaling. Allocation is once, but the object is a GC root for the life of the process, and anything it references (a growing cache map) is scanned on every collection. fork() copies the singleton into the child, including any lock that another thread held at the moment of the fork, which can deadlock the child, and including open sockets, so a parent and child sharing one database connection interleave bytes on the same TCP stream. Pre-forking servers such as gunicorn with --preload hit this when a pool is created at import time; the fix is to create pools after the fork.

Why it's built this way

  1. Some resources are expensive to create (TLS handshakes, connection setup, parsing large config) or limited downstream (database connections), so you want one and share it.
  2. Sharing between threads needs safe publication, so runtimes provide a once primitive with an atomic fast path instead of making every reader lock.
  3. A shared instance reachable by name is convenient, but it removes the dependency from every signature, which breaks substitution in tests.
  4. So keep the once-only creation in one place, the composition root, and pass the instance down. The runtime primitive handles concurrency; the constructor parameters handle visibility.

Diagram

flowchart TD
  A["Get()"] --> B{"done flag set? (atomic load)"}
  B -- yes --> R["return cached value"]
  B -- no --> L["lock mutex"]
  L --> C{"done set? (re-check)"}
  C -- yes --> U["unlock"] --> R
  C -- no --> F["run initialiser"]
  F --> S["store value, done = 1 (atomic)"]
  S --> U

Worked example

A payments service has a module-level db = Database(os.environ["DSN"]) and 1,400 unit tests. Tests that need data write rows into a shared SQLite file; tests that need isolation patch payments.db.db. Over a quarter the suite has a 6% flake rate. Investigation finds 31 tests that pass alone and fail after a specific other test, all because of rows or patches left on the shared instance.

The team moves construction into create_app(settings), makes services take db as a constructor parameter, and gives each test a fresh in-memory database. The change touches 140 files, mostly deleting mock.patch lines. Flakes fall below 0.5%, and with nothing shared the suite runs in parallel with pytest -n 8, from 9 minutes to 2.

The production side has its own arithmetic. The service runs 25 pods, each with 4 worker processes, each with a "singleton" pool of 10: 1,000 connections against a Postgres limit of 400. The singleton was correct per process and wrong for the system. The fix is either a smaller per-process pool (25 x 4 x 3 = 300) or a shared pooler such as PgBouncer in front of the database, which is the real "one per system" instance.

Trade-offs

OptionStrengthsWeaknessesChoose when
Classic singleton class with instance()Familiar; lazyHidden dependency; test leakage; easy to get the locking wrongRarely; legacy code you cannot restructure yet
Module-level instance (Python, JS)No boilerplate; runtime handles once-onlyCreated at import time; still a hidden globalImmutable values and leaf utilities with no I/O
sync.Once / OnceValue / lazy holderCorrect lazy init with a cheap fast pathFailure semantics need care; still global if exportedExpensive, rarely needed resources inside one package
Create in main, injectVisible dependencies; trivial to fake; one place controls lifetimeConstructors take more parameters; wiring code to maintainAnything stateful or doing I/O, which is most services
DI container with singleton scopeLifetime per service is declarative; scales to large graphsMagic at a distance; errors appear at resolve timeLarge apps with many services and several scopes

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a module-level instance in Python remove the race but not the testing problem?

    Show answer

    The import lock guarantees the body runs once, so there is no race, but any code can still import the instance directly, so tests must patch it and state leaks between them.

  2. Your service runs 12 pods with 8 worker processes each and a pool of 15 per process. The database allows 1,000 connections. What is wrong?

    Show answer

    The singleton is per process: 12 x 8 x 15 = 1,440 connections, over the limit. Shrink per-process pools or put a shared pooler in front.

  3. When is Go's sync.Once failure behaviour a problem?

    Show answer

    If the init function fails or panics, Once still marks itself done, so later callers get a zero value forever. For transient failures, cache only success or return the error with OnceValues.

  4. Why is a stateless singleton, such as a compiled regex, acceptable when a database client is not?

    Show answer

    Nothing about it varies between tests or environments and sharing it is safe across threads, so hiding it costs nothing; a client has state, I/O and environment-specific configuration that tests need to replace.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →