Reference · Python · Object & code design
A working catalog of the design patterns Python engineers actually reach for — what each one solves, the shape it takes, the idiomatic Python form, and when the language has already solved it for you.
A design pattern is a named, reusable solution to a problem that keeps coming back. The Gang of Four cataloged twenty-three of them for statically-typed, class-heavy languages. Python is neither, which changes the game: roughly half of GoF dissolves into a language feature.
First-class functions make Strategy and Command a one-liner. Generators are the Iterator. with is a built-in Context Manager. @property, @decorator, and functools.singledispatch ship the Properties, Decorator, and Visitor patterns in the standard library. So this catalog does two things at once: it explains the classic pattern, and it shows the Pythonic form — which is often "you don't need the pattern, you need three lines."
Read this first — reach for the language before the pattern
The patterns here are grouped the classic way — Creational (how objects are made), Structural (how they're composed), Behavioral (how they collaborate) — plus a section of genuinely Python-specific idioms and one of application & concurrency patterns that sit a level up.
The recurring lesson: in Python, a function, a dict, a decorator, or a module usually beats a class hierarchy. A pattern earns its keep when it removes real duplication or names a real seam — not because a textbook listed it. When the language already gives you the pattern, that's noted in the Pythonic row.
Part ICreational
Put the “which concrete class do I instantiate?” decision in one place.
When the type to create depends on input, config, or context, scattering if/elif constructors everywhere couples callers to concrete classes. A factory centralises that choice behind a single call, so callers ask for what they want, not how it’s built. In a static language this is a Creator/Product class hierarchy; in Python it collapses to a function — usually a dict mapping keys to constructors.
# a dict of constructors is the Pythonic factory
EXPORTERS = {
"png": PngExporter,
"jpeg": JpegExporter,
"webp": WebpExporter,
}
def exporter(fmt):
try:
return EXPORTERS[fmt]()
except KeyError:
raise ValueError(f"unknown format: {fmt}")
Construct a complex object step by step instead of through one unreadable constructor.
When an object has many optional parts, or its construction has order and validation, a ten-argument constructor becomes a trap. A builder moves that assembly into incremental, named steps. In Python the pressure is lower — keyword arguments and @dataclass defaults absorb most of the need — so reserve a true builder for genuinely staged construction (a query, a request, a document).
# a fluent builder; in Python a @dataclass often replaces it
class Query:
def __init__(self):
self._parts = []
def select(self, *cols):
self._parts.append("SELECT " + ", ".join(cols))
return self
def where(self, cond):
self._parts.append("WHERE " + cond)
return self
def build(self):
return " ".join(self._parts)
sql = Query().select("id", "name").where("age > 18").build()
Used occasionally
Produce whole families of related objects without naming their concrete classes.
PythonicAn object or module that exposes a matched set of factory callables; the full GoF hierarchy rarely pays.
# a "family" is just an object holding matching builders
class DarkTheme:
def button(self): return DarkButton()
def menu(self): return DarkMenu()
def make_ui(theme):
return theme.button(), theme.menu()
Guarantee one instance with a single global access point.
PythonicDon’t — a module is already a singleton. Put the state at module level; if you truly must, override __new__ or wrap a factory in @lru_cache. Global mutable state is the real cost.
# config.py — the module IS the singleton _settings = {"debug": False} def get(key): return _settings[key] # elsewhere: from config import get
Also
clone() method — reach straight for copy.copy / copy.deepcopy.Part IIStructural
Make an incompatible interface look like the one your code already expects.
Two pieces need to collaborate, but their interfaces don’t line up — a third-party SDK, a legacy class, a different naming convention. An adapter wraps the foreign object and exposes the interface your code calls, translating between them. Because Python uses duck typing, the adapter only has to implement the methods actually used, so it stays thin.
# adapt a third-party API to the interface your code expects class StripeGateway: # your interface: charge(amount) def __init__(self, stripe): self._stripe = stripe def charge(self, amount): self._stripe.create_charge(cents=amount * 100)
Wrap an object or function to add behavior without changing it.
You want to add logging, caching, timing, retry, or access checks around existing behavior — without editing it or subclassing. A decorator wraps the original and adds behavior before/after delegating to it. This is so central to Python that the language ships syntax for it: @decorator for functions, plus functools.wraps to preserve identity. For objects, __getattr__ forwarding gives the same effect.
import functools, time
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
t = time.perf_counter()
result = fn(*args, **kwargs)
print(f"{fn.__name__}: {time.perf_counter() - t:.3f}s")
return result
return wrapper
@timed
def crunch(n):
return sum(range(n))
Put one simple interface in front of a complex subsystem.
A subsystem has many parts that must be called in the right order with the right glue. A facade gives callers a single, friendly entry point and hides the choreography. It doesn’t add power — it removes the need to know the internals. In Python this is very often just a module that exposes a few top-level functions over a pile of internal classes.
# one friendly function over a messy subsystem
def convert(path):
raw = Loader(path).read()
clean = Normalizer().run(raw)
return Encoder(format="mp4").write(clean)
Treat a single object and a tree of objects through the same interface.
When data forms a part-whole hierarchy — files and folders, an AST, a UI tree — you want client code to treat a leaf and a container identically. Composite gives both the same interface; containers implement it by recursing into their children. Python needs no shared base class for this: as long as both expose the method, duck typing makes them interchangeable.
# leaves and containers share one interface class File: def __init__(self, size): self.size = size def total(self): return self.size class Folder: def __init__(self, *children): self.children = children def total(self): return sum(c.total() for c in self.children) root = Folder(File(10), Folder(File(3), File(7))) root.total() # 20
A stand-in with the same interface that controls access to the real object.
Sometimes you want the same interface as an object, but with something extra in front: lazy creation, caching, access control, or a local handle to a remote thing. A proxy implements the subject’s interface and forwards to it, adding that control. Python’s __getattr__ makes transparent forwarding trivial, and functools.cached_property covers the most common case — laziness.
# a lazy proxy: build the real object only on first use class LazyImage: def __init__(self, path): self._path = path self._img = None def render(self): if self._img is None: self._img = load(self._path) # expensive return self._img.render()
Used occasionally
Let an abstraction and its implementation vary independently, instead of multiplying subclasses.
PythonicInject the implementation object — plain composition. Python’s duck typing makes the formal “two hierarchies” mostly dissolve.
# the shape holds a renderer; the two vary independently
class Circle:
def __init__(self, renderer): self.r = renderer
def draw(self): self.r.line("circle")
Circle(SvgRenderer()).draw()
Circle(CanvasRenderer()).draw()
Also
__slots__, or a shared cache via functools.lru_cache — you rarely build an explicit flyweight factory.Part IIIBehavioral
Make a family of interchangeable algorithms swappable behind one call.
When the same operation has several interchangeable implementations — sort orders, pricing rules, shipping calculators — branching with if/elif spreads the choice everywhere. Strategy puts each algorithm behind a common interface and lets you pick one at runtime. In Python the interface is a function: pass a callable, or look one up in a dict. This is the pattern that most thoroughly dissolves into the language.
# strategies are just callables in Python
def by_price(p): return p.price
def by_rating(p): return -p.rating
SORTS = {"price": by_price, "rating": by_rating}
def sort_products(products, how):
return sorted(products, key=SORTS[how])
Notify many subscribers when a subject changes — without coupling it to them.
One object changes and several others must react — send a receipt, update stock, write an audit row — but the subject shouldn’t know who’s listening. Observer lets interested parties subscribe; the subject just broadcasts. Add or remove a reaction without touching the subject. (This is the in-process sibling of the event-driven architecture.) In Python a list of callbacks covers most cases.
# a subject keeps a list of callbacks
class Order:
def __init__(self):
self._observers = []
def subscribe(self, fn):
self._observers.append(fn)
def pay(self):
for notify in self._observers:
notify(self)
order = Order()
order.subscribe(send_receipt)
order.subscribe(update_stock)
order.pay()
Let an object change its behavior when its internal state changes.
An object’s allowed actions depend on a mode that changes over its lifetime — a document that is draft, then published, then archived; a connection that is open or closed. Rather than scattering if self.status == … through every method, State makes the current mode drive behavior. A clean Pythonic form is a transition table: a dict from (state, event) to the next state.
# behavior depends on state; a transition table
TRANSITIONS = {
"draft": {"publish": "published"},
"published": {"archive": "archived"},
"archived": {},
}
class Post:
def __init__(self): self.state = "draft"
def fire(self, event):
self.state = TRANSITIONS[self.state][event]
Turn a request into an object you can store, queue, log, or undo.
Sometimes an action needs to be more than a call — it must be queued, scheduled, logged, retried, or undone. Command reifies “do this” into an object carrying everything needed to run (and reverse) it, decoupling the thing that triggers the action from the thing that performs it. When you don’t need undo or metadata, a plain callable already is a command; the object form earns its keep once you need history.
# a command bundles an action and its undo from dataclasses import dataclass @dataclass class AddText: doc: list text: str def execute(self): self.doc.append(self.text) def undo(self): self.doc.pop() history = [] cmd = AddText(doc, "hello"); cmd.execute(); history.append(cmd) history.pop().undo() # undo the last action
Used occasionally
Define an algorithm’s skeleton once; let subclasses override specific steps.
PythonicAn ABC with abstract steps — or, more often, just pass the varying step in as a callable (Strategy wearing a hat).
class Report:
def render(self): # the fixed skeleton
return self.header() + self.body() + self.footer()
def header(self): return "== report ==\n"
def footer(self): return "\n== end =="
def body(self): raise NotImplementedError
Pass a request along a chain of handlers; each handles it or forwards it.
PythonicA list of callables, or nested middleware closures — exactly how WSGI/ASGI and web-framework middleware work.
# each handler returns a result or passes None onward
def handle(request, handlers):
for h in handlers:
result = h(request)
if result is not None:
return result
return None
Make objects communicate only through a central mediator, cutting many-to-many wiring.
PythonicA small hub object or an event bus that owns the interaction rules; the parts stay ignorant of each other.
# widgets talk to the dialog, not to each other
class Dialog:
def changed(self, widget):
if widget is self.country:
self.city.reload(widget.value)
Traverse a collection without exposing its underlying representation.
PythonicNative to the language — implement __iter__/__next__, or just write a generator with yield.
# a generator IS an iterator def countdown(n): while n > 0: yield n n -= 1 for x in countdown(3): # 3, 2, 1 print(x)
Add new operations to a class hierarchy without modifying its classes.
Pythonicfunctools.singledispatch is the Visitor — register a function per type, with no accept()/visit() boilerplate.
from functools import singledispatch @singledispatch def area(shape): raise NotImplementedError @area.register def _(s: Circle): return 3.14 * s.r ** 2 @area.register def _(s: Square): return s.side ** 2
Also
lark, pyparsing) or the ast module; hand-rolled interpreters are rare.copy.deepcopy or a snapshot of __dict__ / a dataclass; the formal Originator/Caretaker roles are seldom needed.Part IVPython-specific idioms
Guarantee setup and teardown around a block — even when it raises.
Any resource that must be released — a file, a lock, a database transaction, a timer — is a leak waiting to happen if cleanup is manual. The context manager binds acquisition and release to a with block, so teardown runs no matter how the block exits. Implement __enter__/__exit__, or — far more often — write a generator and decorate it with @contextmanager.
from contextlib import contextmanager
@contextmanager
def opened(path):
f = open(path)
try:
yield f
finally:
f.close()
with opened("data.txt") as f:
process(f)
Suitability is decided by what an object can do, not by what it inherits.
“If it walks like a duck and quacks like a duck.” Python doesn’t ask whether an object is a Reader — only whether it has read(). This structural typing replaces whole families of GoF interface gymnastics: any object with the right methods fits. typing.Protocol makes that contract explicit and statically checkable without forcing inheritance; reach for an ABC only when you want runtime enforcement.
from typing import Protocol
class Reader(Protocol):
def read(self) -> str: ...
def ingest(src: Reader): # any object with .read() fits
return src.read()
Auto-collect implementations in a central place as they are defined.
When you have a growing set of handlers, plugins, or serializers that must be discoverable by key, maintaining a central list by hand rots fast. A registry lets each implementation announce itself — via a decorator or __init_subclass__ — so adding one is a local change. It’s the in-language version of the microkernel’s plugin contract.
HANDLERS = {}
def handler(name):
def register(fn):
HANDLERS[name] = fn
return fn
return register
@handler("csv")
def load_csv(path): ...
@handler("json")
def load_json(path): ...
Used occasionally
Expose computed or validated logic as plain attribute access.
PythonicThe @property decorator — start with a plain attribute and add a getter/setter later without breaking any caller.
class Account:
def __init__(self, cents): self._cents = cents
@property
def dollars(self):
return self._cents / 100
Add a slice of reusable behavior to many classes via multiple inheritance.
PythonicA small class with methods but no state, mixed in; Python’s MRO makes this idiomatic (Django’s class-based views lean on it heavily).
class AsDictMixin:
def as_dict(self):
return vars(self)
class User(AsDictMixin):
def __init__(self, name): self.name = name
Also
__dict__; a softer Singleton. Pythonic verdict: a module-level object is almost always simpler and clearer.unittest.mock.patch) and emergency hotfixes; corrosive as architecture — use sparingly and locally.__get__/__set__ that customises attribute access; the machinery behind @property, methods, and ORM fields. Pythonic verdict: you rarely hand-write one, but it explains how Python attributes actually work.None checks. Pythonic verdict: a tiny class of no-op methods, or a module-level sentinel; clean when None-handling is noisy.Part VApplication & concurrency
Hide data access behind a collection-like interface, so the domain never sees the ORM.
Scatter ORM queries through your business logic and the two fuse: you can’t change the database, and you can’t test without one. A repository presents persistence as a simple collection — add, get, list — and keeps the SQL behind that boundary. The payoff is testability: a dict-backed fake with the same methods stands in for the real thing. It pairs naturally with Unit of Work and a Service Layer (the “Cosmic Python” stack).
# a collection-like API over the database class UserRepository: def __init__(self, session): self._session = session def add(self, user): self._session.add(user) def get(self, user_id): return self._session.get(User, user_id) # in tests, a fake with the same methods, backed by a dict
Used occasionally
Track changes across repositories and commit them as one atomic transaction.
PythonicA context manager around the session: commit on success, roll back on exception.
class UnitOfWork:
def __enter__(self):
self.session = Session()
return self
def __exit__(self, exc_type, *_):
if exc_type: self.session.rollback()
else: self.session.commit()
Pass an object’s collaborators in, instead of letting it construct them.
PythonicPlain function or constructor arguments — Python rarely needs a DI framework; defaults keep call sites tidy.
# pass the gateway in; don't construct it inside def checkout(cart, gateway): gateway.charge(cart.total()) checkout(cart, StripeGateway(stripe)) # real checkout(cart, FakeGateway()) # test
A thin layer of use-case functions orchestrating domain objects and repositories.
PythonicPlain functions taking a Unit of Work and primitives; keeps web handlers thin and the domain pure.
# one function per use case
def place_order(cart_id, uow):
with uow:
cart = uow.carts.get(cart_id)
uow.orders.add(cart.to_order())
Decouple work generation from work processing through a shared queue.
Pythonicqueue.Queue for threads, asyncio.Queue for coroutines; the queue absorbs rate differences.
import queue, threading
q = queue.Queue()
def worker():
while (item := q.get()) is not None:
handle(item)
q.task_done()
threading.Thread(target=worker, daemon=True).start()
Run many tasks on a fixed, reused set of workers.
Pythonicconcurrent.futures — ThreadPoolExecutor for I/O-bound work, ProcessPoolExecutor for CPU-bound (it sidesteps the GIL).
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(fetch, urls))
Multiplex thousands of I/O-bound tasks on a single thread.
Pythonicasyncio — async def / await; the loop runs ready coroutines while the rest wait on I/O.
import asyncio
async def main():
results = await asyncio.gather(*(fetch(u) for u in urls))
asyncio.run(main())
Also
{message_type: [handlers]} — it’s Observer + Command at application scale (see “Cosmic Python”).Part VIDecide
Unlike architectures, design patterns don't compose into a stack — you reach for one to solve a specific problem. So this asks what you're trying to do and points you at the pattern that fits, with its Pythonic caveat. Treat it as a nudge, not a mandate — and remember the most common right answer is "a plain function."
The honest takeaway: most code needs far fewer patterns than a catalog implies. Patterns are a vocabulary for seams that already exist, not a shopping list to apply.
Start with the simplest thing — a function, a dict, a dataclass, a module. Introduce a pattern only when duplication, a volatile boundary, or a genuine variation point demands it. Prefer the Pythonic form: a callable over a Strategy class, singledispatch over a Visitor, a module over a Singleton, copy over a Prototype. Keep the GoF name as shared language for your team — "this is a Registry," "that's an Adapter" — because naming a structure is most of the value. Then resist adding any pattern that isn't paying for itself.