Skip to main content

Strategy Pattern in Python: Write Flexible, Clean Code

Strategy Pattern in Python: Write Flexible, Clean Code How to pick different algorithms at runtime—without a tangled web of if/else Hey there! Today I want to write about a design pattern:  the Strategy Pattern . Perfect for when you have multiple ways to solve the same problem and you want clean, maintainable code. Why the Strategy Pattern? Let’s start with a scenario: imagine you're building a pizza delivery app, and different restaurants have wildly different APIs. Without planning, you might end up stuffing your code full of branching logic. Instead, the Strategy Pattern lets you isolate each integration into its own “strategy,” making your code neat and flexible. In software design terms, a strategy defines a family of algorithms, each encapsulated in its own class (or callable), and they’re all interchangeable at runtime. This helps avoid massive if/else blocks and makes adding new behavior super easy without touching existing code—hell...

Strategy Pattern in Python: Write Flexible, Clean Code

Strategy Pattern in Python: Write Flexible, Clean Code

How to pick different algorithms at runtime—without a tangled web of if/else

Hey there! Today I want to write about a design pattern: the Strategy Pattern. Perfect for when you have multiple ways to solve the same problem and you want clean, maintainable code.

Why the Strategy Pattern?

Let’s start with a scenario: imagine you're building a pizza delivery app, and different restaurants have wildly different APIs. Without planning, you might end up stuffing your code full of branching logic. Instead, the Strategy Pattern lets you isolate each integration into its own “strategy,” making your code neat and flexible.

In software design terms, a strategy defines a family of algorithms, each encapsulated in its own class (or callable), and they’re all interchangeable at runtime. This helps avoid massive if/else blocks and makes adding new behavior super easy without touching existing code—hello open/closed principle!

The Problem with if/else Hell

Before we bring in the Strategy Pattern, let’s look at the “naive” way of solving this problem. You might write a function that tries to handle every restaurant API in one place

def order_pizza(api_type: str, pizza_type: str, quantity: int):
    if api_type == "fancy":
        print(
            f"[FancyPizzaAPI] Sending JSON payload: {{'pizza': '{pizza_type}', 'qty': {quantity}}}"
        )
    elif api_type == "oldschool":
        print(f"[OldSchoolPizzaAPI] Sending XML: {pizza_type}{quantity}")
    elif api_type == "superfast":
        print(
            f"[SuperFastPizzaAPI] Sending plain text: ORDER {pizza_type.upper()} x{quantity}"
        )
    else:
        raise ValueError("Unknown API type")

This works fine if you only have two or three cases. But as soon as you need to integrate more restaurants, this function becomes a mess of branching logic. Every new API means editing this function, risking bugs in existing logic. It’s not modular, it’s not extensible, and it violates the open/closed principle. That’s exactly the pain point the Strategy Pattern is designed to solve.

Example

Here is an example of how one might solve the pizza delivery scenario:

from abc import ABC, abstractmethod


# --- Strategy Interface ---
class RestaurantAPI(ABC):
    """Common interface for all restaurant integrations"""

    @abstractmethod
    def place_order(self, pizza_type: str, quantity: int) -> None:
        pass


# --- Concrete Strategies ---
class FancyPizzaAPI(RestaurantAPI):
    def place_order(self, pizza_type: str, quantity: int) -> None:
        print(
            f"[FancyPizzaAPI] Sending JSON payload to /order: {{'pizza': '{pizza_type}', 'qty': {quantity}}}"
        )


class OldSchoolPizzaAPI(RestaurantAPI):
    def place_order(self, pizza_type: str, quantity: int) -> None:
        print(
            f"[OldSchoolPizzaAPI] Sending XML: {pizza_type}{quantity}"
        )


class SuperFastPizzaAPI(RestaurantAPI):
    def place_order(self, pizza_type: str, quantity: int) -> None:
        print(
            f"[SuperFastPizzaAPI] Sending plain text: ORDER {pizza_type.upper()} x{quantity}"
        )


# --- Context ---
class PizzaDeliveryService:
    """Holds a reference to a RestaurantAPI strategy"""

    def __init__(self, api_strategy: RestaurantAPI):
        self._api_strategy = api_strategy

    def set_strategy(self, api_strategy: RestaurantAPI):
        """Switch to a different restaurant API at runtime"""
        self._api_strategy = api_strategy

    def order_pizza(self, pizza_type: str, quantity: int):
        self._api_strategy.place_order(pizza_type, quantity)


# --- Example Usage ---
if __name__ == "__main__":
    # Start with FancyPizzaAPI
    service = PizzaDeliveryService(FancyPizzaAPI())
    service.order_pizza("Margherita", 2)

    # Switch to OldSchoolPizzaAPI
    service.set_strategy(OldSchoolPizzaAPI())
    service.order_pizza("Pepperoni", 1)

    # Switch to SuperFastPizzaAPI
    service.set_strategy(SuperFastPizzaAPI())
    service.order_pizza("Hawaiian", 3)

What’s Going On? 

Now that we’ve seen the code, let’s break down how the Strategy Pattern is applied in our pizza delivery app. We’ll look at the roles of each piece and how they work together to keep our code clean and adaptable.

  • Strategy (RestaurantAPI) – An interface that declares the method place_order, which every restaurant integration must implement.

  • Concrete StrategiesFancyPizzaAPI, OldSchoolPizzaAPI, and SuperFastPizzaAPI each implement place_order in their own way (JSON, XML, plain text).

  • Context (PizzaDeliveryService) – Holds a reference to a strategy object and delegates order_pizza calls to it.

  • Runtime Swapping – We can call set_strategy to switch to a different restaurant API without changing the rest of our code.

In practice, this means the pizza delivery service can send orders without knowing anything about the specific API details of each restaurant. If you need to integrate a new restaurant, you simply create a new class for it. You don't need to alter any existing code. This keeps your application free from longif/else chains and ensures you’re following the open/closed principle: your code is open for extension but closed for modification.

When Should You Use It?

The Strategy Pattern is especially useful in situations where you have several different ways to achieve the same goal, and you want to switch between them without cluttering your code. For example, you might need to apply different sorting, filtering, or validation logic depending on user input or a configuration setting. In an e-commerce application, you could use it to manage multiple pricing or discount rules, allowing each to be encapsulated as its own strategy. It also comes in handy when exporting data in various formats—such as CSV, JSON, or XML—where each format is handled by a dedicated export strategy. Even payment processing can benefit: credit cards, PayPal, and bank transfers can each be implemented as interchangeable strategies, making it easy for the client to pick one at runtime without touching the underlying business logic. In each of these cases, the Strategy Pattern keeps your code modular, flexible, and ready to grow without turning into a maintenance nightmare.

A Lightweight Alternative: Functions as Strategies

One cool thing about Python is that functions are first-class citizens. That means we don’t always need full-blown classes to represent a strategy—sometimes a simple function is enough. Here’s a function-based version of our pizza delivery service:

# --- Function-based strategies ---
def fancy_pizza_order(pizza_type: str, quantity: int):
    print(
        f"[FancyPizzaAPI] Sending JSON payload: {{'pizza': '{pizza_type}', 'qty': {quantity}}}"
    )


def oldschool_pizza_order(pizza_type: str, quantity: int):
    print(f"[OldSchoolPizzaAPI] Sending XML: {pizza_type}{quantity}")


def superfast_pizza_order(pizza_type: str, quantity: int):
    print(
        f"[SuperFastPizzaAPI] Sending plain text: ORDER {pizza_type.upper()} x{quantity}"
    )


# --- Context using functions ---
class PizzaDeliveryService:
    def __init__(self, order_strategy):
        self._order_strategy = order_strategy

    def set_strategy(self, order_strategy):
        self._order_strategy = order_strategy

    def order_pizza(self, pizza_type: str, quantity: int):
        self._order_strategy(pizza_type, quantity)


# --- Example usage ---
service = PizzaDeliveryService(fancy_pizza_order)
service.order_pizza("Margherita", 2)

service.set_strategy(oldschool_pizza_order)
service.order_pizza("Pepperoni", 1)

This version is short, flexible, and feels very “Pythonic.” The function-based approach is great when your strategies are simple, independent, and stateless. The class-based Strategy Pattern (what we built earlier) is more appropriate when strategies get complex, need shared state, or must follow a strict interface.

How Do I Know I Should Use It?

Ask yourself:

  • Do I have different ways to accomplish the same task?
  • Is there lots of branching logic (if/else or switch) spread across my code?
  • Do I anticipate adding new behavior variants in the future?

If you answered yes, the Strategy Pattern can clean that up by isolating behaviors, simplifying maintenance, and making your decisions explicit.

Also note that too many tiny strategy classes can feel over-engineered for simple tasks. Keep strategy interfaces minimal and strategies stateless when possible. Let Context manage state.

Wrapping Up

I hope this short walkthrough helps you see how the Strategy Pattern can make your code cleaner and more flexible. Next time you’re tempted to write a ton of if/else logic, consider isolating variations as strategies. It's a neat way to keep your code modular and future‑friendly.

Feel free to ask more questions if you want to dig deeper.

Happy coding!

References

  1. Refactoring.Guru – Strategy Pattern in Python
  2. Auth0 – Strategy Design Pattern in Python
  3. DEV.to – Strategy Pattern in Python
  4. Medium – Design Patterns in Python: Strategy
  5. GeeksforGeeks – Strategy Design Pattern
  6. Stackify – Strategy Pattern Best Practices

Comments

Popular posts from this blog

Format Your Python Code with black (Using uv like a Pro)

Keep things clean, fast, and simple—no extra installs needed.   Hey there! I want to show you a smooth way to clean up your Python code using black , but with a twist: we’ll use uv to handle it. Why uv you might ask? My short answer: No cluttered virtual environments, no extra installs, just clean code. Why I Use uv (and you might too) If you’ve ever felt bogged down setting up virtual environments just to install tools like black , flake8 , or pytest , I hear you. uv is a package and project manager that’s fast, one-and-done, and smart (and written in Rust , by the way). It lets you run or install tools in clean, cache-friendly environments, with commands like: uv tool install black # keeps black handy on your PATH uvx black my_code.py # quick run in a temp environment Check out the uv docs, especially the "Tools" section, for all the details ( Astral Docs ). Step‑by‑Step: Get black Set Up 1. Install uv (from the  Astral Doc...

Avoiding Pandas Pitfalls: View vs Copy and Keeping Your Base Dataframe Safe

Avoiding Pandas Pitfalls: View vs Copy and Keeping Your Base DataFrame Safe Ever accidentally changed your original pandas DataFrame without meaning to? I certainly have. It can feel like a debugging nightmare. In this post, I want to walk you through some of the lessons I’ve learned, like how pandas handles views and copies, and how to organize your code so you don't trip over unexpected behavior. Understanding View vs Copy in pandas In pandas, a “view” is just another way of looking at the same data in memory. That means if you change it, the original changes too. A “copy”, on the other hand, is a separate object entirely. What you do with it stays isolated. The problem is that pandas doesn’t always make it obvious whether you're dealing with a view or a copy. Sometimes a slice of a DataFrame gives you a view. Other times it gives you a copy. That inconsistency is the reason behind the infamous SettingWithCopyWarning [1] . For example, if...

Understanding the Python GIL (for Beginners)

What it is, why it matters, and what’s coming in future Python releases. Hey! Let’s talk about something that trips up a lot of developers when they dive into Python threading: the Global Interpreter Lock , or GIL . We will cover the essentials here. This will be a good starting point to decide if you want or need to dig deeper in the topic. What is the GIL? The GIL is a lock inside CPython (the standard Python interpreter). It ensures only one thread executes Python bytecode at a time, even on multi-core machines. See more on Wikipedia and the Python Wiki . It was put in place to manage Python’s internal systems (like memory and reference counting) without race conditions. Without the GIL, Python could crash or corrupt data. That’s why it exists, even if it feels limiting [ 1 ]. How does the GIL affect your code? Single-threaded scripts: No change. The GIL is invisible. I/O-bound tasks (like networking or file access): The G...