mechai · arch-crew← Blog

Reference · Python · Object & code design

Python design patterns

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.

Creational · structural · behavioral · Python idioms · application & concurrency

Patterns are answers to recurring problems — not goals

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

How objects get made

1.1Factory Method

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.

exporter(fmt)the factory Exporter · protocol PngExporter JpegExporter WebpExporter
Factory Method — one call decides the concrete type; callers depend only on the protocol.
# 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}")
Use whenThe concrete type depends on input or config and you want one place that decides.
PythonicA function or a dict-of-constructors; skip the Creator/Product class hierarchy.

1.2Builder

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

Query() · builder.select(*cols).where(cond).build()each step returns self SELECT … WHERE …finished query build()
Builder — chain named steps; the product falls out at the end.
# 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()
Use whenMany optional parts or staged, validated construction make a constructor unreadable.
PythonicPrefer a @dataclass with defaults or classmethod constructors; build only when steps are genuinely sequential.

Used occasionally

Abstract Factory

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()

Singleton

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

Part IIStructural

How objects are composed

2.1Adapter

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.

Clientcalls charge() StripeGatewayadapter stripe libcreate_charge()
Adapter — the client sees your interface; the adapter speaks the library’s.
# 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)
Use whenA foreign or legacy interface must satisfy the one your code calls.
PythonicA thin wrapper class or function; duck typing means it implements only the methods actually used.

2.2Decorator

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.

@timed · wrapperruns before / after the inner call crunch()original function call
Decorator — the wrapper adds behavior around an untouched inner function.
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))
Use whenAdd cross-cutting behavior (logging, caching, retry, auth) without touching the wrapped code.
PythonicBuilt into the language: @decorator + functools.wraps; __getattr__ forwarding for object wrappers.

2.3Facade

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.

caller convert()facade Loader Normalizer Encoder
Facade — one call hides the order and glue of the parts behind it.
# one friendly function over a messy subsystem
def convert(path):
    raw = Loader(path).read()
    clean = Normalizer().run(raw)
    return Encoder(format="mp4").write(clean)
Use whenCallers shouldn’t need to know a subsystem’s many parts or their order.
PythonicFrequently just a module exposing a few top-level functions.

2.4Composite

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.

Folder · total() File Folder · total() File File
Composite — a container answers total() by recursing; a leaf just returns its own.
# 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
Use whenData is a part-whole tree and clients should treat a leaf and a group the same.
PythonicA shared protocol/ABC with a recursive method — and duck typing means even that base class is optional.

2.5Proxy

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.

client LazyImageproxy · lazy / guard Imagereal subject on demand
Proxy — same interface as the real subject, with access control bolted on.
# 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()
Use whenDefer creation, cache, guard, or front a remote object behind the same interface.
Pythonic__getattr__ forwarding for transparent proxies; functools.cached_property for laziness.

Used occasionally

Bridge

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

Part IIIBehavioral

How objects collaborate

3.1Strategy

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.

sort_products()context · key= by_price by_rating by_name
Strategy — the context holds a callable; swap the callable, swap the algorithm.
# 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])
Use whenSeveral interchangeable algorithms are chosen at runtime.
PythonicPass a function; a dict of callables replaces the whole Strategy class hierarchy.

3.2Observer

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.

Ordersubject send_receipt update_stock audit_log
Observer — the subject broadcasts; subscribers come and go without it knowing.
# 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()
Use whenOne change must fan out to many reactions the subject shouldn’t know about.
PythonicA list of callbacks; or libraries like blinker, or asyncio events for async fan-out.

3.3State

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.

draft published archived publish archive
State — events drive transitions; behavior follows the current 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]
Use whenAn object’s allowed actions depend on a mode that changes over time.
PythonicA dict transition table, or the enum / transitions library; a class-per-state is rarely worth it.

3.4Command

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.

Invoker AddTextexecute() · undo() doc · receiver history · undo stack
Command — the action becomes an object, so it can be recorded and reversed.
# 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
Use whenYou must queue, log, schedule, or undo actions, or decouple invoker from receiver.
PythonicA plain callable or functools.partial when you don’t need undo or metadata.

Used occasionally

Template Method

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

Chain of Responsibility

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

Mediator

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)

Iterator

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)

Visitor

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

Part IVPython-specific idioms

Patterns the language gives you outright

4.1Context Manager

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.

__enter__acquire with-blockyour code __exit__release · always __exit__ runs even if the block raises
Context Manager — cleanup is bound to the block, not to your discipline.
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)
Use whenA resource must be released regardless of success or failure.
PythonicThe with statement: __enter__/__exit__, or a generator + @contextmanager.

4.2Duck Typing & Protocols

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.

Reader · protocolhas .read() FileReader S3Reader StringIO
Duck typing — unrelated classes are interchangeable if they share the method.
from typing import Protocol

class Reader(Protocol):
    def read(self) -> str: ...

def ingest(src: Reader):       # any object with .read() fits
    return src.read()
Use whenYou want polymorphism without a shared base class — which in Python is most of the time.
PythonicThis is Python: typing.Protocol adds static checking; ABCs only when you need runtime enforcement.

4.3Registry

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.

@handler("csv") @handler("json") @handler("yaml") HANDLERS = {}name → function
Registry — each handler registers itself; the central list never needs editing.
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): ...
Use whenPlugins or handlers must be discoverable by key without a central list to maintain.
PythonicA decorator filling a dict, or __init_subclass__ to auto-register subclasses.

Used occasionally

Properties

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

Mixin

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

Part VApplication & concurrency

Patterns a level up from single objects

5.1Repository

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

servicedomain logic UserRepositoryadd · get · list SQLAlchemy fake dict · tests
Repository — one interface, swappable backings; the domain depends on neither.
# 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
Use whenDomain or service code shouldn’t depend on the ORM, and you want to fake persistence in tests.
PythonicA class wrapping the SQLAlchemy session; a dict-backed fake implements the same methods (duck typing).

Used occasionally

Unit of Work

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()

Dependency Injection

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

Service Layer

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())

Producer–Consumer

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()

Thread / Process Pool

Run many tasks on a fixed, reused set of workers.

Pythonicconcurrent.futuresThreadPoolExecutor 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))

Event Loop

Multiplex thousands of I/O-bound tasks on a single thread.

Pythonicasyncioasync 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

Part VIDecide

Which pattern?

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

How to choose

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.