Design patterns are time-tested solutions to recurring problems in software design. In Python, patterns take on a uniquely “pythonic” flavor because the language emphasizes readability, duck typing, first-class functions, and batteries-included libraries.

This guide takes you from beginner to advanced—covering the classic Gang of Four (GoF) patterns, Pythonic equivalents, concurrency and async patterns, architectural patterns, and metaprogramming techniques. You’ll learn when to use a pattern, the pitfalls to avoid, and how to apply patterns idiomatically in Python so you can ship maintainable, scalable systems and be more capable than 99% of your peers.

Note: Patterns are a means to communicate design intent and reduce accidental complexity, not a checklist. Prefer simple, direct code. Reach for a pattern when it makes the intent clearer or the system more adaptable.

Table of Contents

Prerequisites and Mental Models

  • Embrace Python’s EAFP style (Easier to Ask Forgiveness than Permission) over LBYL (Look Before You Leap). Many patterns become simpler when you rely on duck typing and exceptions.
  • Prefer composition over inheritance. Most reusable designs are easier when objects collaborate rather than subclassing deeply.
  • Distinguish interfaces by behavior (Protocols) rather than base classes when possible.
  • Isolate side effects and couple through abstractions. This is the essence of most patterns.

Pythonic Building Blocks You’ll Use in Patterns

  • First-class functions and closures for Strategy, Command, and Template hooks.
  • Decorators and context managers for cross-cutting concerns (timing, logging, resource control).
  • Dataclasses and attrs for value objects and builders.
  • typing.Protocol and ABCs for interface contracts that remain flexible.
  • itertools, functools (lru_cache, singledispatch), contextlib for powerful, succinct implementations.

Example: a simple timing decorator to reuse across patterns.

import time
from functools import wraps

def timed(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{fn.__name__} took {elapsed:.3f}s")
    return wrapper

Tip: Prefer function decorators or contextlib.contextmanager to structural Decorator when behavior is cross-cutting and orthogonal.

Creational Patterns

Simple Factory

Encapsulate object creation in a function. In Python, this is often enough.

from dataclasses import dataclass

@dataclass
class SMS: 
    to: str; body: str

@dataclass
class Email:
    to: str; subject: str; body: str

def make_notifier(kind: str, **kwargs):
    match kind:
        case "sms":   return SMS(**kwargs)
        case "email": return Email(**kwargs)
        case _: raise ValueError(f"Unknown notifier {kind}")

Abstract Factory

Produce families of related objects without specifying concrete classes. Useful for swapping backends.

from typing import Protocol

class Cache(Protocol):
    def get(self, key: str): ...
    def set(self, key: str, value, ttl: int | None = None): ...

class CacheFactory(Protocol):
    def create_cache(self) -> Cache: ...

class InMemoryCache(dict):
    def get(self, key): return super().get(key)
    def set(self, key, value, ttl=None): self[key] = value

class InMemoryFactory:
    def create_cache(self) -> Cache:
        return InMemoryCache()

def use_cache(factory: CacheFactory):
    cache = factory.create_cache()
    cache.set("x", 1)
    return cache.get("x")

Builder

Build complex objects step-by-step. In Python, prefer dataclasses with defaults and fluent helpers.

from dataclasses import dataclass, field

@dataclass
class Report:
    title: str = "Untitled"
    items: list[str] = field(default_factory=list)
    footer: str | None = None

class ReportBuilder:
    def __init__(self): self._report = Report()
    def titled(self, title): self._report.title = title; return self
    def add_item(self, item): self._report.items.append(item); return self
    def with_footer(self, footer): self._report.footer = footer; return self
    def build(self) -> Report: return self._report

report = (ReportBuilder()
          .titled("Sales")
          .add_item("Q1: 100k")
          .add_item("Q2: 120k")
          .with_footer("Confidential")
          .build())

Prototype

Clone existing instances, possibly with modifications.

import copy

proto = Report(title="Base", items=["common"])
clone = copy.deepcopy(proto)
clone.title = "Customized"

Singleton (and Better Alternatives)

Singletons complicate testing. Prefer:

  • Module-level singletons (imported once)
  • Dependency injection with one instance
  • Borg/Monostate pattern (shared state across instances)
class Borg:
    _state = {}
    def __init__(self): self.__dict__ = Borg._state

a, b = Borg(), Borg()
a.x = 42
assert b.x == 42

Avoid hard singletons. Pass dependencies in constructors so tests can supply fakes.

Flyweight

Share intrinsic state to reduce memory usage.

from functools import lru_cache

@lru_cache(maxsize=None)
def glyph(char: str):
    # Imagine this loads a heavy vector shape
    return {"char": char, "shape": f"shape_of_{char}"}

a = glyph("A"); b = glyph("A")
assert a is b

Structural Patterns

Adapter

Make one interface look like another.

class LegacySMS:
    def send_text(self, number, message): print(f"Text to {number}: {message}")

class Notifier(Protocol):
    def notify(self, to: str, body: str): ...

class SMSAdapter:
    def __init__(self, sms: LegacySMS): self.sms = sms
    def notify(self, to: str, body: str): self.sms.send_text(to, body)

client = SMSAdapter(LegacySMS())
client.notify("+1-555-0100", "Hello")

Facade

Provide a simple API over a complex subsystem.

class PaymentFacade:
    def __init__(self, gateway, ledger, fraud):
        self.gateway, self.ledger, self.fraud = gateway, ledger, fraud

    def charge(self, user_id: str, amount: int, card_token: str):
        if self.fraud.suspect(user_id, amount): 
            return {"status": "blocked"}
        tx = self.gateway.charge(card_token, amount)
        self.ledger.record(user_id, tx)
        return {"status": "ok", "tx": tx}

Proxy

Control access, lazy-load, cache, or add security.

class Image:
    def __init__(self, path): self.path = path
    def load(self): print(f"Loading {self.path}..."); return b"..."

class LazyImageProxy:
    def __init__(self, path): self._path = path; self._img = None
    def data(self):
        if self._img is None: self._img = Image(self._path).load()
        return self._img

Decorator (Structural) vs Function Decorators

Structural Decorator wraps an object to add responsibilities. Function decorators wrap callables.

class NotifierBase(Protocol):
    def notify(self, to: str, body: str): ...

class LoggingDecorator:
    def __init__(self, inner: NotifierBase): self.inner = inner
    def notify(self, to: str, body: str):
        print(f"Sending to {to}")
        return self.inner.notify(to, body)

Composite

Treat part-whole hierarchies uniformly.

from typing import Iterable

class Node(Protocol):
    def render(self) -> str: ...

class Text:
    def __init__(self, s): self.s = s
    def render(self): return self.s

class Group:
    def __init__(self, children: Iterable[Node]): self.children = list(children)
    def render(self): return "".join(child.render() for child in self.children)

html = Group([Text("<h1>Hi</h1>"), Text("<p>Welcome</p>")]).render()

Behavioral Patterns

Strategy

Swap algorithms at runtime. In Python, often just pass a function.

from typing import Callable

PriceStrategy = Callable[[float], float]

def no_discount(p): return p
def ten_percent(p): return p * 0.9

def checkout(price: float, discount: PriceStrategy = no_discount):
    return discount(price)

assert checkout(100, ten_percent) == 90

Command

Encapsulate an operation as an object. Great for queues and undo.

class Command(Protocol):
    def execute(self): ...

class RenameFile:
    def __init__(self, fs, src, dst): self.fs, self.src, self.dst = fs, src, dst
    def execute(self): self.fs.rename(self.src, self.dst)

queue: list[Command] = []
queue.append(RenameFile(fs=os, src="a.txt", dst="b.txt"))
for cmd in queue: cmd.execute()

Observer / Pub-Sub

Notify multiple subscribers of events.

import weakref
from collections import defaultdict
from typing import Callable

class EventBus:
    def __init__(self): self._subs = defaultdict(list)
    def subscribe(self, topic: str, fn: Callable): 
        self._subs[topic].append(weakref.WeakMethod(fn) if hasattr(fn, "__self__") else fn)
    def publish(self, topic: str, *args, **kwargs):
        alive = []
        for sub in self._subs[topic]:
            fn = sub() if isinstance(sub, weakref.WeakMethod) else sub
            if fn:
                fn(*args, **kwargs); alive.append(sub)
        self._subs[topic] = alive

Mediator

Centralize complex communications.

class ChatRoom:
    def __init__(self): self.users = {}
    def join(self, user): self.users[user.name] = user; user.room = self
    def send(self, from_name, to_name, msg):
        self.users[to_name].receive(from_name, msg)

class User:
    def __init__(self, name): self.name, self.room = name, None
    def send(self, to, msg): self.room.send(self.name, to, msg)
    def receive(self, from_, msg): print(f"{from_} -> {self.name}: {msg}")

Template Method

Define skeleton of an algorithm, defer steps to subclasses or functions.

from abc import ABC, abstractmethod

class ETL(ABC):
    def run(self):
        data = self.extract()
        data = self.transform(data)
        self.load(data)

    @abstractmethod
    def extract(self): ...
    def transform(self, data): return data
    @abstractmethod
    def load(self, data): ...

State

Let an object alter behavior when its internal state changes.

class OrderState(Protocol):
    def pay(self, order): ...
    def ship(self, order): ...

class New:
    def pay(self, order): order.state = Paid()
    def ship(self, order): raise RuntimeError("Pay first")

class Paid:
    def pay(self, order): pass
    def ship(self, order): order.state = Shipped()

class Shipped:
    def pay(self, order): pass
    def ship(self, order): pass

class Order:
    def __init__(self): self.state: OrderState = New()
    def pay(self): self.state.pay(self)
    def ship(self): self.state.ship(self)

Iterator and Generator

Leverage Python’s generator protocol.

def fibonacci(limit: int):
    a, b = 0, 1
    while a <= limit:
        yield a
        a, b = b, a + b

for n in fibonacci(10): pass

Visitor

Separate operations from object structure. In Python, consider singledispatch.

from functools import singledispatch

class Circle: 
    def __init__(self, r): self.r = r

class Rectangle:
    def __init__(self, w, h): self.w, self.h = w, h

@singledispatch
def area(shape): raise NotImplementedError

@area.register
def _(shape: Circle): return 3.14159 * shape.r**2

@area.register
def _(shape: Rectangle): return shape.w * shape.h

Concurrency and Async Patterns

Producer–Consumer

Use queues to decouple producers and consumers.

import asyncio

async def producer(q: asyncio.Queue):
    for i in range(5):
        await q.put(i)
    await q.put(None)  # sentinel

async def consumer(q: asyncio.Queue):
    while (item := await q.get()) is not None:
        print("consumed", item)

async def main():
    q = asyncio.Queue()
    await asyncio.gather(producer(q), consumer(q))

asyncio.run(main())

Pipeline

Chain stages, each a coroutine, passing items along.

async def stage1(q_in, q_out):
    while (x := await q_in.get()) is not None:
        await q_out.put(x * 2)
    await q_out.put(None)

async def stage2(q_in):
    while (x := await q_in.get()) is not None:
        print("final", x)

async def run_pipeline(items):
    q1, q2 = asyncio.Queue(), asyncio.Queue()
    async def feeder():
        for x in items: await q1.put(x)
        await q1.put(None)
    await asyncio.gather(feeder(), stage1(q1, q2), stage2(q2))

Actor Model (Lightweight)

Each actor owns state and processes messages sequentially.

class Actor:
    def __init__(self): self.q = asyncio.Queue()
    async def send(self, msg): await self.q.put(msg)
    async def run(self):
        while True:
            msg = await self.q.get()
            if msg == "STOP": break
            await self.handle(msg)
    async def handle(self, msg): ...

class Counter(Actor):
    def __init__(self): super().__init__(); self.n = 0
    async def handle(self, msg):
        if msg == "INC": self.n += 1

async def main():
    c = Counter()
    task = asyncio.create_task(c.run())
    for _ in range(100): await c.send("INC")
    await c.send("STOP"); await task

Retry, Backoff, and Circuit Breaker

Resilience patterns for flaky networks.

import asyncio, random, time

async def retry(fn, retries=3, base=0.1):
    for i in range(retries):
        try:
            return await fn()
        except Exception:
            await asyncio.sleep(base * (2 ** i) + random.random() * 0.05)
    raise

class CircuitBreaker:
    def __init__(self, fail_max=5, reset_after=5.0):
        self.fail_max = fail_max; self.reset_after = reset_after
        self.failures = 0; self.open_until = 0.0
    def allow(self):
        return time.time() >= self.open_until
    def record(self, ok: bool):
        if ok: self.failures = 0
        else:
            self.failures += 1
            if self.failures >= self.fail_max:
                self.open_until = time.time() + self.reset_after

async def call_with_cb(cb: CircuitBreaker, coro_factory):
    if not cb.allow(): raise RuntimeError("Circuit open")
    try:
        res = await coro_factory(); cb.record(True); return res
    except Exception:
        cb.record(False); raise

Architectural Patterns

Layered Architecture

Separate concerns into layers (presentation, application/service, domain, infrastructure).

  • Presentation: adapters (CLI, HTTP)
  • Application: orchestrates use cases
  • Domain: business rules, entities, services
  • Infrastructure: DB, messaging, external APIs

Keep domain pure. Depend inward. Use dependency inversion to isolate infrastructure.

MVC/MVP/MVVM in Python Contexts

  • Web frameworks (Django, Flask + Blueprints, FastAPI routers) implement variations of MVC.
  • GUI (PyQt, Tkinter) often uses MVC/MVP.
  • The principle: separate UI state from business logic and models.

Hexagonal (Ports and Adapters)

Define ports (interfaces) for your app’s core; write adapters for external systems.

# port
class EmailPort(Protocol):
    def send(self, to: str, subject: str, body: str): ...

# domain service depends on port
class WelcomeService:
    def __init__(self, mailer: EmailPort): self.mailer = mailer
    def welcome(self, user):
        self.mailer.send(user.email, "Welcome", "Glad you're here!")

# adapter
class SMTPMailer:
    def send(self, to, subject, body): ...

Dependency Injection (DI) Without a Framework

Constructor injection is enough for most Python projects.

class Service:
    def __init__(self, repo, notifier): self.repo, self.notifier = repo, notifier

Compose dependencies at the edge (main function).

def main():
    repo = SqlAlchemyRepo(...)
    notifier = SMTPMailer(...)
    svc = Service(repo, notifier)

Repository and Unit of Work

Abstract persistence and manage transactions.

class Repo(Protocol):
    def add(self, entity): ...
    def get(self, id): ...

class UnitOfWork(Protocol):
    repo: Repo
    def __enter__(self): ...
    def __exit__(self, exc_type, exc, tb): ...
    def commit(self): ...

class SqlAlchemyUoW:
    def __init__(self, session_factory):
        self.session_factory = session_factory
    def __enter__(self):
        self.session = self.session_factory()
        self.repo = SqlAlchemyRepo(self.session)
        return self
    def __exit__(self, *exc):
        if exc[0] is None: self.session.commit()
        else: self.session.rollback()
        self.session.close()
    def commit(self): self.session.commit()

CQRS and Event Sourcing (Overview)

  • CQRS: Separate read and write models for performance and clarity.
  • Event Sourcing: Store events, rebuild state by replay. Use cautiously; it adds complexity.

Plugin Architectures

Enable extensibility with dynamic loading.

  • Entry points (setuptools) or importlib.metadata to discover plugins
  • Registry pattern via decorators
REGISTRY = {}

def plugin(name):
    def deco(cls):
        REGISTRY[name] = cls
        return cls
    return deco

@plugin("csv")
class CSVLoader: ...

Metaprogramming Patterns

Descriptors for Validation

Control attribute access.

class Positive:
    def __set_name__(self, owner, name): self.name = "_" + name
    def __get__(self, obj, objtype=None): return getattr(obj, self.name, 0)
    def __set__(self, obj, value):
        if value <= 0: raise ValueError("must be positive")
        setattr(obj, self.name, value)

class Account:
    balance = Positive()
    def __init__(self, balance): self.balance = balance

Class Decorators and Registries

Annotate and register types for factories or serialization.

SERIALIZERS = {}

def serializer(fmt):
    def deco(fn):
        SERIALIZERS[fmt] = fn
        return fn
    return deco

@serializer("json")
def to_json(obj): ...

Metaclasses for Frameworks

Use sparingly to build DSLs or automatic registration.

class RegistryMeta(type):
    registry = {}
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        if not name.startswith("Base"):
            mcls.registry[name] = cls
        return cls

class BaseModel(metaclass=RegistryMeta): ...
class User(BaseModel): ...
assert "User" in RegistryMeta.registry

Patterns for Testing and Maintainability

Null Object

Avoid None checks by providing a do-nothing object.

class NullNotifier:
    def notify(self, to, body): pass

Test Data Builders

Make test setup clear and reusable.

class UserBuilder:
    def __init__(self): self._u = {"name": "Alice", "email": "alice@example.com"}
    def with_email(self, email): self._u["email"] = email; return self
    def build(self): return self._u

Fakes, Stubs, and Spies

  • Fakes: working in-memory implementations
  • Stubs: return fixed data
  • Spies: record calls
class SpyMailer:
    def __init__(self): self.sent = []
    def send(self, to, subject, body): self.sent.append((to, subject, body))

Anti-Patterns and Code Smells

  • Overuse of Singletons and global state
  • God classes/modules
  • Deep inheritance hierarchies
  • Premature abstraction; speculative generality
  • Feature envy (class doing too much of another’s work)
  • Lava flow (dead, unrefactored code)

Rule of thumb: Only introduce a pattern when you can name the problem it solves and demonstrate reduced complexity.

Performance-Conscious Patterns

  • Memoization (functools.lru_cache) for pure functions
  • Object pooling is rarely needed in Python; prefer lazy creation
  • Flyweight for large numbers of similar immutable objects
  • Use generators and iterators to stream data
  • Prefer built-in containers and algorithms; leverage vectorization (NumPy) where appropriate
from functools import lru_cache

@lru_cache(maxsize=1024)
def expensive(x: int) -> int:
    return sum(i*i for i in range(x))

Mini Case Study: A Pluggable Async ETL Pipeline

Combine Strategy, Pipeline, DI, and Plugins.

Requirements:

  • Load data from pluggable sources (CSV/HTTP)
  • Transform with strategies
  • Load to sink asynchronously
import asyncio
from typing import Protocol, Iterable

# Ports
class Source(Protocol):
    async def read(self) -> Iterable[dict]: ...

class Transformer(Protocol):
    def __call__(self, row: dict) -> dict: ...

class Sink(Protocol):
    async def write(self, row: dict): ...
    async def close(self): ...

# Plugins (adapters)
class CSVSource:
    def __init__(self, path): self.path = path
    async def read(self):
        import csv
        # simplistic async generator using thread pool handoff if needed
        for row in csv.DictReader(open(self.path)):  # demo only
            yield row

class PrintSink:
    async def write(self, row): print(row)
    async def close(self): pass

# Strategies
def select(*fields):
    def _sel(row): return {k: row[k] for k in fields if k in row}
    return _sel

def uppercase(field):
    def _up(row): 
        row = dict(row); row[field] = row[field].upper(); return row
    return _up

# Pipeline
async def run_etl(source: Source, transforms: list[Transformer], sink: Sink):
    async for row in source.read():
        for t in transforms: row = t(row)
        await sink.write(row)
    await sink.close()

# Composition (DI at the edge)
async def main():
    s = CSVSource("users.csv")
    t = [select("name", "email"), uppercase("name")]
    k = PrintSink()
    await run_etl(s, t, k)

# asyncio.run(main())  # uncomment in real application

Improvements you could add:

  • Backpressure with asyncio.Queue between stages
  • Retry and circuit breaker around sink writes
  • Plugin discovery via entry points to load Source/Sink at runtime

Pattern Selection Cheat Sheet

  • Need multiple interchangeable algorithms? Strategy
  • Need to queue, undo, or log operations? Command
  • Need to notify many listeners? Observer / Pub-Sub
  • Simplify a complex subsystem? Facade
  • Make one API look like another? Adapter
  • Add responsibilities dynamically? Decorator (structural) or function decorators
  • Represent tree structures uniformly? Composite
  • Manage state transitions cleanly? State
  • Different families/backends of objects? Abstract Factory
  • Build complex objects stepwise? Builder
  • Share heavy immutable data? Flyweight
  • Async pipelines and decoupling stages? Producer–Consumer / Pipeline
  • Keep domain pure from infrastructure? Hexagonal + DI
  • Manage transactions and persistence? Repository + Unit of Work

Conclusion

Design patterns are about shared vocabulary and deliberate trade-offs. In Python, many patterns can be implemented more succinctly thanks to first-class functions, duck typing, and powerful standard libraries. Start with clarity and simplicity, and introduce patterns when they make intent explicit and change cheaper. Mastering both the classic GoF catalog and Pythonic idioms will help you design systems that are maintainable, testable, and ready to scale.

Use this guide as a reference, revisit it when you notice recurring design problems, and practice by refactoring small projects. Over time, you’ll develop an intuition for when a pattern clarifies the design—and when it’s unnecessary ceremony.

Best Resources

  • Design Patterns: Elements of Reusable Object-Oriented Software (Gamma et al.) — the GoF classic
  • Fluent Python, 2nd Edition (Luciano Ramalho) — deep dive into Pythonic patterns and idioms
  • Architecture Patterns with Python (Percival & Gregory) — DDD, Repository, Unit of Work in Python
  • Effective Python, 2nd Edition (Brett Slatkin) — practical tips that align with pattern thinking
  • Refactoring, 2nd Edition (Martin Fowler) — smells and refactorings that motivate patterns
  • Patterns of Enterprise Application Architecture (Martin Fowler) — Repository, Unit of Work, CQRS
  • Python Standard Library docs: functools, itertools, contextlib, typing, asyncio
  • SQLAlchemy and Alembic docs — for Repository/UoW implementations
  • attrs and dataclasses documentation — for value objects and builders
  • importlib.metadata and entry points — for plugin discovery
  • Tenacity library — production-grade retries and backoff
  • Trio/AnyIO and asyncio docs — structured concurrency patterns and best practices

Keep learning by reading others’ code: Django, FastAPI, SQLAlchemy, and Pydantic all showcase thoughtful use of patterns tailored to Python.