Singleton pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- A singleton guarantees one instance of a type within some scope and gives code a way to reach it. The two halves are separable, and only the first one is usually worth having.
- "One instance" is the useful part: a connection pool sized to the database's limit, a metrics registry, a parsed configuration. "Reachable from anywhere" is the harmful part: it is global mutable state with a nicer name, and it hides dependencies from signatures and from tests.
- The modern answer is to create the one instance in the composition root (your
main, or a dependency-injection container) and pass it in. You keep "one per process" and lose the hidden global. - Lazy creation must be safe under concurrency. Use the runtime's primitive (Go
sync.Once, Python's module import lock, the Java class-initialisation lock, the JS module cache) rather than hand-rolled double-checked locking. - "One per process" is not "one per system". Forty pods each holding "the" pool of 20 connections is 800 connections to a database that allows 500.
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._instanceEvery 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.
- Python modules are singletons. A module's top-level code runs once per interpreter, and every later
importreturns the cached module object fromsys.modules. A module-levelflags = FeatureFlags(...)is a singleton without any class machinery. - JS modules are cached too. An ES module is evaluated once per realm and keyed by its resolved URL; CommonJS caches by resolved filename in
require.cache.export const flags = new FeatureFlags()is shared by every importer. - Go has
sync.Onceand, since Go 1.21,sync.OnceValue(f), which returns a function that callsfonce and caches its result for every caller.
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
- Stateless or effectively immutable values: a compiled regular expression, a lookup table, a parsed timezone database. Sharing them is safe and there is nothing to fake in tests.
- Things that are truly one per process: the process's logger root,
os.Stdout, a metrics registry that a/metricshandler scrapes. Python'slogging.getLogger(name)returns the same logger for the same name for exactly this reason. - Leaf utilities with no I/O. If a test would never want a different one, a global costs little.
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
- 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.
- Sharing between threads needs safe publication, so runtimes provide a once primitive with an atomic fast path instead of making every reader lock.
- A shared instance reachable by name is convenient, but it removes the dependency from every signature, which breaks substitution in tests.
- 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 --> UWorked 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
Classic singleton class with instance() | Familiar; lazy | Hidden dependency; test leakage; easy to get the locking wrong | Rarely; legacy code you cannot restructure yet |
| Module-level instance (Python, JS) | No boilerplate; runtime handles once-only | Created at import time; still a hidden global | Immutable values and leaf utilities with no I/O |
sync.Once / OnceValue / lazy holder | Correct lazy init with a cheap fast path | Failure semantics need care; still global if exported | Expensive, rarely needed resources inside one package |
Create in main, inject | Visible dependencies; trivial to fake; one place controls lifetime | Constructors take more parameters; wiring code to maintain | Anything stateful or doing I/O, which is most services |
| DI container with singleton scope | Lifetime per service is declarative; scales to large graphs | Magic at a distance; errors appear at resolve time | Large apps with many services and several scopes |
Pitfalls in production
- I/O at import time (connect to Redis, read a secret), which makes imports slow, fails tests that never use the client, and runs before logging is configured. Fix: construct in the app factory or
main. - Hand-written double-checked locking without an atomic or
volatileflag, so another thread can see a non-null pointer to a half-constructed object. Fix: use the runtime primitive. - A singleton created before
fork()sharing sockets or held locks with the child. Fix: create pools after fork, or register an after-fork hook that rebuilds them. - Mutable shared state behind one lock becoming the throughput ceiling. Fix: make it immutable and swap with an atomic pointer, or shard it per core or per key.
- Two copies of the "singleton" because a package is installed twice or a module is imported under two paths. Fix: dedupe dependencies and treat "exactly one" as something to assert in a test, not assume.
sync.Onceswallowing a failed init forever. Fix: useOnceValuesand return the error, or a small lazy holder that only caches success.
Leadership lens
- Ask "one per what?" in design review: per process, per pod, per region, per tenant. The answer decides pool sizes and whether you need a shared service instead of a shared object.
- Treat new module-level clients and
getInstance()accessors as a review flag, especially in agent-written code. The cost shows up months later as flaky tests and slow suites. - Budget the migration honestly. Moving from globals to injection is mostly mechanical, which makes it a good task to delegate to an agent with a clear instruction and a test suite as the oracle.
- Keep a single composition root per binary that a new engineer can read top to bottom to see every long-lived object and its lifetime.
Check yourself
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.
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.
When is Go's
sync.Oncefailure behaviour a problem?Show answer
If the init function fails or panics,
Oncestill marks itself done, so later callers get a zero value forever. For transient failures, cache only success or return the error withOnceValues.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
- The Clean Code Talks: Global State and Singletons (Misko Hevery, Google TechTalks): why singletons make tests lie about their dependencies, with concrete examples.
- The Clean Code Talks: Don't Look For Things! (Misko Hevery, Google TechTalks): the case for asking for dependencies instead of fetching them from globals.
- Singleton Pattern (Christopher Okhravi): the classic structure and a critical look at when it is justified.
Free resources
- The Singleton Pattern (Brandon Rhodes, python-patterns.guide): why Python mostly does not need the class form, and what to use instead.
- Root Cause of Singletons (Google Testing Blog): how global access, not single instance, is what hurts testability.
- Inversion of Control Containers and the Dependency Injection pattern (Martin Fowler): the original description of constructor injection and service locators.
- sync package: Once and OnceValue (Go docs): the exact semantics, including what happens when the function panics.
- The "Double-Checked Locking is Broken" Declaration (Bill Pugh et al.): why hand-rolled lazy init fails without proper memory-model guarantees.
- Node.js modules: caching (Node docs): how
requirecaches modules by resolved filename, and why that makes modules singletons.