Skip to content
Home
Python Design Patterns: Modern Code and Best Practices

Python Design Patterns: Modern Code and Best Practices

Python Python 8 min read 1652 words Beginner ExcellentWiki Editorial Team

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] = value

The 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 wrapper

The 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.

Section: Python 1652 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top