Session 1 · Singleton pattern#

The big idea#

Some things in a program should exist exactly once: the app settings, the logger, the database connection pool, the LLM client. If every part of the code makes its own copy, you waste memory, open too many connections and end up with copies that disagree with each other.

The Singleton pattern guarantees two things: the class has only one instance, and everyone gets that same instance.

Everyday example: the electricity meter of a house. There is one meter. The kitchen, the fan and the TV are all counted on it. Nobody installs a second meter for each room.

In plain words

Make sure there is only one of something, and that every part of the program uses that same one. The first person to ask creates it; everyone after that gets the one that already exists.

Reach for it when:

  • several copies of the same thing would waste memory or connections

  • copies could disagree (settings, a cache, a logger)

  • there is a real rule that only one may exist, not just that one is convenient

Without Singleton#

0.no_singleton.py: every call builds a new object.

class Config:
    def __init__(self):
        print("Loading config from disk...")
        self.settings = {"debug": False}

a = Config()
b = Config()

a.settings["debug"] = True
print(a is b)               # False
print(b.settings["debug"])  # False  ← b never heard about the change
Loading config from disk...
Loading config from disk...
False
False

Two objects, two disk reads, two different truths. In a real app this shows up as “I changed the setting but nothing happened”.

With Singleton#

1.singleton_new.py: Python calls __new__ to build the object, so we decide there whether to build a new one or return the old one.

class Config:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            print("Loading config from disk...")
            cls._instance = super().__new__(cls)
            cls._instance.settings = {"debug": False}
        return cls._instance

a = Config()
b = Config()

a.settings["debug"] = True
print(a is b)               # True
print(b.settings["debug"])  # True
Loading config from disk...
True
True

Loaded once, shared by everyone.

Singleton: everyone gets the same object The first Config() call creates the object. Every later call returns that same object, so a change made through one variable is seen through all of them. a = Config() settings page b = Config() API handler c = Config() background job first call: create later calls: return the same one the one Config object settings = {"debug": True} a is b is c → True
The first Config() call creates the object. Every later call returns that same object, so a change made through one variable is seen through all of them.

Note that we set the data inside __new__ and did not write an __init__. Python runs __init__ on every call, even when __new__ returns the old object, so it would reset your data.

Ways to build one in Python#

1. A module is already a Singleton (2.module_singleton/). Python imports a module once and caches it. This is the simplest option and often the best:

# settings.py
DEBUG = False
DB_URL = "postgresql://localhost/app"
# anywhere else
import settings
settings.DEBUG = True      # everyone importing settings sees this

2. A decorator (3.decorator.py): reusable for any class.

def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Logger:
    def __init__(self):
        self.lines = []

assert Logger() is Logger()

3. Thread-safe version (4.thread_safe.py): if two threads call Config() at the same moment, both may see _instance is None and both create one. A lock fixes that:

import threading

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

    def __new__(cls):
        if cls._instance is None:               # fast check, no lock
            with cls._lock:
                if cls._instance is None:       # check again inside the lock
                    cls._instance = super().__new__(cls)
        return cls._instance

This is called double-checked locking.

Singleton + AI: one LLM client#

Creating an LLM client reads keys, sets up HTTP sessions and sometimes loads a model. You want that once per process (5.llm_client.py):

from functools import lru_cache
from langchain_groq import ChatGroq

@lru_cache(maxsize=1)
def get_llm():
    print("Creating LLM client...")
    return ChatGroq(model="openai/gpt-oss-120b")

llm_1 = get_llm()
llm_2 = get_llm()
print(llm_1 is llm_2)    # True, "Creating LLM client..." printed once

lru_cache(maxsize=1) on a function with no arguments is the shortest Singleton in Python. FastAPI apps use exactly this trick for settings and database sessions.

Common mistakes#

  • Using it for everything. A Singleton is global state in a nice costume. Use it only when “exactly one” is a real rule (config, pool, logger), not just because it is convenient.

  • Forgetting threads. The plain __new__ version can create two objects under load. Use the lock version in web apps.

  • Putting data in __init__. It runs on every Config() call and wipes your state.

  • Hard-to-test code. Tests share the one instance, so one test leaks into the next. Add a reset method for tests, or pass the object in as an argument instead of importing it everywhere.

  • Subclassing surprises. All subclasses of a _instance-based Singleton share the same _instance unless each class has its own.

Hands-on exercises#

Try each one before opening the solution.

Exercise 1 · Logger. Build a Logger Singleton with log(msg) and show(). Log from two different variables and check both see all messages.

Solution
class Logger:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.lines = []
        return cls._instance

    def log(self, msg: str):
        self.lines.append(msg)

    def show(self):
        for line in self.lines:
            print(line)

a = Logger()
b = Logger()
a.log("server started")
b.log("user logged in")
a.show()
# server started
# user logged in

Exercise 2 · Reset for tests. Add a way to throw the instance away so each test starts clean.

Solution
class Config:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.settings = {}
        return cls._instance

    @classmethod
    def reset(cls):
        cls._instance = None

c1 = Config()
Config.reset()
c2 = Config()
print(c1 is c2)    # False

Call Config.reset() in a test’s setup or teardown. Never call it in production code.

Exercise 3 · Break it, then fix it. Make the plain version create two objects using threads, then fix it with a lock.

Solution
import threading, time

class Slow:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            time.sleep(0.1)                 # pretend loading takes time
            cls._instance = super().__new__(cls)
        return cls._instance

ids = []
def work():
    ids.append(id(Slow()))

threads = [threading.Thread(target=work) for _ in range(5)]
[t.start() for t in threads]
[t.join() for t in threads]
print(len(set(ids)))    # usually more than 1  ← broken

Fix: add _lock = threading.Lock() and the double check from the thread-safe section. Now len(set(ids)) is always 1.

Exercise 4 · Decorator on two classes. Apply the singleton decorator to Logger and Cache. Are they the same object?

Solution
@singleton
class Logger: ...

@singleton
class Cache: ...

print(Logger() is Logger())   # True
print(Cache() is Cache())     # True
print(Logger() is Cache())    # False

Each class gets its own single instance, because instances is keyed by the class.

One catch: after decoration, Logger is a function, not a class, so isinstance(x, Logger) fails. That is one reason to prefer __new__ or lru_cache.

Exercise 5 · Database connection. Write get_connection() that opens a SQLite connection only once and returns the same one on every call.

Solution
import sqlite3
from functools import lru_cache

@lru_cache(maxsize=1)
def get_connection():
    print("Opening connection...")
    return sqlite3.connect("app.db", check_same_thread=False)

c1 = get_connection()
c2 = get_connection()
print(c1 is c2)    # True

check_same_thread=False lets other threads use the connection. For heavy traffic use a connection pool instead, and make the pool the Singleton.

Full source#

1.singleton_new.py
"""Singleton with __new__: build the object once, hand out the same one after that."""


class Config:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            print("Loading config from disk...")
            cls._instance = super().__new__(cls)
            cls._instance.settings = {"debug": False}
        return cls._instance
    # No __init__ on purpose: Python runs __init__ on every Config() call, even when __new__
    # returns the old object, so it would reset the settings.


a = Config()
b = Config()

a.settings["debug"] = True
print(a is b)               # True
print(b.settings["debug"])  # True
4.thread_safe.py
"""Thread-safe Singleton with double-checked locking.

The plain version can create two objects when two threads arrive at the same moment.
"""
import threading
import time


class Unsafe:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            time.sleep(0.05)                    # pretend loading takes time
            cls._instance = super().__new__(cls)
        return cls._instance


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

    def __new__(cls):
        if cls._instance is None:               # fast check, no lock
            with cls._lock:
                if cls._instance is None:       # check again inside the lock
                    time.sleep(0.05)
                    cls._instance = super().__new__(cls)
        return cls._instance


def count_instances(cls, n=10):
    ids = []
    threads = [threading.Thread(target=lambda: ids.append(id(cls()))) for _ in range(n)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return len(set(ids))


print("unsafe :", count_instances(Unsafe), "instance(s)")    # usually more than 1
print("locked :", count_instances(Config), "instance(s)")    # always 1
5.llm_client.py
"""One LLM client per process: lru_cache(maxsize=1) on a function with no arguments.

Runs offline with a stand-in client. With langchain-groq installed and GROQ_API_KEY set, it
creates a real ChatGroq client instead.
"""
import os
from functools import lru_cache


class FakeLLM:
    def invoke(self, prompt):
        return type("Msg", (), {"content": f"(fake answer to: {prompt})"})()


@lru_cache(maxsize=1)
def get_llm():
    print("Creating LLM client...")
    if os.getenv("GROQ_API_KEY"):
        from langchain_groq import ChatGroq
        return ChatGroq(model="openai/gpt-oss-120b")
    return FakeLLM()


llm_1 = get_llm()
llm_2 = get_llm()
print(llm_1 is llm_2)    # True, "Creating LLM client..." printed once
print(llm_1.invoke("What is a Singleton?").content)

Downloads: 0.no_singleton.py · 1.singleton_new.py · 2.module_singleton/settings.py · 2.module_singleton/app.py · 3.decorator.py · 4.thread_safe.py · 5.llm_client.py