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'
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
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
| Pattern | Skip It When... | Python Alternative |
|---|---|---|
| Singleton | You just need one shared object | Module-level instance |
| Factory Method | You have 2–3 concrete types | Simple if/elif or match/case |
| Abstract Factory | You don't need to swap families | Plain functions or dataclasses |
| Iterator | Always | Generator functions (yield) |
| Command | Functions cover your use case | Callable objects, functools.partial |
| Adapter | Duck typing satisfies the contract | Duck typing — no wrapper needed |
| Decorator (GoF) | Always | Python @ 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.
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. UseABCwhen you want to enforce that subclasses implement specific methods at class definition time (raisesTypeErroron instantiation if abstract methods are missing). In practice,Protocolis 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-injectororlagomprovide container-based DI with auto-wiring. FastAPI'sDependsis 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 likeblinker. Avoid Django signals for business logic — they make control flow hard to trace.