Python Design Patterns That Still Matter in 2026

Most of the famous 23 patterns from the Gang of Four book were workarounds for stiffer languages. When functions are objects and a module is already a shared instance, half of them collapse into one line. So this post keeps only the six Python design patterns that still earn their keep, each one run on Python 3.14.6, with a plain answer to when you should skip it.

“Special cases aren’t special enough to break the rules.”

Tim Peters, The Zen of Python (PEP 20)

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 19 minutes

Here is the trap juniors fall into. They read the patterns book, get excited, and start wrapping everything in factories and abstract base classes to look senior. The result is code three times longer than it needs to be, where a plain function would have done the job. A pattern is a tool for a specific pain, not a badge. The skill is not knowing all 23 patterns, it is knowing which two or three your problem actually needs, and having the taste to skip the rest.

Think of it like a kitchen. A good cook does not use every gadget in the drawer for every dish. Boiling rice needs a pot, not a food processor. Patterns are the same: each one solves a specific problem, and using the fancy one when the simple one fits just makes a mess to clean up later.

Why Most Python Design Patterns Dissolve

The Gang of Four patterns were written in 1994 for C++ and Smalltalk, languages where a function cannot exist on its own and every behavior must be wrapped in a class. Python does not share that constraint. The Command pattern is usually just a function you store in a variable. The Iterator pattern is built into every for loop. A Singleton is what a module already is. So before you copy a pattern from a tutorial, ask the honest question in the flowchart below: does the code actually demand it, or are you adding ceremony?

NoYesYesYesNoNoYesNoUrge to add adesign patternSame logic in3+ places?Stop. A plainfunction or moduleis enoughBehavior mustswap at runtime?One method,no state?Pass a function(Strategy)Small class:Strategy orAdapterMany typesshare onemethod shape?Define atyping.ProtocolDo You Actually Need a Design Pattern Here?

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The six Python design patterns that survive, Strategy, Factory, Observer, Adapter, Repository, and a careful look at Singleton, all earn their place because they solve a problem Python does not solve for you: they give a name to a shape that keeps changing code decoupled from stable code. That is the real value of a pattern, a shared vocabulary for a design decision, not the class boilerplate around it.

Strategy: Swap Behavior Without a Wall of if-else

Strategy solves one problem: you have several ways to do the same job and you want to pick one at runtime without a growing if/elif tree. Picture a checkout counter. The billing logic is fixed, but the discount rule changes with the season: no discount today, a festival discount next week, a loyalty discount for regulars. In a Java tutorial each rule would be its own class implementing a DiscountStrategy interface. In Python a rule is just a function, and a function is already a first-class object you can pass around.

📄 strategy.py: each discount rule is a plain function

# Strategy as plain functions: swap the pricing rule, not the checkout code.
def no_discount(total):
    return total

def festival_discount(total):
    return total * 0.90 if total > 1000 else total

def loyalty_discount(total):
    return total - 50 if total > 500 else total

def checkout(cart_total, discount):
    return round(discount(cart_total), 2)

cart = 1200.0
for rule in (no_discount, festival_discount, loyalty_discount):
    print(f"{rule.__name__:18} -> {checkout(cart, rule)}")

▶ Output

no_discount        -> 1200.0
festival_discount  -> 1080.0
loyalty_discount   -> 1150.0

What happened here: checkout never knows or cares which discount it got. It just calls whatever function you handed it. Adding a new rule next month means writing one more function and passing it in, with zero edits to checkout. That is the whole Strategy pattern, and in Python it needs no interface, no base class, and no factory. The functions are the strategies. When a strategy needs to remember state between calls (say a discount that tracks how many times it fired), promote it to a small class with a __call__ method, but reach for that only when a plain function genuinely cannot hold the data.

When not to use it: if there is only ever one way to do the job, or the choice is fixed at import time and never changes, skip it. A single function with a normal if is clearer than a strategy nobody swaps.

Factory: One Place That Decides What to Build

A factory answers the question “given some input, which object or function do I need?” and keeps that decision in exactly one place. Without it, the same if filename ends with .csv block gets copied into five files, and the day you add a new format you have to hunt down every copy. A factory centralizes the choice so the rest of your code just asks for the right tool by name. Say a user named Anvi uploads either a CSV or a JSON export, and your code must parse whichever arrived.

📄 factory.py: one function picks the right parser

# Factory: one place decides which parser to build from the file name.
import json

def parse_csv(text):
    header, *body = [line.split(",") for line in text.strip().splitlines()]
    return [dict(zip(header, row)) for row in body]

def parse_json(text):
    return json.loads(text)

def get_parser(filename):
    if filename.endswith(".csv"):
        return parse_csv
    if filename.endswith(".json"):
        return parse_json
    raise ValueError(f"no parser for {filename}")

csv_data = "name,qty\nrice,3\nflour,1"
print(get_parser("stock.csv")(csv_data))
print(get_parser("stock.json")('{"rice": 3}'))

▶ Output

[{'name': 'rice', 'qty': '3'}, {'name': 'flour', 'qty': '1'}]
{'rice': 3}

What happened here: get_parser is the single spot that knows how to map a file name to a parser. Everywhere else in the app you write get_parser(name)(text) and stay blissfully unaware of the rules. When a new format shows up, you edit this one function. Notice again there is no ParserFactory class: a Python factory is very often just a function that returns another function or a class. The decision lives in one place, which was the entire point.

When not to use it: if you only ever build one type, a factory is pure overhead. And if your factory grows a giant if/elif chain, replace the chain with a plain dictionary that maps a key to the builder, which is faster to read and to extend.

Observer: Announce That Something Happened

Observer lets one part of your code shout “this just happened” and any number of other parts react, without the shouter knowing who is listening. Think of a shop that places an order: the moment it is confirmed, an email should go out and the stock count should drop. The order code should not have to import the email module and the inventory module and call them by hand. Instead it publishes an event, and interested parties subscribe.

📄 observer.py: publish an event, let subscribers react

# Observer: a publisher keeps a list of subscribers and calls them on change.
class OrderEvents:
    def __init__(self):
        self._subscribers = []
    def subscribe(self, fn):
        self._subscribers.append(fn)
    def publish(self, order):
        for fn in self._subscribers:
            fn(order)

def send_email(order):
    print(f"email:     order {order['id']} confirmed")

def update_stock(order):
    print(f"inventory: reserved one {order['item']}")

events = OrderEvents()
events.subscribe(send_email)
events.subscribe(update_stock)
events.publish({"id": 7, "item": "paneer"})

▶ Output

email:     order 7 confirmed
inventory: reserved one paneer

What happened here: OrderEvents holds a list of subscriber functions and calls each one when publish fires. The order code never mentions email or inventory by name, so tomorrow you can add an SMS alert by writing one function and subscribing it, touching nothing that already works. This decoupling is exactly how UI frameworks handle button clicks and how message queues fan work out to many workers.

When not to use it: if only one thing ever reacts, just call it directly. Observer trades a little indirection for flexibility, and when there is nothing to flex, the indirection only makes the flow harder to trace. Overused, it turns a program into a game of “who is listening?” that is miserable to debug.

Adapter: Make a Square Peg Fit a Round Hole

Adapter wraps a class you cannot change so it fits the interface your code expects. This one earns its keep constantly, because you rarely control third-party libraries. Say your whole app calls channel.send(to, message) on every notification channel, but the SMS library you just installed insists on dispatch(number, body). Rewriting your app to match the library is backwards. Instead you write a thin adapter that speaks your language on the outside and theirs on the inside.

📄 adapter.py: a thin wrapper translates the interface

# Adapter: wrap a class you cannot change so it fits the interface you expect.
class LegacySms:
    # a third-party library you do not control
    def dispatch(self, number, body):
        return f"SMS to {number}: {body}"

class Notifier:
    """Our app expects every channel to expose send(to, message)."""
    def __init__(self, backend):
        self._backend = backend
    def send(self, to, message):
        return self._backend.dispatch(to, message)

notifier = Notifier(LegacySms())
print(notifier.send("+91-99999-00000", "Your order shipped"))

▶ Output

SMS to +91-99999-00000: Your order shipped

What happened here: Notifier exposes the send that the rest of the app already uses and quietly forwards to the library’s dispatch underneath. The day you swap the SMS vendor for one with yet another method name, you edit only the adapter. Your app code never learns that the library changed. This is the pattern that keeps a messy outside world from leaking its odd shapes into your clean core.

When not to use it: if you own both sides of the code, do not build an adapter, just fix the method name at the source. Adapters exist for boundaries you cannot move, like a vendor SDK or a legacy system, not as an excuse to leave two inconsistent interfaces you could unify.

Repository: Hide Where the Data Lives

Repository puts a simple object between your business logic and wherever data is stored, so the rest of the code says repo.get(id) and never learns whether that hits a dictionary, a Postgres table, or a REST Application Programming Interface (API). The payoff is huge for testing: you develop against a fast in-memory version and swap in the real database later, with no change to the code that uses it. This is also the perfect place to show the modern Pythonic replacement for interface hierarchies, typing.Protocol.

In older Python you would force every repository to inherit from an abstract base class. At the time of writing, the cleaner idiom is a Protocol: it describes the shape a repository must have (structural typing), and any class with matching methods satisfies it automatically, with no inheritance and no import coupling. Type checkers like mypy verify the fit, and if you mark it runtime_checkable you can even isinstance it.

📄 repository.py: a Protocol describes the shape, no base class

# Repository: hide WHERE data lives behind a small, swappable interface.
from typing import Protocol, runtime_checkable

@runtime_checkable
class UserRepository(Protocol):
    def get(self, user_id: int) -> dict | None: ...
    def add(self, user: dict) -> dict: ...

class InMemoryUserRepo:                 # never says "implements UserRepository"
    def __init__(self):
        self._users: dict[int, dict] = {}
    def get(self, user_id):
        return self._users.get(user_id)
    def add(self, user):
        stored = {"id": len(self._users) + 1, **user}  # the repo owns id assignment
        self._users[stored["id"]] = stored
        return stored

def register(repo: UserRepository, name: str) -> dict:
    return repo.add({"name": name})     # only Protocol methods, no storage peeking

repo = InMemoryUserRepo()
register(repo, "Aditi")
register(repo, "Anvay")
print(repo.get(1))
print(repo.get(99))
print("fits the Protocol:", isinstance(repo, UserRepository))

▶ Output

{'id': 1, 'name': 'Aditi'}
None
fits the Protocol: True

What happened here: register is typed to accept any UserRepository, yet InMemoryUserRepo never inherits from it or even imports it. It fits purely because it has a matching get and add, which is what structural typing means, and the final line proves it at runtime with isinstance. Notice that register touches only the two Protocol methods and lets the repo assign the id, so it truly knows nothing about the storage.

To move to a real database you write a PostgresUserRepo with the same two methods (Postgres would hand out the id via a sequence) and pass it in instead. Nothing in your business logic changes. That is Repository plus Protocol working together, and it is the pattern combo you will lean on most in production code. For the deeper contrast with inheritance-based contracts, see the abstract classes and ABC post.

When not to use it: a tiny script that reads one CSV does not need a repository layer. Add it when you have real business logic worth protecting from storage details, or when you need to test that logic without a live database. For a throwaway task it is just extra rooms in a house with one occupant.

Singleton, and Why a Module Usually Beats It

Singleton guarantees exactly one instance of something, like a single config object or one shared connection pool. In most languages this needs a careful class dance. In Python it is worth knowing the dance, but also worth knowing that you almost never need it, because a module is already a singleton: the first import runs the file once, Python caches the result in sys.modules, and every later import hands back the very same object.

📄 singleton.py: the class dance vs. what a module already gives you

# The classic Singleton dance: override __new__ to reuse one instance.
class Config:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.debug = False
        return cls._instance

a = Config()
b = Config()
a.debug = True
print("Singleton:  same object:", a is b, "| b.debug:", b.debug)

# A Python module is ALREADY a singleton: imported once, cached in sys.modules.
import sys
import math
import math as math_again
print("Module:     imported once cached:", math is math_again)
print("Module:     in sys.modules:", "math" in sys.modules)

▶ Output

Singleton:  same object: True | b.debug: True
Module:     imported once cached: True
Module:     in sys.modules: True

What happened here: the Config class does work to make a and b the same object, and it succeeds. But the bottom half shows you already had a singleton for free: importing math twice gives you the identical cached module. So for shared settings, the plain Python answer is a small settings.py module with module-level variables (or a single instance created once at the bottom of it). You import it anywhere and everyone sees the same state, no __new__ tricks required.

When not to use it: almost always. Singleton is really global state wearing a nicer coat, and global mutable state makes code hard to test and reason about, because any function might quietly change it. Prefer passing what a function needs as an argument (dependency injection). Reach for a real Singleton class only when you must lazily create one heavy resource and truly guarantee a single copy.

Refactor Exercise: Strategy plus Repository on a Log Parser

Python design patterns click when you apply them to messy code. Here is a small web-server log parser that counts requests by status category. The first version tangles three jobs into one function: it classifies each status code with a hard-coded if ladder and stores the tally in a bare dictionary, all mixed together. It works, but you cannot change the grouping or the storage without editing the parsing loop.

📄 log_parser_before.py: classify, tally, and loop all tangled

# One function does everything: read, classify, tally, all tangled together.
LINES = ["200 /home", "404 /missing", "500 /crash", "200 /cart", "301 /old"]

def parse(lines):
    counts = {"ok": 0, "redirect": 0, "client_error": 0, "server_error": 0}
    for line in lines:
        code = int(line.split()[0])
        if 200 <= code < 300:
            counts["ok"] += 1
        elif 300 <= code < 400:
            counts["redirect"] += 1
        elif 400 <= code < 500:
            counts["client_error"] += 1
        else:
            counts["server_error"] += 1
    return counts

print(parse(LINES))

▶ Output

{'ok': 2, 'redirect': 1, 'client_error': 1, 'server_error': 1}

Now the refactor. We pull the classification out into a classify function (Strategy: swap it to regroup the report) and hide the tally behind a CountRepository object (Repository: the parse loop no longer knows how counts are stored). The parsing loop shrinks to three lines and depends on nothing concrete. Crucially, the output stays byte-for-byte identical, which is your proof the refactor preserved behavior.

📄 log_parser_after.py: Strategy for grouping, Repository for storage

# Strategy picks the category; Repository stores the tally. Same output.
CATEGORIES = ("ok", "redirect", "client_error", "server_error")

def classify(code):                 # Strategy: swap this to regroup the report
    if 200 <= code < 300: return "ok"
    if 300 <= code < 400: return "redirect"
    if 400 <= code < 500: return "client_error"
    return "server_error"

class CountRepository:              # Repository: hide how tallies are stored
    def __init__(self):
        self._counts = {name: 0 for name in CATEGORIES}
    def record(self, category):
        self._counts[category] += 1
    def summary(self):
        return dict(self._counts)

def parse(lines, categorize, repo):
    for line in lines:
        repo.record(categorize(int(line.split()[0])))
    return repo.summary()

LINES = ["200 /home", "404 /missing", "500 /crash", "200 /cart", "301 /old"]
print(parse(LINES, classify, CountRepository()))

▶ Output

{'ok': 2, 'redirect': 1, 'client_error': 1, 'server_error': 1}

What happened here: same numbers, very different flexibility. Now parse accepts any categorize function, so grouping 4xx and 5xx together for an error dashboard is a one-line new strategy, no edit to the loop. And CountRepository could just as easily write to a database or a metrics service without parse noticing. This is the professional move: keep the thing that changes often (grouping rules, storage) separate from the thing that stays stable (the parse loop). AI systems lean on this same separation heavily, and the patterns specific to Large Language Model (LLM) pipelines and agents get their own treatment later in the series.

Common Mistakes

Mistake 1: Adding a pattern before you feel the pain

The most common mistake with Python design patterns is applying one speculatively, “in case we need it later.” You end up with factories that build one thing and strategies nobody swaps. Write the simple version first. When a second way to do the job actually arrives, the refactor to Strategy takes five minutes, and now it is justified. Patterns are a response to real change, not a prediction of it.

Mistake 2: Translating Java patterns line for line

Copying a Java Strategy with an interface, three implementing classes, and a factory into Python gives you fifty lines where five would do. Ask what the pattern is protecting, then use the lightest Python tool that protects it, usually a function, a dict, or a Protocol. The pattern is the idea, not the class scaffolding around it.

Mistake 3: Reaching for a Singleton instead of an argument

A Singleton feels convenient because you can grab it anywhere, but that is exactly why it hurts: it is hidden global state that any function can mutate, which makes tests flaky and bugs hard to trace. Pass the config or connection in as an argument instead. Your functions become honest about what they depend on, and testing gets trivial because you can hand them a fake.

Best Practices

  • DO prefer a plain function for Strategy and Command, since a function is already a first-class object in Python
  • DO use typing.Protocol for interfaces so classes fit by shape, with no inheritance and no import coupling
  • DO keep the thing that changes often separate from the thing that stays stable, which is the point of every pattern here
  • DO reach for a plain module instead of a Singleton class when you just need shared state
  • DON’T add a pattern to look senior, add it when duplicated or changing code makes it pay for itself
  • DON’T hide a growing if/elif chain inside a factory when a dictionary lookup reads better

Conclusion

You now have the six Python design patterns that survive contact with real code, and just as importantly, the judgment to know when each is overkill. Strategy and Factory tame changing behavior, Observer and Adapter tame changing collaborators, Repository tames changing storage, and Singleton is mostly a warning to prefer a plain module. Every one of them exists to keep the code that changes often away from the code that stays stable. That single idea, not the class boilerplate, is what makes a design good.

Practice these Python design patterns by opening any script you wrote earlier in this series and asking the flowchart’s question: is there logic duplicated in three places, or a behavior you wish you could swap? If yes, refactor it to the lightest pattern that fits and rerun to confirm the output did not move. And if you want to see everything this series covers, from first steps to AI and machine learning, browse the Python + AI/ML tutorial series home.

Frequently Asked Questions

Do I still need design patterns in Python?

Some, not most. Many classic Python design patterns dissolve because the language has first-class functions and modules that are already singletons. A handful still earn their keep: Strategy, Factory, Observer, Adapter, and Repository. Use them when duplicated or changing code makes them pay off, not to prove you know them.

Why use a function instead of a Strategy class?

In Python a function is a first-class object you can pass around, so a strategy is just a function you hand to another function. That removes the interface and the implementing classes a Java version needs. Promote it to a class only when the strategy must carry state between calls.

What replaces interfaces in modern Python?

typing.Protocol. At the time of writing it is the standard idiom for describing a shape a class must have. Any class with matching methods satisfies it through structural typing, with no inheritance and no import coupling, and type checkers like mypy verify the fit.

Is Singleton an anti-pattern in Python?

It is close to one because it is global mutable state that any function can change, which makes code hard to test. You rarely need the class version anyway, since a module is imported once and cached, giving you a shared single instance for free. Prefer passing dependencies as arguments.

When should I use the Repository pattern?

When you have real business logic worth isolating from storage details, or when you need to test that logic without a live database. Combine it with a Protocol so an in-memory version and a database version are interchangeable. For a one-off script that reads a single file, skip it.

Interview Questions on Design Patterns

The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.

Q: Why do many Gang of Four patterns feel unnecessary in Python?

Because they were designed around limits Python does not have. Command and Strategy exist to wrap behavior in objects, but Python functions are already first-class objects you can pass and store. Iterator is built into every for loop. Singleton is what a module already is once imported. So those patterns collapse into a line or two, and only the ones that name a genuine decoupling decision, like Adapter or Repository, still carry weight.

Q: How would you implement Strategy without any classes?

I write each strategy as a plain function with the same signature and pass the chosen one into the code that uses it. The consumer just calls whatever it received, so adding a new strategy means writing one function, with no edit to the consumer. I only reach for a class with a call method when the strategy must remember state between invocations.

Q: What is the difference between an abstract base class and a Protocol?

An abstract base class uses nominal typing: a class fits only if it explicitly inherits from it. A Protocol uses structural typing: any class with the right methods fits automatically, with no inheritance and no import of the Protocol. Protocols keep modules decoupled and are the modern idiom for interfaces at the time of writing, while an ABC is useful when you also want to share default implementation.

Q: Why is Singleton often called an anti-pattern, and what do you use instead?

Because it is global mutable state in disguise. Any function can reach in and change it, which makes behavior depend on hidden order and makes tests flaky since state leaks between them. I prefer passing the dependency in as an argument, which makes the need explicit and lets me inject a fake in tests. When I truly need one shared instance, a plain module usually gives it for free.

Q: How does the Repository pattern make testing easier?

It puts an object between business logic and storage, so the logic depends on a small interface, not on a specific database. In tests I pass an in-memory repository that fits the same Protocol, so the logic runs fast with no database at all, and in production I pass the real one. The business code is identical in both cases, which is exactly the decoupling the pattern buys.

Want more? the official Python documentation documents everything this post could not fit.

Previous: Python Project: Build a Log Parser CLI (Regex + argparse)

Next: Python: pip vs Poetry vs uv vs conda, Package Managers Compared

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *