DEV Community

Cover image for 10 Python Design Patterns to Streamline Your Python Development Projects
Sahil Khurana
Sahil Khurana

Posted on • Originally published at innostax.com

10 Python Design Patterns to Streamline Your Python Development Projects

Design patterns have a reputation for being over-applied. Someone reads the Gang of Four book, discovers Singleton and Factory, and starts reaching for them everywhere — including places where a plain function would be cleaner and easier to test.

This isn't that kind of post.

The 10 patterns below are the ones that genuinely appear in Django, Flask, FastAPI, and standard library Python. For each one: what it does, where you already encounter it, working code, and an honest take on when not to use it.


Why Patterns Still Matter in Python

Python lets you implement the same thing six different ways. That's a feature until you're on a team, then it's a coordination problem.

Patterns give common names to recurring structural decisions. "We're using Observer here" tells a developer the intent without them reading every line. Strategy and Factory make logic independently testable. Facade and Adapter let you swap underlying systems without touching calling code.

The caveat: patterns add abstraction, abstraction has a cost. Use them when they solve a real problem. A simple class is always better than a pattern that adds complexity without adding clarity.


Creational Patterns

1. Singleton

One instance, global access point. Python's logging module is a Singleton. Django's database connection manager too.

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super().__new__(cls, *args, **kwargs)
        return cls._instance

instance1 = Singleton()
instance2 = Singleton()
print(instance1 is instance2)  # True
Enter fullscreen mode Exit fullscreen mode

💡 Honest note: For most Python use cases, a module-level variable is a simpler Singleton. Use class-based Singleton when you need lazy initialization or subclassing.

Skip it when: it introduces hidden global state that makes unit tests depend on each other.


2. Factory

Object type decided at runtime. Flask's create_app() is Factory. Django's form field creation uses it too.

class Dog:
    def speak(self): return "Woof!"

class Cat:
    def speak(self): return "Meow!"

class AnimalFactory:
    @staticmethod
    def create_animal(animal_type: str):
        animals = {"dog": Dog, "cat": Cat}
        animal_class = animals.get(animal_type.lower())
        if not animal_class:
            raise ValueError(f"Unknown animal type: {animal_type}")
        return animal_class()

print(AnimalFactory.create_animal("dog").speak())  # Woof!
Enter fullscreen mode Exit fullscreen mode

Skip it when: you're only ever creating one type of object. Factory adds an abstraction layer — that cost is only worth it when the flexibility actually gets used.


3. Abstract Factory

Factory of factories. Creates families of related objects. Classic example: UI toolkits that generate platform-specific widgets.

from abc import ABC, abstractmethod

class Button(ABC):
    @abstractmethod
    def render(self): pass

class WindowsButton(Button):
    def render(self): return "Rendering Windows button"

class MacButton(Button):
    def render(self): return "Rendering Mac button"

class GUIFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button: pass

class WindowsFactory(GUIFactory):
    def create_button(self) -> Button: return WindowsButton()

class MacFactory(GUIFactory):
    def create_button(self) -> Button: return MacButton()

factory = WindowsFactory()
print(factory.create_button().render())  # Rendering Windows button
Enter fullscreen mode Exit fullscreen mode

Skip it when: there's only one product family. Abstract Factory is more complex than plain Factory — use it only when the complexity is genuinely needed.


4. Builder

Complex object construction with many optional params. SQL query builders, HTTP request builders, pytest fixtures.

class QueryBuilder:
    def __init__(self):
        self._table = ""
        self._conditions = []
        self._limit = None

    def from_table(self, table: str):
        self._table = table
        return self

    def where(self, condition: str):
        self._conditions.append(condition)
        return self

    def limit(self, count: int):
        self._limit = count
        return self

    def build(self) -> str:
        query = f"SELECT * FROM {self._table}"
        if self._conditions:
            query += " WHERE " + " AND ".join(self._conditions)
        if self._limit:
            query += f" LIMIT {self._limit}"
        return query

query = (
    QueryBuilder()
    .from_table("users")
    .where("age > 18")
    .where("active = true")
    .limit(10)
    .build()
)
# SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10
Enter fullscreen mode Exit fullscreen mode

Skip it when: object has 2-3 fields. A dataclass or regular constructor is the right call for simple objects.


5. Prototype

Clone instead of construct — useful when initialization is expensive (DB queries, API calls, heavy config).

import copy

class Config:
    def __init__(self, settings: dict):
        self.settings = settings

    def clone(self):
        return copy.deepcopy(self)

base_config = Config({"debug": False, "db_host": "localhost", "timeout": 30})
prod_config = base_config.clone()
prod_config.settings["db_host"] = "prod-db.example.com"

print(base_config.settings["db_host"])  # localhost
print(prod_config.settings["db_host"])  # prod-db.example.com
Enter fullscreen mode Exit fullscreen mode

Skip it when: object creation is cheap. copy.deepcopy() has its own cost and can behave unexpectedly with objects containing file handles or DB connections.


Structural Patterns

6. Adapter

Makes incompatible interfaces work together. Used heavily when integrating third-party libraries or wrapping legacy code.

class EuropeanSocket:
    def voltage(self): return 230
    def live(self): return "L"

class EuropeanToUSAAdapter:
    def __init__(self, socket: EuropeanSocket):
        self._socket = socket

    def voltage(self): return 120
    def neutral(self): return self._socket.live()

adapter = EuropeanToUSAAdapter(EuropeanSocket())
print(adapter.voltage())  # 120
print(adapter.neutral())  # L
Enter fullscreen mode Exit fullscreen mode

Skip it when: you have access to the original source. Adapter adds indirection — fix the interface directly when possible.


7. Decorator

The most Pythonic pattern in the list — @decorator syntax IS the Decorator pattern. @property, @login_required, @app.route — you're already using it.

import functools, time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} ran in {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}")
        return func(*args, **kwargs)
    return wrapper

@timer
@log_call
def fetch_data(user_id: int):
    time.sleep(0.1)
    return {"user_id": user_id, "name": "Alice"}

fetch_data(42)
# Calling fetch_data with args=(42,)
# fetch_data ran in 0.1003s
Enter fullscreen mode Exit fullscreen mode

⚠️ More than 3-4 decorators on one function is a red flag. Execution order gets confusing and debugging gets painful fast.


8. Facade

Simple interface over a complex subsystem. Django's ORM is a Facade over raw SQL. requests is a Facade over urllib.

class CPU:
    def freeze(self): print("CPU: Freeze")
    def jump(self, address): print(f"CPU: Jump to {address}")
    def execute(self): print("CPU: Execute")

class Memory:
    def load(self, address, data): print(f"Memory: Load {data} at {address}")

class HardDrive:
    def read(self, sector, size): return f"Data from sector {sector}"

class ComputerFacade:
    def __init__(self):
        self.cpu = CPU()
        self.memory = Memory()
        self.hard_drive = HardDrive()

    def start(self):
        self.cpu.freeze()
        data = self.hard_drive.read(sector=0, size=1024)
        self.memory.load(address=0, data=data)
        self.cpu.jump(address=0)
        self.cpu.execute()

ComputerFacade().start()
Enter fullscreen mode Exit fullscreen mode

Skip it when: there's no real complexity to hide. A Facade that just proxies to a single class is noise — and aggressive Facades get bypassed by developers who need the underlying behavior, which is worse than not having them.


Behavioral Patterns

9. Strategy

Swappable algorithms at runtime. Payment methods, sorting implementations, auth backends in Django.

from abc import ABC, abstractmethod

class SortStrategy(ABC):
    @abstractmethod
    def sort(self, data: list) -> list: pass

class BubbleSort(SortStrategy):
    def sort(self, data: list) -> list:
        data = data.copy()
        n = len(data)
        for i in range(n):
            for j in range(0, n - i - 1):
                if data[j] > data[j + 1]:
                    data[j], data[j + 1] = data[j + 1], data[j]
        return data

class PythonBuiltinSort(SortStrategy):
    def sort(self, data: list) -> list: return sorted(data)

class Sorter:
    def __init__(self, strategy: SortStrategy):
        self._strategy = strategy

    def set_strategy(self, strategy: SortStrategy):
        self._strategy = strategy

    def sort(self, data: list) -> list:
        return self._strategy.sort(data)

data = [5, 2, 8, 1, 9]
sorter = Sorter(PythonBuiltinSort())
print(sorter.sort(data))   # [1, 2, 5, 8, 9]
sorter.set_strategy(BubbleSort())
print(sorter.sort(data))   # [1, 2, 5, 8, 9]
Enter fullscreen mode Exit fullscreen mode

Skip it when: there's only one algorithm that's never going to change. Strategy for a single fixed implementation is indirection with no payoff.


10. Observer

One-to-many notification. Django signals (post_save, pre_delete), event-driven UIs, real-time notification systems.

from abc import ABC, abstractmethod

class Observer(ABC):
    @abstractmethod
    def update(self, event: str, data: dict): pass

class Subject:
    def __init__(self):
        self._observers: list[Observer] = []

    def attach(self, observer: Observer): self._observers.append(observer)
    def detach(self, observer: Observer): self._observers.remove(observer)

    def notify(self, event: str, data: dict):
        for observer in self._observers:
            observer.update(event, data)

class EmailNotifier(Observer):
    def update(self, event: str, data: dict):
        print(f"Email sent for event '{event}': {data}")

class LogNotifier(Observer):
    def update(self, event: str, data: dict):
        print(f"Log recorded for event '{event}': {data}")

order_system = Subject()
order_system.attach(EmailNotifier())
order_system.attach(LogNotifier())
order_system.notify("order_placed", {"order_id": 101, "amount": 250.00})
# Email sent for event 'order_placed': {'order_id': 101, 'amount': 250.0}
# Log recorded for event 'order_placed': {'order_id': 101, 'amount': 250.0}
Enter fullscreen mode Exit fullscreen mode

Skip it when: the notification chain becomes so tangled that you can't trace which observer triggered which side effect. Too many observers on a single subject makes debugging disproportionately hard.


Quick Reference

Pattern Category Python native equivalent Use when
Singleton Creational logging, module-level vars Shared resource, single access point
Factory Creational Functions returning instances Object type at runtime
Abstract Factory Creational ABC + concrete factories Multiple related object families
Builder Creational Chained calls, dataclass Complex multi-param construction
Prototype Creational copy.deepcopy() Expensive object creation
Adapter Structural Wrapper classes Incompatible interface integration
Decorator Structural @decorator syntax Runtime behavior extension
Facade Structural Wrapper modules Simplify complex subsystem access
Strategy Behavioral Callable injection Swappable algorithms at runtime
Observer Behavioral Django signals, asyncio Event-driven state notification

Building Python systems that need to stay maintainable as they scale? At Innostax, our Python engineers work across Django, FastAPI, and ML pipelines — and know when a pattern earns its complexity cost. innostax.com/contact

Originally published on the Innostax Engineering Blog.


Which pattern do you see most over-applied in Python codebases you've worked in? Singleton? Observer? Something else entirely? Drop it below. 👇

#python #programming #beginners #webdev

Top comments (0)