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
- Pythonic Building Blocks You’ll Use in Patterns
- Creational Patterns
- Structural Patterns
- Behavioral Patterns
- Concurrency and Async Patterns
- Architectural Patterns
- Metaprogramming Patterns
- Patterns for Testing and Maintainability
- Anti-Patterns and Code Smells
- Performance-Conscious Patterns
- Mini Case Study: A Pluggable Async ETL Pipeline
- Pattern Selection Cheat Sheet
- Conclusion
- Best Resources
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.