Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Design Patterns (GoF and modern)  Module 2 – Creational patterns: Factory Method to Singleton

Singleton: one instance and its real costs

Singleton promises one instance and global access. Thread-safe lazy creation in six languages, the race it hides, its cost in tests, and how DI replaces it.

  • Intermediate
  • 35 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, Java 25, Go 1.27.1, Clang 21.0.0 and Rust 1.99.0
  • By MySmartCoPilot
Free: 35 minutes

What you will learn

  • Implement a safe, lazily initialised single instance in each of the six languages
  • Explain why Singleton makes testing and reasoning harder, and when it is still acceptable
  • Replace a Singleton with dependency injection of one shared instance
  • Recognise module-level singletons, multitons and registries

Before you start

What you’ll learn · Before you start

What you will learn

  • Implement a safe, lazily initialised single instance in each of the six languages
  • Explain why Singleton makes testing and reasoning harder, and when it is still acceptable
  • Replace a Singleton with dependency injection of one shared instance
  • Recognise module-level singletons, multitons and registries

Before you start

On this page

Singleton is a creational design pattern that ensures a class has exactly one instance and gives the whole program a global way to reach it, usually a static get() or instance() method. It makes two promises, and they are worth separating. One instance is often exactly right: one configuration, one connection pool, one log. Global access is what causes most of the harm: classes that fetch the instance hide a dependency their constructors do not show, any code can change the shared state, and tests that touch it start to depend on the order they run in. Creating the instance lazily, on first use, adds a race of its own. Most modern code keeps the single instance and drops the global access: create it once at start-up and pass it to the classes that need it.

This lesson reproduces the lazy-creation race in Python, shows the safe single instance of Java, TypeScript, Go, C++ and Rust under 16 threads, finds two ways one “singleton” quietly becomes two, measures the cost in tests, and ends with the cases where a single instance is the right design.

Two promises, and what each costs

The catalogue’s version is a class that hides its constructor and offers a static method returning the one instance, creating it on the first call. Each half of that design has a price:

  • Global access turns the instance into a global variable with a nicer name. A class that calls Settings.get() inside its methods depends on Settings, but nothing in its signature says so, so readers and tests discover it the hard way.
  • Shared mutable state means a change made by one part of the program, or by one test, is seen everywhere, and undoing it becomes somebody’s job.
  • Lazy creation has to be safe when two threads, or two asynchronous tasks, ask at the same moment, or the “one” instance is created several times.
  • Lifetime is the whole process: the instance is created at a moment you do not choose and is hard to replace, which is exactly what a test wants to do.

The race in lazy creation

Three lazy getters raced by 16 threads Python · race/lazy.py
"""Three lazy getters raced by 16 threads that start at the same moment. The settings take 50 ms
to load, as reading a file or asking a server would, which is long enough for the other
threads to arrive before the first load ends."""

import functools
import threading
import time


class Loads:
    """Counts loads safely from many threads."""

    def __init__(self):
        self.count, self._lock = 0, threading.Lock()

    def load(self):
        with self._lock:
            self.count += 1
        time.sleep(0.05)
        return {"currency": "INR", "tax_rate_percent": 18}


def race(get, threads=16):
    """Calls get() from `threads` threads released together; returns how many different objects
    they received."""
    barrier, seen, lock = threading.Barrier(threads), [], threading.Lock()

    def worker():
        barrier.wait()
        value = get()
        with lock:
            seen.append(value)

    workers = [threading.Thread(target=worker) for _ in range(threads)]
    for w in workers:
        w.start()
    for w in workers:
        w.join()
    return len({id(v) for v in seen})


# 1. Check, then create: every thread that checks before the first load has finished loads
#    again.
loads = Loads()


class NaiveSettings:
    _instance = None

    @classmethod
    def get(cls):
        if cls._instance is None:
            cls._instance = loads.load()
        return cls._instance


race(NaiveSettings.get)
print(f"check, then create:   loaded more than once: {loads.count > 1}")

# 2. functools.cached_property: since Python 3.12 it takes no lock, so its getter can run once
#    per thread.
loads = Loads()


class Config:
    @functools.cached_property
    def settings(self):
        return loads.load()


config = Config()
race(lambda: config.settings)
print(f"cached_property:      loaded more than once: {loads.count > 1}")

# 3. The check and the creation inside one lock: one load, and every thread gets the same
#    object.
loads = Loads()


class LockedSettings:
    _instance = None
    _lock = threading.Lock()

    @classmethod
    def get(cls):
        with cls._lock:
            if cls._instance is None:
                cls._instance = loads.load()
        return cls._instance


distinct = race(LockedSettings.get)
print(f"check inside a lock:  loaded {loads.count} time; threads got {distinct} object")

Output

check, then create:   loaded more than once: True
cached_property:      loaded more than once: True
check inside a lock:  loaded 1 time; threads got 1 object

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy.py

The settings take 50 ms to load, and 16 threads ask for them at the same moment:

  1. Check, then create. Every thread that runs if cls._instance is None before the first load has finished sees nothing and loads again. The “one” instance was created several times, and different threads may keep different copies.
  2. functools.cached_property looks like a ready-made lazy attribute, but its documentation says that since Python 3.12 it no longer takes a lock, so its getter can run more than once on the same instance when threads race, and the last run wins.
  3. The check inside a lock makes the check and the creation one step: the other 15 threads wait, then find the instance. One load, one object.

The free-threaded build of CPython, which PEP 779 made officially supported in Python 3.14, runs Python threads truly in parallel, so races like these get easier to hit, not harder. The pattern itself does not depend on threads: the TypeScript version below shows the same race with asynchronous callers in a single thread.

Python’s built-in singleton: the module

Python has a single instance built in: the module. The programming FAQ relies on it when it explains how to share settings across modules: there is only one instance of each module, so a change to a module’s attribute is seen by every importer. A module of settings is the idiomatic Python singleton, until the same file gets a second name:

One module object, then the same file under two names

modules/main.py

"""A module is Python's built-in singleton, until the same file is imported under two names."""

import logging
import sys

import app.config
import app.config as again

print("imported twice, one module object:", app.config is again)

# A second entry on sys.path makes the same file importable as "config" as well as "app.config".
sys.path.insert(0, "app")
import config  # noqa: E402

print("app.config is config:", app.config is config)
config.settings["currency"] = "USD"
print("app.config still says:", app.config.settings["currency"])

# A registry of named instances: one logger per name, for the whole process.
print("one logger per name:", logging.getLogger("shop.orders") is logging.getLogger("shop.orders"))

Output

running config.py as the module 'app.config'
imported twice, one module object: True
running config.py as the module 'config'
app.config is config: False
app.config still says: INR
one logger per name: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py

app/config.py · modules/app/config.py

"""The application's settings: one module object, so one set of settings, for as long as it has
one name."""

print(f"running config.py as the module {__name__!r}")

settings = {"currency": "INR", "tax_rate_percent": 18}

Importing app.config twice returns one module object, because Python keeps every imported module in sys.modules under its name. But the cache is keyed by that name, not by the file. When sys.path also contains the app folder, as it does when a script inside a package is run directly or a tool adds folders to the path, the same file is importable as config too. The output shows config.py running twice, two module objects, and a currency change made through one of them that the other never sees.

The last line shows the friendlier relative of Singleton: a registry of named instances, sometimes called a multiton. logging.getLogger(name) returns the same logger for the same name for the whole process, so modules can share a logger without passing it around, and the name makes it explicit which one they share.

One instance in six languages

Each program below creates its instance lazily and lets 16 threads (in TypeScript, 16 asynchronous callers) ask for it at the same moment. The Go program runs under the race detector and the C++ program under ThreadSanitizer, so a data race would be reported in the output.

A lazy single instance in Java, TypeScript, Go, C++ and Rust

Java · compiled/Lazy.java

// One lazily created instance in Java 25, raced by 16 threads that start together; the settings
// take 50 ms to load.
// Run: java Lazy.java
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public class Lazy {
    static final AtomicInteger LOADS = new AtomicInteger();

    static Map<String, String> load() {
        LOADS.incrementAndGet();
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return Map.of("currency", "INR");
    }

    // 1. Check, then create, with no synchronisation: a data race, and several loads.
    static final class Naive {
        private static Map<String, String> instance;

        static Map<String, String> get() {
            if (instance == null) instance = load();
            return instance;
        }
    }

    // 2. The holder idiom: Holder is initialised on first use, and the JVM's class
    //    initialisation lock (JLS 12.4.2) makes every other thread wait until it is done.
    static final class Settings {
        private static final class Holder {
            static final Map<String, String> INSTANCE = load();
        }

        static Map<String, String> get() {
            return Holder.INSTANCE;
        }
    }

    // 3. An enum with one constant: the JLS allows no other instances of an enum class (JLS
    //    8.9).
    enum Rates {
        INSTANCE;

        final Map<String, String> values = load();
    }

    /** Calls get() from 16 threads released together; returns how many objects they received. */
    static int race(Supplier<Object> get) throws InterruptedException {
        CountDownLatch start = new CountDownLatch(1);
        Set<Object> byIdentity = Collections.newSetFromMap(new IdentityHashMap<>());
        Set<Object> seen = Collections.synchronizedSet(byIdentity);
        Thread[] threads = new Thread[16];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = Thread.ofPlatform().start(() -> {
                try {
                    start.await();
                    seen.add(get.get());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
        start.countDown();
        for (Thread t : threads) t.join();
        return seen.size();
    }

    public static void main(String[] args) throws InterruptedException {
        LOADS.set(0);
        race(Naive::get);
        System.out.println("check, then create: loaded more than once: " + (LOADS.get() > 1));
        LOADS.set(0);
        int distinct = race(Settings::get);
        int loads = LOADS.get();
        System.out.println("holder idiom:       loaded " + loads + " time;"
                + " threads got " + distinct + " object");
        LOADS.set(0);
        distinct = race(() -> Rates.INSTANCE.values);
        loads = LOADS.get();
        System.out.println("enum:               loaded " + loads + " time;"
                + " threads got " + distinct + " object");
    }
}

TypeScript · compiled/lazy.ts

// One lazily loaded value shared by 16 concurrent async callers in TypeScript. JavaScript runs
// one task at a time, yet the same race appears: every caller that checks while the first load
// is awaited starts a load of its own.
// Run: node lazy.ts
type Rates = Map<string, string>;

let loads = 0;

async function load(): Promise<Rates> {
  loads += 1;
  await new Promise((resolve) => setTimeout(resolve, 50)); // reading a file or asking a server
  return new Map([["currency", "INR"]]);
}

// 1. Cache the value: the check happens before the await, so all 16 callers see nothing cached.
let value: Rates | undefined;
async function cachedValue(): Promise<Rates> {
  if (value === undefined) value = await load();
  return value;
}

// 2. Cache the promise: every caller awaits the one load in flight; a failed load is forgotten
//    so a later call retries.
let pending: Promise<Rates> | undefined;
function cachedPromise(): Promise<Rates> {
  pending ??= load().catch((error: unknown) => {
    pending = undefined;
    throw error;
  });
  return pending;
}

async function main(): Promise<void> {
  const first = await Promise.all(Array.from({ length: 16 }, () => cachedValue()));
  console.log(
    `cache the value:   loaded ${loads} times; callers got ${new Set(first).size} objects`,
  );
  loads = 0;
  const second = await Promise.all(Array.from({ length: 16 }, () => cachedPromise()));
  console.log(
    `cache the promise: loaded ${loads} time; callers got ${new Set(second).size} object`,
  );
}

void main();

Go · compiled/lazy.go

// One lazily created value in Go: sync.OnceValue runs the function once, however many
// goroutines ask at the same time, and all of them get its result.
// Run with the race detector: go run -race lazy.go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var loads atomic.Int32

func load() *map[string]string {
	loads.Add(1)
	time.Sleep(50 * time.Millisecond) // reading a file or asking a server
	return &map[string]string{"currency": "INR"}
}

var settings = sync.OnceValue(load)

func main() {
	start := make(chan struct{})
	var wg sync.WaitGroup
	var mu sync.Mutex
	seen := map[*map[string]string]bool{}
	for range 16 {
		wg.Go(func() {
			<-start
			s := settings()
			mu.Lock()
			seen[s] = true
			mu.Unlock()
		})
	}
	close(start)
	wg.Wait()
	fmt.Printf("sync.OnceValue: loaded %d time; goroutines got %d object\n", loads.Load(), len(seen))
}

C++ · compiled/lazy.cpp

// One lazily created object in C++23: a function-local static is initialised once, and threads
// that arrive during the initialisation wait for it ([stmt.dcl]).
// Build with ThreadSanitizer, which reports any data race:
// clang++ -std=c++23 -g -fsanitize=thread lazy.cpp -o lazy
#include <atomic>
#include <chrono>
#include <latch>
#include <map>
#include <mutex>
#include <print>
#include <set>
#include <string>
#include <thread>
#include <vector>

std::atomic<int> loads{0};

std::map<std::string, std::string> load() {
    ++loads;
    // reading a file or asking a server
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    return {{"currency", "INR"}};
}

const std::map<std::string, std::string>& settings() {
    static const std::map<std::string, std::string> instance = load();
    return instance;
}

int main() {
    std::latch start{1};
    std::mutex mu;
    std::set<const void*> seen;
    std::vector<std::jthread> threads;
    for (int i = 0; i < 16; ++i) {
        threads.emplace_back([&] {
            start.wait();
            const auto* s = &settings();
            std::scoped_lock lock{mu};
            seen.insert(s);
        });
    }
    start.count_down();
    threads.clear();  // jthread joins when it is destroyed
    std::println("function-local static: loaded {} time; threads got {} object", loads.load(),
                 seen.size());
}

Rust · compiled/lazy.rs

// One lazily created value in Rust: LazyLock runs its closure on first use, and threads that
// arrive meanwhile wait; OnceLock does the same with the closure given at the call.
// Build: rustc --edition 2024 lazy.rs
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Barrier, LazyLock, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

static LOADS: AtomicUsize = AtomicUsize::new(0);

fn load() -> HashMap<&'static str, &'static str> {
    LOADS.fetch_add(1, Ordering::SeqCst);
    thread::sleep(Duration::from_millis(50)); // reading a file or asking a server
    HashMap::from([("currency", "INR")])
}

static SETTINGS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(load);
static RATES: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();

/// Calls get() from 16 threads released together; returns how many different objects they received.
fn race(get: fn() -> &'static HashMap<&'static str, &'static str>) -> usize {
    let barrier = Barrier::new(16);
    let seen = Mutex::new(HashSet::new());
    thread::scope(|scope| {
        for _ in 0..16 {
            scope.spawn(|| {
                barrier.wait();
                let address = get() as *const _ as usize;
                seen.lock().unwrap().insert(address);
            });
        }
    });
    seen.into_inner().unwrap().len()
}

fn main() {
    let distinct = race(|| &SETTINGS);
    println!(
        "LazyLock: loaded {} time; threads got {} object",
        LOADS.swap(0, Ordering::SeqCst),
        distinct
    );
    let distinct = race(|| RATES.get_or_init(load));
    println!(
        "OnceLock: loaded {} time; threads got {} object",
        LOADS.load(Ordering::SeqCst),
        distinct
    );
}
All five run with 16 callers at once

compiled/languages_session.py

from session import run, versions

# One lazily created instance raced by 16 threads (or 16 async callers) in five languages. Go
# runs under its race detector and C++ under ThreadSanitizer, so a data race would be reported
# here.
versions("javac --version", "node --version", "go version", "clang++ --version", "rustc --version")
run("""
java Lazy.java
node lazy.ts
go run -race lazy.go
clang++ -std=c++23 -g -Wall -Wextra -fsanitize=thread lazy.cpp -o lazy && ./lazy
rustc --edition 2024 -o lazy_rs lazy.rs && ./lazy_rs
""")

Output

# javac 25.0.4.1
# v24.21.0
# go version go1.27.1 darwin/arm64
# Apple clang version 21.0.0 (clang-2100.3.34.2)
# rustc 1.99.0 (b940084d7)
$ java Lazy.java
check, then create: loaded more than once: true
holder idiom:       loaded 1 time; threads got 1 object
enum:               loaded 1 time; threads got 1 object
$ node lazy.ts
cache the value:   loaded 16 times; callers got 16 objects
cache the promise: loaded 1 time; callers got 1 object
$ go run -race lazy.go
sync.OnceValue: loaded 1 time; goroutines got 1 object
$ clang++ -std=c++23 -g -Wall -Wextra -fsanitize=thread lazy.cpp -o lazy && ./lazy
function-local static: loaded 1 time; threads got 1 object
$ rustc --edition 2024 -o lazy_rs lazy.rs && ./lazy_rs
LazyLock: loaded 1 time; threads got 1 object
OnceLock: loaded 1 time; threads got 1 object

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 languages_session.py

compiled/session.py

"""Prints a terminal session: "$ " and each command, then everything it printed (standard output
and standard error together), and its exit status when that is not 0.

The toolchains are the pinned ones: Java 25 (Eclipse Temurin 25.0.4), Node.js 24.21.0 and Go
1.27.1 from the mise installations next to the Python that runs this file, Rust 1.99.0 from
its rustup toolchain, and the system's Apple clang. learn:verify cannot let a sandboxed run
read the home folder those installations live in, so the sessions of this folder are recorded
with LEARN_VERIFY_NETWORK=allow. They read only this folder and the toolchains, write only
inside this folder and the run's own temporary home, and never use the network.

A command that fails and is not listed in expect_failure makes the session exit with status
1, so a run that could not build a program never becomes a record."""

import os
import pwd
import re
import signal
import subprocess
import sys
from pathlib import Path

# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
REAL_HOME = Path(pwd.getpwuid(os.getuid()).pw_dir)
TOOLCHAINS = [
    INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin",
    INSTALLS / "node" / "24.21.0" / "bin",
    INSTALLS / "go" / "1.27.1" / "bin",
    REAL_HOME / ".rustup" / "toolchains" / "1.99.0-aarch64-apple-darwin" / "bin",
]
# Version lines that end with a build date (rustc's) are shown without it: pages show no dates.
BUILD_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}(?=\))")


def _env() -> dict[str, str]:
    env = dict(os.environ)
    env["PATH"] = os.pathsep.join([*map(str, TOOLCHAINS), env.get("PATH", "")])
    env["GOTOOLCHAIN"] = "local"  # never download another Go
    env["GOTELEMETRY"] = "off"
    env["GOFLAGS"] = "-trimpath"
    return env


def _shell(command: str) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        command, shell=True, env=_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
    )


def versions(*commands: str) -> None:
    """Prints the first line of each version command as a comment."""
    for command in commands:
        done = _shell(command)
        lines = done.stdout.splitlines()
        if done.returncode != 0 or not lines:
            sys.exit(f"{command} did not run: the pinned toolchain is missing here")
        print("#", BUILD_DATE.sub("", lines[0]))


def run(script: str, expect_failure: tuple[str, ...] = ()) -> None:
    """Runs each line of the script as one shell command; only the commands in expect_failure
    may fail."""
    failed = 0
    for command in (line.strip() for line in script.strip().splitlines()):
        print(f"$ {command}")
        done = _shell(command)
        print(done.stdout, end="")
        if done.returncode < 0:
            print(f"[stopped by signal {signal.Signals(-done.returncode).name}]")
        elif done.returncode != 0:
            print(f"[exit status {done.returncode}]")
        if done.returncode != 0 and command not in expect_failure:
            failed += 1
    if failed:
        sys.exit(1)
  • Java. The unsynchronised check-then-create loads more than once. The holder idiom puts the instance in a nested class: the JVM initialises a class the first time it is used, under the per-class initialisation lock that JLS 12.4.2 describes, so the other threads wait and then see the finished field. An enum with one constant is shorter still, since the specification allows no instances of an enum class other than its constants (JLS 8.9). Double-checked locking also works, but only with a volatile field: the memory model’s guarantee that a write to a volatile variable is seen by every later read of it is what keeps a thread from seeing a half-built object.
  • TypeScript. JavaScript runs one task at a time, yet caching the value loads 16 times: every caller checks before the first await resumes. Caching the promise makes every early caller wait for the one load in flight, and the catch forgets a failed load so that a later call can try again. The exercise below builds the same idea in Python.
  • Go. sync.OnceValue, added in Go 1.21, wraps a function so that it runs once and every caller gets its result; sync.Once is the older form for code with side effects. The race detector reports nothing.
  • C++. A function-local static is initialised the first time control passes through its declaration, and the standard requires that a thread arriving during the initialisation waits for it to finish ([stmt.dcl]). It is the form that the Core Guidelines’ rule against singletons (I.3) allows as an exception, because it solves the order of initialisation without a class that hides its constructor.
  • Rust. A static LazyLock (stable since Rust 1.80) runs its closure on first use; OnceLock (stable since 1.70) takes the closure at the call, which suits values that need run-time input. Both make other threads wait during the initialisation, and both are what static items need to hold anything that cannot be built at compile time.

Stable values in JDK 25

JDK 25 also previews StableValue (JEP 502), an API for values that are initialised at most once, even in multi-threaded programs, and then treated by the JVM like constants. Preview APIs need --enable-preview and may change before they are final, so this lesson’s Java code uses only final features.

In Node.js: a package loaded twice

JavaScript modules are singletons too: Node.js caches ES modules by URL and CommonJS modules by their resolved file name, so importing one module twice gives one module object. A package that ships both formats breaks that, because its ES module build and its CommonJS build are two different files:

One package, imported and required in the same program

dual/main.js

// One package that ships both an ES module and a CommonJS build, loaded both ways in one
// program. package.json maps "#ratecard" to ratecard.mjs for import and to ratecard.cjs for
// require, as a dual package's "exports" does.
import { createRequire } from "node:module";
import { card as imported } from "#ratecard";

const require = createRequire(import.meta.url);
const { card: required } = require("#ratecard");

imported.rates.set("express", 4900);
console.log("import and require return one object:", imported === required);
console.log(
  "express rate set through import, read through require:",
  required.rates.get("express"),
);
console.log(`two module instances: ${imported.build} and ${required.build}`);

Output

import and require return one object: false
express rate set through import, read through require: undefined
two module instances: ES module and CommonJS

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node main.js

dual/package.json

{
  "name": "dual-ratecard-demo",
  "private": true,
  "type": "module",
  "imports": {
    "#ratecard": {
      "import": "./ratecard.mjs",
      "require": "./ratecard.cjs"
    }
  }
}

dual/ratecard.mjs

// The ES module build of a package's rate card: one Map, created when the module is first
// loaded.
export const card = { build: "ES module", rates: new Map() };

dual/ratecard.cjs

// The CommonJS build of the same rate card, as a package that ships both formats contains it.
module.exports = { card: { build: "CommonJS", rates: new Map() } };

The program loads the package both ways, as an application and one of its dependencies easily do, and gets two rate cards: a rate set through the imported one is missing from the required one. The Node.js guide to publishing packages calls this the dual-package hazard and warns that a stateful package loaded in both formats ends up with parallel states. A package that holds state should ship one format, or keep the state in a single module that both builds use.

What a Singleton costs in tests

The same two tests in both orders Python · testing/order_matters.py
"""Two small tests of a price formatter, run in both orders: once with a settings singleton,
once with the settings passed in through the constructor."""


# ---- with a singleton ------------------------------------------------------------------------
class Settings:
    _instance = None

    def __init__(self):
        self.currency = "INR"

    @classmethod
    def get(cls):
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance


class GlobalFormatter:
    def price(self, minor_units):
        return f"{Settings.get().currency} {minor_units / 100:.2f}"


def global_test_dollars():
    # the test changes the one shared instance and never changes it back
    Settings.get().currency = "USD"
    assert GlobalFormatter().price(250) == "USD 2.50"


def global_test_rupees():
    assert GlobalFormatter().price(129900) == "INR 1299.00", GlobalFormatter().price(129900)


# ---- with the settings passed in -------------------------------------------------------------
class InjectedFormatter:
    def __init__(self, currency):
        self.currency = currency

    def price(self, minor_units):
        return f"{self.currency} {minor_units / 100:.2f}"


def injected_test_dollars():
    assert InjectedFormatter("USD").price(250) == "USD 2.50"


def injected_test_rupees():
    assert InjectedFormatter("INR").price(129900) == "INR 1299.00"


def run(tests, reset=None):
    """Runs tests in the given order in a fresh process state; returns a one-line report."""
    if reset:
        reset()
    failures = []
    for test in tests:
        try:
            test()
        except AssertionError as error:
            failures.append(f"{test.__name__} failed: got {error}")
    return "; ".join(failures) or "all passed"


def fresh_process():
    Settings._instance = None  # what starting a new test process does


print("with a singleton:")
print("  dollars, then rupees:", run([global_test_dollars, global_test_rupees], fresh_process))
print("  rupees, then dollars:", run([global_test_rupees, global_test_dollars], fresh_process))
print("with the settings passed in:")
print("  dollars, then rupees:", run([injected_test_dollars, injected_test_rupees]))
print("  rupees, then dollars:", run([injected_test_rupees, injected_test_dollars]))

Output

with a singleton:
  dollars, then rupees: global_test_rupees failed: got USD 1299.00
  rupees, then dollars: all passed
with the settings passed in:
  dollars, then rupees: all passed
  rupees, then dollars: all passed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_matters.py

global_test_dollars changes the shared Settings and never changes it back. Run it first and the rupee test fails; run it second and everything passes. Real suites hit this as tests that fail only in a certain order, only in parallel runs, or only on the build server, and the usual patch is a “reset” method on the singleton that every test must remember to call. The injected version needs no reset: each test builds the formatter with the currency it wants.

With a Singleton, three classes reach out to a global instance; with injection, main() creates one instance and passes it to each.SingletonInjectionOrdersInvoicesReportsSettings.get()global accessmain()creates one SettingsOrders(settings)Invoices(settings)Reports(settings)hiddenhiddenhidden

One instance, reached two ways

Text description of the diagram

The diagram has two panels, one above the other.

  1. Singleton: Orders, Invoices and Reports each call Settings.get(), which returns one instance with global access. The arrows are labelled hidden, because nothing in the classes' constructors shows that they depend on Settings.
  2. Injection: main() creates one Settings object and passes it to Orders(settings), Invoices(settings) and Reports(settings). There is still exactly one instance, but every class states its dependency in its constructor, and a test can pass a different one.

The refactor keeps the single instance and moves its creation to one place. main(), the composition root, creates one Settings when the program starts and passes it to every class that needs it through their constructors. There is still exactly one instance in production, but each dependency is visible in a constructor, a test can pass a different one, and changing the settings is no longer something any line of code can do from anywhere.

When one instance is the right design

  • Configuration loaded once and never changed. Make it immutable and pass it in. The C++ Core Guidelines put it simply: if you don’t want a global object to change, declare it const or constexpr.
  • Process-wide infrastructure with no business state, such as loggers or a metrics registry. Python’s logging registry is the model: one instance per name, created on first use, safe to share.
  • A resource that must exist once, such as a connection pool. Create it at start-up in the composition root and pass it in; create it lazily only with one of the safe forms above.

What to avoid is the combination the catalogue’s version invites: mutable state, created lazily, reached through a global.

Exercise: one load for every early caller

Exercise · Hard · Python

One load for every early caller of a rate card

The app makes one RateCard at start-up and passes it to every part that needs shipping rates. Its get() loads the rates on first use and keeps them, which is right for calls that come one after another. But when 50 checkout requests arrive together after a restart, each one sees that nothing is loaded yet and starts its own load: 50 calls to the courier instead of one. Change RateCard.get() so that:

- Early callers share one load. A caller that asks while the first load is still running waits for that same load and gets the same rates object. - A failure reaches every waiter and is not kept. If the load raises, every caller waiting for it gets the exception, and the next call to get() starts a new load. - Giving up is allowed. If one waiting caller is cancelled, the load goes on and the other callers still get the rates. (Awaiting a shared task directly ties them together: cancelling the caller would cancel the load for everybody.) - The loader stays a constructor argument. Each RateCard uses only the load function it was given, so tests pass a fake courier and never need to reset anything.

The sample tests use a fake courier that counts its loads and takes a few turns of the event loop to answer. Run them when you are done.

Starter code · ratecard.py

"""The shipping rate card: loaded once, on first use, and shared by every caller of the one RateCard the app makes."""


class RateCard:
    def __init__(self, load):
        """`load` is an async function that fetches the rates from the courier, for example over HTTP."""
        self._load = load
        self._rates = None

    async def get(self):
        """The rates, loaded on the first call and kept for every later one."""
        if self._rates is None:
            self._rates = await self._load()
        return self._rates
The sample tests · test_ratecard.py
import asyncio

from ratecard import RateCard


class Courier:
    """A fake courier service: a load takes a few turns of the event loop, is counted, and fails the first
    `failures` times."""

    def __init__(self, rates, turns=3, failures=0):
        self.rates, self.turns, self.failures = rates, turns, failures
        self.loads = 0

    async def load(self):
        self.loads += 1
        for _ in range(self.turns):
            await asyncio.sleep(0)
        if self.loads <= self.failures:
            raise ConnectionError("the courier did not answer")
        return dict(self.rates)


async def test_one_load_for_calls_in_sequence():
    """three calls one after the other: one load, the same rates each time"""
    courier = Courier({"standard": 4900})
    card = RateCard(courier.load)
    first = await card.get()
    assert await card.get() is first
    assert await card.get() is first
    assert courier.loads == 1


async def test_early_callers_share_one_load():
    """50 callers that ask before the first load ends: one load, and every caller gets the same object"""
    courier = Courier({"standard": 4900, "express": 9900})
    card = RateCard(courier.load)
    results = await asyncio.gather(*(card.get() for _ in range(50)))
    assert courier.loads == 1, f"{courier.loads} loads: early callers must wait for the load in flight"
    assert all(r is results[0] for r in results)
    assert results[0] == {"standard": 4900, "express": 9900}


async def test_a_failed_load_reaches_every_waiter():
    """when the load fails, every caller waiting for it gets the error, and only one load was made"""
    courier = Courier({"standard": 4900}, failures=1)
    card = RateCard(courier.load)
    results = await asyncio.gather(*(card.get() for _ in range(5)), return_exceptions=True)
    assert all(isinstance(r, ConnectionError) for r in results), results
    assert courier.loads == 1


async def test_the_next_call_retries_after_a_failure():
    """a failed load is not kept: the next call loads again and succeeds"""
    courier = Courier({"standard": 4900}, failures=1)
    card = RateCard(courier.load)
    try:
        await card.get()
    except ConnectionError:
        pass
    else:
        raise AssertionError("the first load should have failed")
    assert await card.get() == {"standard": 4900}
    assert courier.loads == 2


async def test_cards_with_their_own_loader_are_independent():
    """each RateCard uses the loader it was given, so a test needs no reset hook"""
    cheap, dear = Courier({"standard": 100}), Courier({"standard": 99900})
    assert await RateCard(cheap.load).get() == {"standard": 100}
    assert await RateCard(dear.load).get() == {"standard": 99900}
    assert (cheap.loads, dear.loads) == (1, 1)


async def test_a_cancelled_caller_does_not_stop_the_load():
    """one early caller gives up; the others still get the rates from the same single load"""
    courier = Courier({"standard": 4900}, turns=5)
    card = RateCard(courier.load)
    quitter = asyncio.ensure_future(card.get())
    stayer = asyncio.ensure_future(card.get())
    await asyncio.sleep(0)
    quitter.cancel()
    assert await stayer == {"standard": 4900}
    assert courier.loads == 1
A hint

Keep the load that is in progress, not only its result: an asyncio task made from self._load() is something every early caller can wait for. Store it before the first await, so callers that arrive later find it. Then look at what asyncio.shield() does when the caller waiting on it is cancelled, and at what must happen to the stored task when the load raises.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Python Online Compiler Paste ratecard.py and its tests into the compiler to run them outside the lesson.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A Singleton promises one instance and a global point of access. Which half causes most of the trouble?

    Choose one answer.

    Show the answer to question 1

    Answer: The global access, because any code can reach and change the instance without saying so

    One instance is often exactly right: one configuration, one connection pool. Reaching it through a global hides the dependency from constructors and tests, lets any code change shared state, and makes tests depend on their order, as order_matters.py shows. Passing the one instance in keeps the first half and drops the second.

  2. Question 2 of 5 What does lazy.py print?

    What does this program print? Choose one answer.

    """Three lazy getters raced by 16 threads that start at the same moment. The settings take 50 ms
    to load, as reading a file or asking a server would, which is long enough for the other
    threads to arrive before the first load ends."""
    
    import functools
    import threading
    import time
    
    
    class Loads:
        """Counts loads safely from many threads."""
    
        def __init__(self):
            self.count, self._lock = 0, threading.Lock()
    
        def load(self):
            with self._lock:
                self.count += 1
            time.sleep(0.05)
            return {"currency": "INR", "tax_rate_percent": 18}
    
    
    def race(get, threads=16):
        """Calls get() from `threads` threads released together; returns how many different objects
        they received."""
        barrier, seen, lock = threading.Barrier(threads), [], threading.Lock()
    
        def worker():
            barrier.wait()
            value = get()
            with lock:
                seen.append(value)
    
        workers = [threading.Thread(target=worker) for _ in range(threads)]
        for w in workers:
            w.start()
        for w in workers:
            w.join()
        return len({id(v) for v in seen})
    
    
    # 1. Check, then create: every thread that checks before the first load has finished loads
    #    again.
    loads = Loads()
    
    
    class NaiveSettings:
        _instance = None
    
        @classmethod
        def get(cls):
            if cls._instance is None:
                cls._instance = loads.load()
            return cls._instance
    
    
    race(NaiveSettings.get)
    print(f"check, then create:   loaded more than once: {loads.count > 1}")
    
    # 2. functools.cached_property: since Python 3.12 it takes no lock, so its getter can run once
    #    per thread.
    loads = Loads()
    
    
    class Config:
        @functools.cached_property
        def settings(self):
            return loads.load()
    
    
    config = Config()
    race(lambda: config.settings)
    print(f"cached_property:      loaded more than once: {loads.count > 1}")
    
    # 3. The check and the creation inside one lock: one load, and every thread gets the same
    #    object.
    loads = Loads()
    
    
    class LockedSettings:
        _instance = None
        _lock = threading.Lock()
    
        @classmethod
        def get(cls):
            with cls._lock:
                if cls._instance is None:
                    cls._instance = loads.load()
            return cls._instance
    
    
    distinct = race(LockedSettings.get)
    print(f"check inside a lock:  loaded {loads.count} time; threads got {distinct} object")
    Show the answer to question 2

    Answer: it prints

    check, then create:   loaded more than once: True
    cached_property:      loaded more than once: True
    check inside a lock:  loaded 1 time; threads got 1 object

    All 16 threads check before the first 50 ms load ends, so the check-then-create getter loads more than once. Since Python 3.12, functools.cached_property takes no lock, so its getter can run in several threads too. Only the getter that checks and creates inside one lock loads once.

  3. Question 3 of 5 Why does Java's holder idiom create the instance only once, even when 16 threads call get() together?

    Choose one answer.

    Show the answer to question 3

    Answer: Because a class is initialised under a JVM initialisation lock, and other threads wait until it finishes

    The nested Holder class is initialised the first time its field is read, and the Java Language Specification (12.4.2) gives every class a unique initialisation lock: one thread runs the initialiser, the others wait and then see the finished field. Lazy.java records one load with 16 threads.

  4. Question 4 of 5 A package ships an ES module build and a CommonJS build. One part of an application imports it, another requires it. What is true of a Map the package creates at load time?

    Choose one answer.

    Show the answer to question 4

    Answer: There are two Maps, one per build, so a value set through one is missing in the other

    ES modules are cached by URL and CommonJS modules by file name, so the two builds are two modules with two states, the "dual package hazard" of the Node.js documentation. The run prints false and undefined. A stateful package should ship one format, or keep its state in one place both builds use.

  5. Question 5 of 5 Which of these are reasonable single instances?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • A process-wide registry of named loggers, such as Python's logging.getLogger
    • Settings loaded once at start-up and never changed afterwards, passed to the classes that need them

    Immutable configuration passed in, and stateless or append-only infrastructure such as loggers, are the usual good cases. A cart shared by all requests mixes customers' data, and a counter that tests must reset is exactly the shared mutable state that makes tests depend on their order.

Interview questions

Fresher · Why do many teams avoid the Singleton pattern, and what do they use instead?

A Singleton makes two promises: one instance, and a global point of access to it. The first is often fine; the second causes the trouble. Code that calls Settings.get() hides a dependency that its constructor does not show, any code can change the shared state, and tests that change it affect each other, so they pass or fail depending on their order. Lazy creation adds a race: two threads, or two async callers, can both see that no instance exists and create one each. Teams therefore create the one instance at start-up, in one place, and pass it to the classes that need it, which is dependency injection: there is still one instance, but every dependency is visible and a test can pass its own. A good answer adds that immutable configuration and loggers are reasonable single instances.

Key takeaways

  • Singleton’s “one instance” is often right; its global access is what hides dependencies and couples tests.
  • Lazy creation needs a safe form: a lock around check and create in Python, the holder idiom or an enum in Java, a cached promise in TypeScript, sync.OnceValue in Go, a function-local static in C++, LazyLock or OnceLock in Rust.
  • Since Python 3.12, functools.cached_property takes no lock; free-threaded Python makes such races easier to hit.
  • One “singleton” can become two: a Python module imported under two names, or a package loaded as both an ES module and CommonJS.
  • Prefer one instance created at start-up and passed in through constructors; it keeps the single instance and drops the hidden global.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.