Python Design Patterns: Practical Guide (2026)

Published June 6, 2026 • 14 min read

Design patterns are reusable solutions to recurring software design problems. Python's dynamic nature means many classical patterns from the Gang of Four book look very different — or are unnecessary — in Python. This guide skips the theoretical overview and goes straight to practical, idiomatic Python implementations with real-world use cases. Critically, it also covers when not to reach for a pattern, because Python's duck typing often gives you a simpler path.

Singleton — Thread-Safe Implementations

The Singleton pattern ensures only one instance of a class exists. Python gives you several ways to implement it — each with different trade-offs:

import threading

# Method 1: Module-level instance (most Pythonic — module import is already a singleton)
# config.py
class _Config:
    def __init__(self):
        self.debug = False
        self.db_url = ''

config = _Config()   # Import config.config — same object every time

# Method 2: Thread-safe singleton with Lock
class DatabasePool:
    _instance: 'DatabasePool | None' = None
    _lock: threading.Lock = threading.Lock()

    def __new__(cls) -> 'DatabasePool':
        if cls._instance is None:
            with cls._lock:
                # Double-checked locking
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialized = False
        return cls._instance

    def __init__(self):
        if self._initialized:
            return
        self._initialized = True
        self.pool = []  # initialize connection pool
        print("DatabasePool initialized")

pool1 = DatabasePool()
pool2 = DatabasePool()
assert pool1 is pool2  # True

# Method 3: Metaclass singleton (reusable, cleaner for library code)
class SingletonMeta(type):
    _instances: dict = {}
    _lock: threading.Lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            with cls._lock:
                if cls not in cls._instances:
                    instance = super().__call__(*args, **kwargs)
                    cls._instances[cls] = instance
        return cls._instances[cls]

class AppSettings(metaclass=SingletonMeta):
    def __init__(self, env: str = 'production'):
        self.env      = env
        self.log_level = 'INFO' if env == 'production' else 'DEBUG'
Pro Tip: In most cases, a module-level instance is the cleanest Python singleton. Use the metaclass approach only when you need the singleton behavior to be reusable across multiple classes in a library.

Factory Patterns

Python's first-class functions make factory patterns simpler than in Java or C++. You rarely need an abstract factory class:

from abc import ABC, abstractmethod
from typing import Protocol

# Simple Factory Function (most common Python approach)
def create_notification(channel: str, **kwargs):
    match channel:
        case 'email': return EmailNotification(**kwargs)
        case 'sms':   return SMSNotification(**kwargs)
        case 'push':  return PushNotification(**kwargs)
        case _:       raise ValueError(f"Unknown channel: {channel!r}")

# Factory Method — subclasses decide which object to create
class Document(ABC):
    @abstractmethod
    def create_parser(self):
        pass

    def process(self, content: str) -> str:
        parser = self.create_parser()   # factory method
        return parser.parse(content)

class PDFDocument(Document):
    def create_parser(self):
        return PDFParser()

class MarkdownDocument(Document):
    def create_parser(self):
        return MarkdownParser()

# Registry-based factory — extensible without modifying the factory
class StorageBackendRegistry:
    _backends: dict = {}

    @classmethod
    def register(cls, name: str):
        def decorator(backend_class):
            cls._backends[name] = backend_class
            return backend_class
        return decorator

    @classmethod
    def create(cls, name: str, **kwargs):
        if name not in cls._backends:
            raise ValueError(f"Storage backend {name!r} not registered")
        return cls._backends[name](**kwargs)

@StorageBackendRegistry.register('s3')
class S3Backend:
    def __init__(self, bucket: str, **kwargs):
        self.bucket = bucket

@StorageBackendRegistry.register('local')
class LocalBackend:
    def __init__(self, path: str, **kwargs):
        self.path = path

# Usage
backend = StorageBackendRegistry.create('s3', bucket='my-bucket')

Observer — EventEmitter

The Observer pattern decouples event producers from event consumers. Python's type system makes this clean with Protocol:

from collections import defaultdict
from typing import Callable, Any

class EventEmitter:
    """Lightweight publish/subscribe event bus."""

    def __init__(self):
        self._listeners: dict[str, list[Callable]] = defaultdict(list)

    def on(self, event: str, callback: Callable) -> None:
        self._listeners[event].append(callback)

    def off(self, event: str, callback: Callable) -> None:
        self._listeners[event] = [
            cb for cb in self._listeners[event] if cb != callback
        ]

    def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
        for callback in list(self._listeners[event]):
            callback(*args, **kwargs)

# Domain example: Order lifecycle events
class OrderService:
    def __init__(self):
        self.events = EventEmitter()

    def place_order(self, order_id: int, items: list, user_id: int) -> dict:
        order = {'id': order_id, 'items': items, 'user_id': user_id, 'status': 'placed'}
        # ... save to DB ...
        self.events.emit('order_placed', order)
        return order

    def ship_order(self, order_id: int) -> None:
        # ... update DB ...
        self.events.emit('order_shipped', {'id': order_id})

# Register observers
service = OrderService()

def send_confirmation_email(order: dict) -> None:
    print(f"Sending confirmation for order #{order['id']} to user {order['user_id']}")

def update_inventory(order: dict) -> None:
    print(f"Reducing inventory for {len(order['items'])} items")

def notify_warehouse(order: dict) -> None:
    print(f"Warehouse notified: order #{order['id']} ready to pick")

service.events.on('order_placed',  send_confirmation_email)
service.events.on('order_placed',  update_inventory)
service.events.on('order_shipped', notify_warehouse)

service.place_order(1001, ['SKU-A', 'SKU-B'], user_id=42)

Strategy Pattern

Strategy lets you swap algorithms at runtime. In Python, functions are first-class objects — the strategy can just be a callable:

from typing import Callable, Protocol
from decimal import Decimal

# Protocol defines the strategy interface
class DiscountStrategy(Protocol):
    def __call__(self, price: Decimal, quantity: int) -> Decimal:
        ...

# Concrete strategies
def no_discount(price: Decimal, quantity: int) -> Decimal:
    return price * quantity

def bulk_discount(price: Decimal, quantity: int) -> Decimal:
    if quantity >= 10:
        return price * quantity * Decimal('0.85')  # 15% off
    return price * quantity

def seasonal_discount(price: Decimal, quantity: int) -> Decimal:
    return price * quantity * Decimal('0.90')  # 10% off always

# Context that uses the strategy
class PricingEngine:
    def __init__(self, strategy: DiscountStrategy = no_discount):
        self._strategy = strategy

    def set_strategy(self, strategy: DiscountStrategy) -> None:
        self._strategy = strategy

    def calculate(self, price: Decimal, quantity: int) -> Decimal:
        return self._strategy(price, quantity)

engine = PricingEngine(bulk_discount)
total = engine.calculate(Decimal('29.99'), 12)
print(f"Total: ${total:.2f}")  # Total: $305.90

# Strategy with class-based approach (for stateful strategies)
class TieredPricingStrategy:
    def __init__(self, tiers: list[tuple[int, float]]):
        """tiers: [(min_qty, discount_pct), ...]  sorted ascending"""
        self.tiers = sorted(tiers, reverse=True)  # highest tier first

    def __call__(self, price: Decimal, quantity: int) -> Decimal:
        for min_qty, discount in self.tiers:
            if quantity >= min_qty:
                return price * quantity * Decimal(str(1 - discount))
        return price * quantity

tiered = TieredPricingStrategy(tiers=[(50, 0.25), (20, 0.15), (10, 0.10)])
engine.set_strategy(tiered)

Repository Pattern for Data Access

The Repository pattern abstracts data access behind a clean interface, making it easy to swap storage implementations and write unit tests with in-memory fakes:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional

@dataclass
class User:
    id       : int
    username : str
    email    : str
    is_active: bool = True

class UserRepository(ABC):
    @abstractmethod
    def get_by_id(self, user_id: int) -> Optional[User]: ...
    @abstractmethod
    def get_by_email(self, email: str) -> Optional[User]: ...
    @abstractmethod
    def save(self, user: User) -> User: ...
    @abstractmethod
    def delete(self, user_id: int) -> bool: ...
    @abstractmethod
    def list_active(self, limit: int = 100) -> list[User]: ...

# SQLAlchemy implementation
class SQLUserRepository(UserRepository):
    def __init__(self, session):
        self.session = session

    def get_by_id(self, user_id: int) -> Optional[User]:
        from sqlalchemy import select
        from .models import UserModel
        row = self.session.scalar(select(UserModel).where(UserModel.id == user_id))
        return User(id=row.id, username=row.username, email=row.email) if row else None

    def save(self, user: User) -> User:
        # upsert logic ...
        return user

    def get_by_email(self, email):     ...
    def delete(self, user_id):         ...
    def list_active(self, limit=100):  ...

# In-memory fake for tests — no database required
class InMemoryUserRepository(UserRepository):
    def __init__(self):
        self._store: dict[int, User] = {}
        self._next_id = 1

    def get_by_id(self, user_id: int) -> Optional[User]:
        return self._store.get(user_id)

    def get_by_email(self, email: str) -> Optional[User]:
        return next((u for u in self._store.values() if u.email == email), None)

    def save(self, user: User) -> User:
        if user.id == 0:
            user.id = self._next_id
            self._next_id += 1
        self._store[user.id] = user
        return user

    def delete(self, user_id: int) -> bool:
        return bool(self._store.pop(user_id, None))

    def list_active(self, limit: int = 100) -> list[User]:
        return [u for u in list(self._store.values())[:limit] if u.is_active]

Dependency Injection in Python

Python doesn't need a DI framework for most applications. Constructor injection is clean and testable:

class UserService:
    def __init__(self, repo: UserRepository, email_service, logger):
        self.repo  = repo
        self.email = email_service
        self.log   = logger

    def register(self, username: str, email: str) -> User:
        if self.repo.get_by_email(email):
            raise ValueError(f"Email {email!r} already registered")
        user = self.repo.save(User(id=0, username=username, email=email))
        self.email.send_welcome(user)
        self.log.info("user_registered", user_id=user.id, username=username)
        return user

# Production wiring
from sqlalchemy.orm import Session
import structlog

def build_user_service(db_session: Session) -> UserService:
    return UserService(
        repo          = SQLUserRepository(db_session),
        email_service = SMTPEmailService(),
        logger        = structlog.get_logger('myapp.users'),
    )

# Test wiring — no mocking library needed
def test_register_duplicate_email():
    repo   = InMemoryUserRepository()
    svc    = UserService(repo=repo, email_service=FakeEmailService(), logger=FakeLogger())
    svc.register('alice', 'alice@example.com')
    try:
        svc.register('alice2', 'alice@example.com')
        assert False, "Should have raised"
    except ValueError:
        pass
Note: FastAPI's Depends() system is a built-in DI container. Use it for request-scoped dependencies (database sessions, current user). For application-level singletons (repositories, services), wire them up at startup in the app factory function.

When NOT to Use Design Patterns

PatternSkip It When...Python Alternative
SingletonYou just need one shared objectModule-level instance
Factory MethodYou have 2–3 concrete typesSimple if/elif or match/case
Abstract FactoryYou don't need to swap familiesPlain functions or dataclasses
IteratorAlwaysGenerator functions (yield)
CommandFunctions cover your use caseCallable objects, functools.partial
AdapterDuck typing satisfies the contractDuck typing — no wrapper needed
Decorator (GoF)AlwaysPython @ decorator syntax

Python's duck typing means you rarely need an explicit interface hierarchy to achieve polymorphism. If it has a save() method and a load() method, it's a valid storage backend — no abstract base class required unless you want enforced contracts or IDE autocomplete.

Pro Tip: The most important "design pattern" in Python is writing small, single-purpose functions. Most over-engineered Python code has too many classes, not too few. Before reaching for a pattern, ask: could this be a function, a generator, or a dataclass?

Frequently Asked Questions

Is the Singleton pattern evil?
It has legitimate uses — connection pools, configuration objects, loggers — but it makes unit testing hard because the global state persists between tests. Always provide a way to reset the singleton (a class method or module-level reset function) for tests. If you find yourself fighting the singleton in tests, consider switching to dependency injection.
What is the difference between Strategy and Template Method?
Strategy varies the whole algorithm through composition (passing a callable or object). Template Method fixes the algorithm skeleton in a base class and lets subclasses override specific steps. Strategy is more flexible and easier to test; Template Method is simpler when the variations are small and the skeleton is stable.
Should I use ABCs or Protocols?
Use Protocol (structural subtyping) for interfaces that third-party code needs to satisfy without inheriting from your base class. Use ABC when you want to enforce that subclasses implement specific methods at class definition time (raises TypeError on instantiation if abstract methods are missing). In practice, Protocol is more Pythonic and more flexible.
How do I implement a clean DI container in Python?
For small applications, manual constructor injection is sufficient. For larger codebases, libraries like dependency-injector or lagom provide container-based DI with auto-wiring. FastAPI's Depends is production-battle-tested for web applications. Avoid over-engineering DI until you actually have more than ~10 dependencies to wire.
What is the difference between the Observer pattern and Python's signals?
Django's signals (post_save, pre_delete) are a built-in Observer implementation coupled to the Django ORM. They're convenient for loosely coupling model changes to side effects. For custom event systems in non-Django code, implement your own EventEmitter as shown above, or use a library like blinker. Avoid Django signals for business logic — they make control flow hard to trace.