Design Patterns in Python

Reviewed & published by Brayan K

Master the essential design patterns used in professional Python development. Learn Singleton, Factory, and Strategy patterns with real-world examples and best practices.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

1. What Are Design Patterns (Really)?

Design patterns are reusable solutions to common design problems in software.

They're not code you copy 1:1. They're mental templates you adapt:

Problem You HavePattern to UseReal Example
"I need only one of this thing globally"SingletonDatabase connection, Logger
"I need a flexible way to create objects"FactoryPayment processors, Notification senders
"I need to swap behaviour/algorithm easily"StrategyDiscount calculators, Sorting algorithms

In Python, we also care about:

In this lesson we'll cover:

2. Singleton Pattern — Intent & When to Use

Intent: Ensure only one instance of a class exists, and provide a global access point to it.

3. The Simplest Python Singleton: Just Use a Module

Instead of creating a complex class-based singleton, you can just create a module:

# config.py

SETTINGS = {
    "debug": True,
    "db_url": "postgres://localhost",
}

def is_debug():
    return SETTINGS["debug"]

# Then everywhere:
# anywhere.py
# import config
# print(config.SETTINGS["db_url"])

print(SETTINGS)
print(is_debug())

# ✅ Expected output:
# {'debug': True, 'db_url': 'postgres://localhost'}
# True

👉 For many apps, this is the most Pythonic "singleton".

Only use heavy OO-singleton patterns if you really need class-based behaviour.

4. Classic OO Singleton With __new__

Let's look at the "canonical" pattern:

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            # create the one and only instance
            cls._instance = super().__new__(cls)
        return cls._instance

class AppConfig(Singleton):
    def __init__(self, debug=False):
        # __init__ may be called multiple times, be careful
        if not hasattr(self, "_initialised"):
            self.debug = debug
            self._initialised = True

config1 = AppConfig(debug=True)
config2 = AppConfig(debug=False)

print(config1 is config2)      # True
print(config1.debug, config2.debug)  # True True

# ✅ Expected output:
# True
# True True

5. Singleton via Decorator (Reusable & Clean)

We can build a @singleton decorator that turns any class into a singleton:

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.logs = []

    def log(self, msg):
        self.logs.append(msg)

logger1 = Logger()
logger2 = Logger()

logger1.log("Hello")
logger2.log("World")

print(logger1 is logger2)  # True
print(logger1.logs)        # ['Hello', 'World']

# ✅ Expected output:
# True
# ['Hello', 'World']

6. The Borg Pattern (Shared State Singleton)

Alternative: many instances, shared state.

class Borg:
    _shared_state = {}

    def __init__(self):
        self.__dict__ = self._shared_state

class Settings(Borg):
    def __init__(self):
        super().__init__()
        if not hasattr(self, "initialised"):
            self.debug = True
            self.initialised = True

s1 = Settings()
s2 = Settings()

s1.debug = False
print(s2.debug)        # False
print(s1 is s2)        # False (different instances)

# ✅ Expected output:
# False
# False

All instances share the same __dict__.

Changing an attribute in one changes it for all.

When might this be useful?

7. Singleton in Real Projects — When & When NOT to Use

Example of something better than a singleton: dependency injection:

Part 2: Factory Patterns

Factory patterns control how objects are created:

8. Simple Factory (Function-Based)

This is the most Pythonic and often best starting point.

Example: Notification Sender Factory

We want to create different notification senders: email, SMS, push.

class EmailSender:
    def send(self, to, message):
        print(f"[EMAIL to {to}] {message}")

class SMSSender:
    def send(self, to, message):
        print(f"[SMS to {to}] {message}")

class PushSender:
    def send(self, to, message):
        print(f"[PUSH to {to}] {message}")

def create_notifier(channel: str):
    channel = channel.lower()
    if channel == "email":
        return EmailSender()
    elif channel == "sms":
        return SMSSender()
    elif channel == "push":
        return PushSender()
    else:
        raise ValueError(f"Unknown channel: {channel}")

# Usage
notifier = create_notifier("email")
notifier.send("[email protected]", "Welcome to LearnCodingFast!")

# ✅ Expected output:
# [EMAIL to [email protected]] Welcome to LearnCodingFast!

If you add WhatsApp later, only one place changes.

9. Improving the Simple Factory with a Registry

Instead of if/elif, we can use a mapping (much cleaner for many types).

class EmailSender:
    def send(self, to, message):
        print(f"[EMAIL to {to}] {message}")

class SMSSender:
    def send(self, to, message):
        print(f"[SMS to {to}] {message}")

class PushSender:
    def send(self, to, message):
        print(f"[PUSH to {to}] {message}")

NOTIFIER_REGISTRY = {
    "email": EmailSender,
    "sms": SMSSender,
    "push": PushSender,
}

def create_notifier(channel: str):
    try:
        cls = NOTIFIER_REGISTRY[channel.lower()]
    except KeyError:
        raise ValueError(f"Unknown channel: {channel}")
    return cls()

notifier = create_notifier("sms")
notifier.send("+44...", "Your code is 123456")

# ✅ Expected output:
# [SMS to +44...] Your code is 123456

This "simple factory + registry" pattern is used everywhere in real-world Python code (ML frameworks, plugin systems, etc.).

10. Factory Method Pattern (OO Style)

Sometimes you don't want a separate function, you want subclasses to decide what they create.

Factory Method = a method in a parent class that is overridden in subclasses to decide what object to create.

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def create_client(self):
        """Factory method: subclasses return appropriate client."""
        pass

    def process_payment(self, amount: float):
        client = self.create_client()
        client.charge(amount)

class StripeClient:
    def charge(self, amount):
        print(f"[Stripe] Charging £{amount:.2f}")

class PayPalClient:
    def charge(self, amount):
        print(f"[PayPal] Charging £{amount:.2f}")

class StripeProcessor(PaymentProcessor):
    def create_client(self):
        return StripeClient()

class PayPalProcessor(PaymentProcessor):
    def create_client(self):
        return PayPalClient()

# Usage:
processor = StripeProcessor()
processor.process_payment(29.99)

# ✅ Expected output:
# [Stripe] Charging £29.99

This pattern is great when:

11. Abstract Factory — Creating "Families" of Related Objects

You're building a cross-platform GUI toolkit:

Abstract Factory returns a family of related objects.

from abc import ABC, abstractmethod

# Product interfaces
class Button(ABC):
    @abstractmethod
    def render(self): ...

class Checkbox(ABC):
    @abstractmethod
    def render(self): ...

# Concrete products
class LightButton(Button):
    def render(self):
        print("[Light Button]")

class DarkButton(Button):
    def render(self):
        print("[Dark Button]")

class LightCheckbox(Checkbox):
    def render(self):
        print("[Light Checkbox]")

class DarkCheckbox(Checkbox):
    def render(self):
        print("[Dark Checkbox]")

# Abstract Factory
class UIFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button: ...
    
    @abstractmethod
    def create_checkbox(self) -> Checkbox: ...

# Concrete factories
class LightUIFactory(UIFactory):
    def create_button(self) -> Button:
        return LightButton()
    
    def create_checkbox(self) -> Checkbox:
        return LightCheckbox()

class DarkUIFactory(UIFactory):
    def create_button(self) -> Button:
        return DarkButton()
    
    def create_checkbox(self) -> Checkbox:
        return DarkCheckbox()

# Usage
def render_screen(factory: UIFactory):
    btn = factory.create_button()
    cb = factory.create_checkbox()
    btn.render()
    cb.render()

factory = DarkUIFactory()
render_screen(factory)

# ✅ Expected output:
# [Dark Button]
# [Dark Checkbox]

This is similar to:

Part 3: Strategy Pattern

The Strategy pattern allows you to change how something works without changing the code that uses it.

Python makes this pattern extremely powerful because functions are first-class objects.

12. Why Strategy Pattern Exists

Without Strategy, developers often write:

Strategy solves all this.

13. Strategy Pattern — Functional (Most Pythonic)

The simplest and BEST way in Python is using functions as strategies.

# Step 1: Define behaviors
def discount_percentage(price):
    return price * 0.9

def discount_fixed(price):
    return price - 5

def discount_bogo(price):
    return price / 2

# Step 2: Strategy dictionary
DISCOUNT_STRATEGIES = {
    "percentage": discount_percentage,
    "fixed": discount_fixed,
    "bogo": discount_bogo
}

# Step 3: Apply strategy
def apply_discount(price, strategy_name):
    strategy = DISCOUNT_STRATEGIES[strategy_name]
    return strategy(price)

print(apply_discount(100, "percentage"))
print(apply_discount(100, "fixed"))
print(apply_discount(100, "bogo"))

# ✅ Expected output:
# 90.0
# 95
# 50.0

14. Strategy Using Classes (Classic OOP Approach)

Sometimes you need stateful strategies or polymorphism.

from abc import ABC, abstractmethod

# Step 1: Strategy interface
class DiscountStrategy(ABC):
    @abstractmethod
    def apply(self, price):
        pass

# Step 2: Implement concrete strategies
class PercentageDiscount(DiscountStrategy):
    def apply(self, price):
        return price * 0.9

class FixedDiscount(DiscountStrategy):
    def apply(self, price):
        return price - 5

# Step 3: Use strategy
def calculate_total(price, strategy: DiscountStrategy):
    return strategy.apply(price)

print(calculate_total(100, PercentageDiscount()))
print(calculate_total(100, FixedDiscount()))

# ✅ Expected output:
# 90.0
# 95

15. Strategy in Real Systems

▶ 1. Machine Learning Preprocessing Pipelines

▶ 2. Payment Processing Logic

Choose pricing or fraud-checking algorithms at runtime.

▶ 5. Web Framework Routing

📖 Worked Example: All Three Patterns in One Checkout

You have met the three patterns one at a time. In real code they show up together, and each one answers a different question:

Read the program below before writing anything. Notice that checkout() — the function doing the actual work — never names a concrete discount rule or a concrete payment class. It looks both up by name. That is the whole point of the patterns.

"""One small checkout — Singleton, Factory and Strategy working together."""

# ---------- SINGLETON: one shared settings object for the whole program ----------
class Settings:
    _instance = None

    def __new__(cls):
        # __new__ runs BEFORE __init__ and decides which object you get back.
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.currency = "GBP"
            cls._instance.vat = 0.20
        return cls._instance          # every later call returns that same object


# ---------- STRATEGY: interchangeable pricing rules, as plain functions ----------
def no_discount(total: float) -> float:
    return total

def student(total: float) -> float:
    return total * 0.90                              # 10% off

def bulk(total: float) -> float:
    return total * 0.75 if total > 100 else total    # 25% off big baskets only

# The strategies live in a dict, so choosing one is a lookup, not an if/elif chain.
DISCOUNTS = {"none": no_discount, "student": student, "bulk": bulk}


# ---------- FACTORY: pick a payment processor by name, at run time ----------
class CardPayment:
    def pay(self, amount: float, currency: str) -> str:
        return f"Card charged {amount:.2f} {currency}"

class PayPalPayment:
    def pay(self, amount: float, currency: str) -> str:
        return f"PayPal charged {amount:.2f} {currency}"

PROCESSORS = {"card": CardPayment, "paypal": PayPalPayment}

def make_processor(kind: str):
    """The factory: the ONE place that decides which class gets built."""
    try:
        return PROCESSORS[kind]()     # look the CLASS up, then call it to build one
    except KeyError:
        raise ValueError(f"unknown payment type: {kind}") from None


# ---------- The checkout uses all three and knows no concrete detail ----------
def checkout(basket_total: float, discount_name: str, payment_kind: str) -> str:
    settings = Settings()                       # singleton: same object every time
    discount = DISCOUNTS[discount_name]         # strategy: chosen by name
    subtotal = discount(basket_total)
    total = subtotal * (1 + settings.vat)       # VAT comes from the shared settings
    processor = make_processor(payment_kind)    # factory: chosen by name
    return processor.pay(total, settings.currency)


print(checkout(80.00, "none", "card"))
print(checkout(80.00, "student", "paypal"))
print(checkout(200.00, "bulk", "card"))

# The singleton really is one object, not two that look alike:
print("Same settings object?", Settings() is Settings())

# The factory refuses what it does not recognise, instead of returning None:
try:
    make_processor("bitcoin")
except ValueError as e:
    print("Error:", e)

# ✅ Expected output:
# Card charged 96.00 GBP
# PayPal charged 86.40 GBP
# Card charged 180.00 GBP
# Same settings object? True
# Error: unknown payment type: bitcoin

To add a "black Friday" discount you add one function and one dictionary entry. To add Apple Pay you add one class and one dictionary entry. checkout() is never touched. When people say patterns make code "extensible", that is the concrete thing they mean.

🎯 Your Turn: A Logger and Two Formatters

One singleton and one strategy table, with three pieces removed. Each blank is one of the ideas from this lesson. Fill them in, run it, and compare with the expected output at the bottom.

Watch blank 2 carefully: you store the function itself, not a call to it. Writing as_csv() with brackets would run the function immediately and store its return value.

# 🎯 YOUR TURN — replace the three ___ blanks

class Logger:
    """SINGLETON: every part of the program writes to the same log."""
    _instance = None

    def __new__(cls):
        # 1) Only build a new object the FIRST time. After that, hand back the one you kept.
        if cls._instance is ___:              # 👉 what is _instance before anything is built?
            cls._instance = super().__new__(cls)
            cls._instance.lines = []
        return cls._instance

    def log(self, message: str) -> None:
        self.lines.append(message)


# STRATEGY: two interchangeable ways to turn rows into text.
def as_text(rows):
    return "\n".join(", ".join(r) for r in rows)

def as_csv(rows):
    return "\n".join(";".join(r) for r in rows)

# 2) Put the strategies in a dict so picking one is a lookup, not an if/elif chain.
FORMATTERS = {"text": as_text, "csv": ___}    # 👉 the FUNCTION itself, with no ()


def export(rows, style):
    # 3) Look the chosen strategy up by name.
    formatter = FORMATTERS[___]               # 👉 which variable holds the chosen name?
    Logger().log(f"exported as {style}")
    return formatter(rows)


rows = [("id", "name"), ("1", "Ada"), ("2", "Grace")]

print(export(rows, "text"))
print("---")
print(export(rows, "csv"))
print("---")
print("Log lines:", Logger().lines)
print("One logger?", Logger() is Logger())

# ✅ Expected output:
# id, name
# 1, Ada
# 2, Grace
# ---
# id;name
# 1;Ada
# 2;Grace
# ---
# Log lines: ['exported as text', 'exported as csv']
# One logger? True

1) None — _instance starts as None, so if cls._instance is None is true exactly once.

2) as_csv — the bare name. Adding () would call it right there with no arguments and raise a TypeError.

3) style — the argument that carries the caller's choice.

If "Log lines" shows only one entry, you rebuilt the logger each time — check that __new__ returns cls._instance on every path, not just the first.

🏆 Mini-Challenge: A Self-Registering Factory

Outline only — every line is yours to write. This is the pattern that turns a factory into a plugin system, and it is how real libraries let you drop in a new backend without editing their code.

You will combine the registry factory from section 9 with a decorator. A decorator that takes an argument is a function that returns a decorator — that extra layer is the one bit worth going slowly over.

# 🎯 MINI-CHALLENGE: a self-registering factory
#
# The factories earlier in this lesson keep a hand-written dict. Real plugin
# systems let each class register ITSELF, so adding one never means editing the
# factory. Build that.
#
# 1. Start with an empty dict:  SHIPPERS = {}
# 2. Write register(name) — a decorator factory. It takes a name and RETURNS a
#    decorator; that inner decorator stores the class in SHIPPERS[name] and then
#    returns the class unchanged.
# 3. Write make_shipper(name): look the class up and call it. On a missing key,
#    raise ValueError(f"no shipper called {name!r}") — use `from None` so the
#    KeyError does not clutter the traceback.
# 4. Define three classes, each decorated with @register("..."), each with a
#    quote(self, kg) method:
#       standard -> 3.99 + kg * 0.50
#       express  -> 9.99 + kg * 1.20
#       courier  -> 15.00           (flat rate, ignores kg)
# 5. Print sorted(SHIPPERS), then for each of standard, express and courier print
#    f"{name:9}£{make_shipper(name).quote(4):.2f}"
# 6. Finally, try make_shipper("pigeon") in a try/except and print the error.
#
# ✅ Expected output:
# Registered: ['courier', 'express', 'standard']
# standard £5.99
# express  £14.79
# courier  £15.00
# Error: no shipper called 'pigeon'

# your code here
SHIPPERS = {}

def register(name):
    """Decorator factory: returns the decorator that records the class."""
    def decorator(cls):
        SHIPPERS[name] = cls
        return cls
    return decorator

def make_shipper(name):
    try:
        return SHIPPERS[name]()
    except KeyError:
        raise ValueError(f"no shipper called {name!r}") from None


@register("standard")
class Standard:
    def quote(self, kg):
        return 3.99 + kg * 0.50

@register("express")
class Express:
    def quote(self, kg):
        return 9.99 + kg * 1.20

@register("courier")
class Courier:
    def quote(self, kg):
        return 15.00


print("Registered:", sorted(SHIPPERS))
for name in ("standard", "express", "courier"):
    print(f"{name:9}£{make_shipper(name).quote(4):.2f}")

try:
    make_shipper("pigeon")
except ValueError as e:
    print("Error:", e)

Adding a fourth shipper now costs one class and one decorator line. Nothing in make_shipper changes, and nothing that already worked can break — which is exactly what "open for extension, closed for modification" means in practice.

🎓 Conclusion

You now fully understand all 3 major design patterns:

Control instance creation, global state, config, caching.

✔ Factory (Simple, Method, Abstract)

These patterns together give you:

📋 Quick Reference — Design Patterns

PatternUse when
SingletonOnly one instance needed (config, DB pool)
FactoryCreate objects without exposing class details
ObserverEvent-driven: notify multiple subscribers
StrategySwap algorithms at runtime
DecoratorAdd behaviour without changing the class

🎉 Great work! You've completed this lesson.

You can now recognise and apply the most important design patterns — the vocabulary every senior engineer uses when discussing architecture.

Practice quiz

Which problem does the Singleton pattern solve?

  • Swapping algorithms at runtime
  • Creating families of related objects
  • Ensuring only one instance exists with a global access point
  • Adding behavior to a class dynamically

Answer: Ensuring only one instance exists with a global access point. Singleton guarantees a single instance (e.g. a config or DB pool) and provides one global access point to it.

What is the most Pythonic 'singleton' for many simple cases?

  • Just a module — imported once and cached in sys.modules
  • A metaclass
  • A global list
  • A frozen dataclass

Answer: Just a module — imported once and cached in sys.modules. A module is imported once and cached in sys.modules, so every import gets the same object — a natural singleton.

In the classic singleton, which method is overridden to control instance creation?

  • __init__
  • __call__
  • __enter__
  • __new__

Answer: __new__. __new__ controls object creation, so it returns the stored single instance; __init__ only initializes.

What distinguishes the Borg pattern from a classic singleton?

  • It forbids inheritance
  • Many instances exist but they share the same state via __dict__
  • It allows only one instance
  • It caches return values

Answer: Many instances exist but they share the same state via __dict__. Borg instances are distinct objects (s1 is s2 is False) yet share one __dict__, so they share all state.

What does a simple (function-based) factory like create_notifier('email') achieve?

  • It centralizes the 'which class?' decision so callers don't use if/elif everywhere
  • It caches notifications
  • It makes the class a singleton
  • It validates argument types

Answer: It centralizes the 'which class?' decision so callers don't use if/elif everywhere. The factory hides class-selection logic in one place, so app code just asks for 'email' without knowing the concrete class.

How does a registry improve a simple factory?

  • It adds threading
  • It removes the need for classes
  • It replaces if/elif chains with a dictionary mapping names to classes
  • It enforces a single instance

Answer: It replaces if/elif chains with a dictionary mapping names to classes. A registry dict maps keys to classes, so adding a new type is just one dictionary entry instead of another elif branch.

In the Factory Method pattern, who decides which concrete object is created?

  • A standalone function
  • Subclasses, by overriding the factory method
  • The caller passes the class in
  • A global registry only

Answer: Subclasses, by overriding the factory method. Factory Method puts a create_*() method in the base class that each subclass overrides to return its own product.

What does an Abstract Factory return?

  • A single object
  • A cached value
  • A function
  • A family of related objects (e.g. matching Button and Checkbox)

Answer: A family of related objects (e.g. matching Button and Checkbox). Abstract Factory produces whole families of related objects, ensuring you never mix incompatible components.

What is the most Pythonic way to implement the Strategy pattern?

  • A deep class hierarchy
  • Functions as first-class strategies stored in a dict
  • A singleton per strategy
  • Global if/elif branches

Answer: Functions as first-class strategies stored in a dict. Because functions are first-class in Python, storing them in a strategy dict is the simplest, fastest approach.

Which principle does replacing if/elif algorithm-selection with Strategy uphold?

  • DRY only
  • Single instance guarantee
  • The Open-Closed Principle (open for extension, closed for modification)
  • Lazy initialization

Answer: The Open-Closed Principle (open for extension, closed for modification). Strategy lets you add new behaviors without editing existing selection code, honoring the Open-Closed Principle.

Continue this course