Python Design Patterns: Modern Code and Best Practices
Design patterns are reusable solutions to recurring software design problems. The classic Gang of Four patterns from 1994 were described for statically-typed languages like C++ and Smalltalk. Python’s dynamic features — first-class functions, duck typing, protocols, context managers, and metaclasses — often simplify pattern implementations dramatically. This guide covers the most relevant design patterns for modern Python development, with implementations idiomatic to the language.
Why Python Changes the Pattern Game
Python’s object model differs fundamentally from the statically-typed languages where patterns were originally catalogued. First-class functions mean that Strategy, Command, and Observer patterns can often be implemented as function references rather than class hierarchies. Duck typing eliminates the need for explicit interface hierarchies. Python’s __dunder__ methods provide protocol-based polymorphism that replaces many classic structural patterns.
The patterns worth knowing in Python are not the implementations, but the problems they solve. Recognizing when you need to ensure a single instance, when you need to decouple sender from receiver, or when you need to compose behavior dynamically is more valuable than memorizing class diagrams.
Alex Martelli’s observation that “design patterns are bug reports against your programming language” captures the Pythonic perspective. Where Java requires the Visitor pattern due to single dispatch, Python provides functools.singledispatch. Where C++ requires the Abstract Factory pattern for platform-independent code, Python’s duck typing makes factories trivial. The Python documentation on protocols (PEP 544) provides structural typing that further reduces the need for traditional pattern scaffolding.
Creational Patterns
Singleton
Python modules are singletons by nature — a module is imported once and reused on subsequent imports. This is often sufficient for configuration, registry, and logging use cases. When a class-level singleton is truly needed, a metaclass provides the cleanest implementation:
class SingletonMeta(type):
_instances: dict = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class DatabasePool(metaclass=SingletonMeta):
def __init__(self):
self._connections = []
def acquire(self):
...The Borg pattern, also called Monostate, shares state across instances rather than restricting instantiation. All instances share the same __dict__:
class BorgConfiguration:
_shared_state: dict = {}
def __init__(self):
self.__dict__ = self._shared_state
def __getattr__(self, name):
return self._shared_state.get(name)
def __setattr__(self, name, value):
self._shared_state[name] = valueThe Borg pattern is appropriate when you want the API to support multiple constructions but share configuration state transparently. It is commonly used for application-wide settings managers.
Factory
Python’s first-class functions make factory patterns trivial. A simple factory is a function that returns objects:
from dataclasses import dataclass
from typing import Literal
@dataclass
class User:
name: str
role: str
@dataclass
class Admin:
name: str
permissions: list[str]
def create_user(name: str, role: Literal["user", "admin"]):
if role == "admin":
return Admin(name=name, permissions=["read", "write", "delete"])
return User(name=name, role="user")For more complex factory requirements, a class-based factory with registry pattern provides extensibility:
from typing import Callable
class ModelFactory:
_registry: dict[str, Callable] = {}
@classmethod
def register(cls, name: str):
def decorator(builder: Callable):
cls._registry[name] = builder
return builder
return decorator
@classmethod
def create(cls, name: str, **kwargs):
builder = cls._registry.get(name)
if not builder:
raise ValueError(f"Unknown model: {name}")
return builder(**kwargs)
@ModelFactory.register("linear")
def linear_regression(**kwargs):
...
@ModelFactory.register("random_forest")
def random_forest(**kwargs):
...The registry pattern used here is Pythonic and extensible — third-party code can register their own builders without modifying the factory class. This is similar to how Django’s model forms and DRF serializers use registration.
Structural Patterns
Adapter
Python’s duck typing often eliminates the need for explicit adapter classes. If an object has the right methods, it works — no adapter required. When adaptation is necessary, __getattr__ provides dynamic delegation:
class LegacyLogger:
def log_message(self, msg):
print(f"LEGACY: {msg}")
class ModernLogger:
def info(self, message):
print(f"INFO: {message}")
def error(self, message):
print(f"ERROR: {message}")
class LoggerAdapter:
def __init__(self, legacy_logger):
self._logger = legacy_logger
def info(self, message):
self._logger.log_message(f"INFO: {message}")
def error(self, message):
self._logger.log_message(f"ERROR: {message}")The adapter translates the modern interface to the legacy implementation, enabling gradual migration without modifying the legacy code. This pattern is frequently used when replacing third-party libraries where the interface differs from the replacement.
Decorator
The Decorator pattern composes behavior dynamically. Python’s decorator syntax, function wrappers, and functools.wraps provide native support:
from functools import wraps
import time
import logging
logger = logging.getLogger(__name__)
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
logger.debug("%s took %.3fs", func.__name__, elapsed)
return wrapper
def retry(max_attempts: int = 3, delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
logger.warning(
"Attempt %d/%d failed: %s",
attempt + 1, max_attempts, e
)
time.sleep(delay)
return None
return wrapper
return decorator
@timed
@retry(max_attempts=3)
def fetch_data(url: str) -> dict:
import requests
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()Class-based decorators using __call__ provide stateful decoration with configuration:
class RateLimited:
def __init__(self, calls_per_second: float):
self.min_interval = 1.0 / calls_per_second
self.last_call = 0.0
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - self.last_call
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
result = func(*args, **kwargs)
self.last_call = time.time()
return result
return wrapperThe Decorator pattern in Python is so deeply integrated into the language that its use extends far beyond the GoF specification. Python uses decorators for static methods, class methods, properties, context management, and more. As noted in PEP 318, decorators were specifically added to the language because the pattern was so useful.
Behavioral Patterns
Observer
The Observer pattern establishes a one-to-many dependency where changes to one object are propagated to dependents. A minimal observable class:
from typing import Callable, Any
class Observable:
def __init__(self):
self._observers: list[Callable] = []
def subscribe(self, callback: Callable) -> Callable:
self._observers.append(callback)
return callback # Allow use as decorator
def unsubscribe(self, callback: Callable) -> None:
self._observers.remove(callback)
def notify(self, **kwargs: Any) -> None:
for observer in self._observers:
observer(**kwargs)
class DataStore(Observable):
def __init__(self):
super().__init__()
self._data: dict[str, Any] = {}
def set(self, key: str, value: Any) -> None:
self._data[key] = value
self.notify(key=key, value=value)For more structured observer patterns, Python’s blinker library provides a production-ready signal dispatcher. The asyncio event loop also supports observer-like patterns through Future callbacks and custom event handlers.
Strategy
The Strategy pattern encapsulates interchangeable algorithms. In Python, strategies are naturally expressed as functions or callable objects:
from typing import Callable, Sequence
SortStrategy = Callable[[Sequence[float]], list[float]]
def bubble_sort(data: Sequence[float]) -> list[float]:
items = list(data)
n = len(items)
for i in range(n):
for j in range(0, n - i - 1):
if items[j] > items[j + 1]:
items[j], items[j + 1] = items[j + 1], items[j]
return items
def quick_sort(data: Sequence[float]) -> list[float]:
items = list(data)
if len(items) <= 1:
return items
pivot = items[0]
less = [x for x in items[1:] if x <= pivot]
greater = [x for x in items[1:] if x > pivot]
return quick_sort(less) + [pivot] + quick_sort(greater)
class Sorter:
def __init__(self, strategy: SortStrategy = quick_sort):
self._strategy = strategy
def sort(self, data: Sequence[float]) -> list[float]:
return self._strategy(data)The strategy can be swapped at runtime based on data characteristics. This pattern is used extensively in Python’s standard library — for example, sorted() accepts a key function that is essentially a Strategy for defining sort order.
Command
The Command pattern encapsulates a request as an object. In Python, functions and closures serve this role naturally. For undoable operations, a command class with execute and undo methods:
from typing import Protocol
class Command(Protocol):
def execute(self) -> None:
...
def undo(self) -> None:
...
class InsertText:
def __init__(self, document: list[str], position: int, text: str):
self._document = document
self._position = position
self._text = text
def execute(self) -> None:
self._document.insert(self._position, self._text)
def undo(self) -> None:
del self._document[self._position]
class CommandHistory:
def __init__(self):
self._history: list[Command] = []
def execute(self, command: Command) -> None:
command.execute()
self._history.append(command)
def undo_last(self) -> None:
if self._history:
command = self._history.pop()
command.undo()Pythonic Patterns Beyond GoF
Beyond the Gang of Four patterns, Python’s ecosystem has developed idiomatic patterns leveraging language features. The context manager protocol (__enter__ / __exit__) manages resource lifecycle. The contextlib module provides utilities for creating context managers without class definitions:
from contextlib import contextmanager
@contextmanager
def timed_block(label: str):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")Descriptors (__get__ / __set__) control attribute access, replacing verbose getter/setter patterns:
class Validated:
def __init__(self, validator):
self.validator = validator
self.data = {}
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.data.get(id(obj))
def __set__(self, obj, value):
self.validator(value)
self.data[id(obj)] = value
class Person:
age = Validated(lambda v: 0 <= v <= 150)
name = Validated(lambda v: isinstance(v, str) and len(v) > 0)Protocols (PEP 544) provide structural typing, letting you define interfaces without inheritance. This is Python’s answer to the Interface Segregation Principle — protocols define what an object can do, not what it is:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...FAQ
Do I need design patterns in Python?
Understanding the problems design patterns solve is valuable, but rigid pattern implementations are often unnecessary. Python’s dynamic features frequently provide simpler solutions than pattern class hierarchies.
What is the most useful design pattern in Python?
The Decorator pattern, embodied in Python’s decorator syntax, is the most frequently applicable. It enables cross-cutting concerns — timing, logging, caching, retry — to be applied declaratively without modifying function internals.
Are design patterns still relevant with modern Python features?
Yes, but the implementation changes. Modern Python features — dataclasses, protocols, context managers, pattern matching — provide more concise implementations of classic patterns. The underlying problems remain relevant.
How do I decide between function-based and class-based implementations?
Use functions when the pattern has no state or configuration (simple strategy, simple command). Use classes when the pattern maintains state across calls (stateful observer, undoable command) or when configuration requires multiple parameters.
What is the anti-pattern to avoid in Python design patterns?
Over-engineering. Implementing AbstractFactoryFactory patterns from Java tutorials adds complexity without benefit. Start with the simplest solution and add pattern structure only when the code genuinely requires it.
Related: Explore Python concurrency for patterns applied to concurrent execution. Learn Python error handling for exception-handling patterns. Study Python decorators for the most idiomatic Python pattern implementation.