Python Design Patterns Every Developer Should Know

Design patterns are reusable solutions to common programming problems. They represent best practices evolved over time by experienced developers. In Python, some patterns are built into the language itself, while others can significantly improve code organization, maintainability, and readability.

1. Singleton Pattern

Ensures a class has only one instance and provides global access to it.

class DatabaseConnection:
    _instance = None
    _initialized = False
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    
    def __init__(self):
        if not self._initialized:
            self.connection = "Database connected"
            self._initialized = True
    
    def query(self, sql):
        return f"Executing: {sql}"

# Usage
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2)  # True - same instance

# Alternative using decorator
def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Logger:
    def __init__(self):
        self.logs = []
    
    def log(self, message):
        self.logs.append(message)

2. Factory Pattern

Creates objects without specifying their exact classes.

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def make_sound(self):
        pass

class Dog(Animal):
    def make_sound(self):
        return "Woof!"

class Cat(Animal):
    def make_sound(self):
        return "Meow!"

class Bird(Animal):
    def make_sound(self):
        return "Tweet!"

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

# Usage
factory = AnimalFactory()
dog = factory.create_animal('dog')
cat = factory.create_animal('cat')

print(dog.make_sound())  # "Woof!"
print(cat.make_sound())  # "Meow!"

3. Observer Pattern

Defines a one-to-many dependency between objects.

class Subject:
    def __init__(self):
        self._observers = []
        self._state = None
    
    def attach(self, observer):
        if observer not in self._observers:
            self._observers.append(observer)
    
    def detach(self, observer):
        if observer in self._observers:
            self._observers.remove(observer)
    
    def notify(self):
        for observer in self._observers:
            observer.update(self)
    
    def set_state(self, state):
        self._state = state
        self.notify()
    
    def get_state(self):
        return self._state

class Observer(ABC):
    @abstractmethod
    def update(self, subject):
        pass

class EmailNotifier(Observer):
    def __init__(self, name):
        self.name = name
    
    def update(self, subject):
        print(f"{self.name} received email: State changed to {subject.get_state()}")

class SMSNotifier(Observer):
    def __init__(self, name):
        self.name = name
    
    def update(self, subject):
        print(f"{self.name} received SMS: State changed to {subject.get_state()}")

# Usage
news_agency = Subject()

email_notifier = EmailNotifier("John")
sms_notifier = SMSNotifier("Jane")

news_agency.attach(email_notifier)
news_agency.attach(sms_notifier)

news_agency.set_state("Breaking News: Python 4.0 Released!")

4. Decorator Pattern

Python’s built-in decorator syntax makes this pattern elegant.

import functools
import time
from datetime import datetime

def timing_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise e
                    print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay} seconds...")
                    time.sleep(delay)
        return wrapper
    return decorator

class cache:
    def __init__(self, expiration_time=300):  # 5 minutes default
        self.expiration_time = expiration_time
        self.cache_data = {}
    
    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            key = str(args) + str(sorted(kwargs.items()))
            
            if key in self.cache_data:
                result, timestamp = self.cache_data[key]
                if time.time() - timestamp < self.expiration_time:
                    print(f"Cache hit for {func.__name__}")
                    return result
            
            result = func(*args, **kwargs)
            self.cache_data[key] = (result, time.time())
            print(f"Cache miss for {func.__name__}")
            return result
        return wrapper

# Usage
@timing_decorator
@cache(expiration_time=60)
@retry(max_attempts=3, delay=0.5)
def expensive_operation(n):
    time.sleep(1)  # Simulate expensive operation
    return n * n

print(expensive_operation(5))  # Cache miss, takes ~1 second
print(expensive_operation(5))  # Cache hit, much faster

5. Strategy Pattern

Defines a family of algorithms and makes them interchangeable.

from abc import ABC, abstractmethod

class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

class CreditCardPayment(PaymentStrategy):
    def __init__(self, card_number, cvv):
        self.card_number = card_number
        self.cvv = cvv
    
    def pay(self, amount):
        return f"Paid ${amount} using Credit Card ending in {self.card_number[-4:]}"

class PayPalPayment(PaymentStrategy):
    def __init__(self, email):
        self.email = email
    
    def pay(self, amount):
        return f"Paid ${amount} using PayPal account {self.email}"

class BankTransferPayment(PaymentStrategy):
    def __init__(self, account_number):
        self.account_number = account_number
    
    def pay(self, amount):
        return f"Paid ${amount} using Bank Transfer from account {self.account_number}"

class PaymentProcessor:
    def __init__(self, strategy: PaymentStrategy):
        self._strategy = strategy
    
    def set_strategy(self, strategy: PaymentStrategy):
        self._strategy = strategy
    
    def process_payment(self, amount):
        return self._strategy.pay(amount)

# Usage
processor = PaymentProcessor(CreditCardPayment("1234567890123456", "123"))
print(processor.process_payment(100))

processor.set_strategy(PayPalPayment("user@example.com"))
print(processor.process_payment(50))

processor.set_strategy(BankTransferPayment("9876543210"))
print(processor.process_payment(200))

6. Command Pattern

Encapsulates requests as objects, allowing you to parameterize clients with different requests.

from abc import ABC, abstractmethod
import json

class Command(ABC):
    @abstractmethod
    def execute(self):
        pass
    
    @abstractmethod
    def undo(self):
        pass

class FileManager:
    def __init__(self):
        self.files = {}
    
    def create_file(self, filename, content):
        self.files[filename] = content
        print(f"Created file: {filename}")
    
    def delete_file(self, filename):
        if filename in self.files:
            del self.files[filename]
            print(f"Deleted file: {filename}")
    
    def modify_file(self, filename, new_content):
        if filename in self.files:
            old_content = self.files[filename]
            self.files[filename] = new_content
            print(f"Modified file: {filename}")
            return old_content

class CreateFileCommand(Command):
    def __init__(self, file_manager, filename, content):
        self.file_manager = file_manager
        self.filename = filename
        self.content = content
    
    def execute(self):
        self.file_manager.create_file(self.filename, self.content)
    
    def undo(self):
        self.file_manager.delete_file(self.filename)

class DeleteFileCommand(Command):
    def __init__(self, file_manager, filename):
        self.file_manager = file_manager
        self.filename = filename
        self.deleted_content = None
    
    def execute(self):
        self.deleted_content = self.file_manager.files.get(self.filename)
        self.file_manager.delete_file(self.filename)
    
    def undo(self):
        if self.deleted_content is not None:
            self.file_manager.create_file(self.filename, self.deleted_content)

class FileManagerInvoker:
    def __init__(self):
        self.history = []
        self.current_position = -1
    
    def execute_command(self, command):
        # Remove any commands after current position
        self.history = self.history[:self.current_position + 1]
        
        command.execute()
        self.history.append(command)
        self.current_position += 1
    
    def undo(self):
        if self.current_position >= 0:
            command = self.history[self.current_position]
            command.undo()
            self.current_position -= 1
    
    def redo(self):
        if self.current_position < len(self.history) - 1:
            self.current_position += 1
            command = self.history[self.current_position]
            command.execute()

# Usage
file_manager = FileManager()
invoker = FileManagerInvoker()

# Execute commands
create_cmd = CreateFileCommand(file_manager, "test.txt", "Hello World")
invoker.execute_command(create_cmd)

delete_cmd = DeleteFileCommand(file_manager, "test.txt")
invoker.execute_command(delete_cmd)

# Undo operations
invoker.undo()  # Recreates the file
invoker.undo()  # Removes the file again
invoker.redo()  # Recreates the file

7. Builder Pattern

Constructs complex objects step by step.

class Pizza:
    def __init__(self):
        self.size = None
        self.crust = None
        self.toppings = []
        self.cheese = None
    
    def __str__(self):
        return f"{self.size} {self.crust} pizza with {self.cheese} cheese and toppings: {', '.join(self.toppings)}"

class PizzaBuilder:
    def __init__(self):
        self.pizza = Pizza()
    
    def set_size(self, size):
        self.pizza.size = size
        return self
    
    def set_crust(self, crust):
        self.pizza.crust = crust
        return self
    
    def add_topping(self, topping):
        self.pizza.toppings.append(topping)
        return self
    
    def set_cheese(self, cheese):
        self.pizza.cheese = cheese
        return self
    
    def build(self):
        return self.pizza

class PizzaDirector:
    @staticmethod
    def make_margherita():
        return (PizzaBuilder()
                .set_size("Medium")
                .set_crust("Thin")
                .set_cheese("Mozzarella")
                .add_topping("Tomato")
                .add_topping("Basil")
                .build())
    
    @staticmethod
    def make_pepperoni():
        return (PizzaBuilder()
                .set_size("Large")
                .set_crust("Thick")
                .set_cheese("Cheddar")
                .add_topping("Pepperoni")
                .add_topping("Mushrooms")
                .build())

# Usage
# Manual building
custom_pizza = (PizzaBuilder()
                .set_size("Small")
                .set_crust("Stuffed")
                .set_cheese("Parmesan")
                .add_topping("Chicken")
                .add_topping("BBQ Sauce")
                .add_topping("Red Onions")
                .build())

# Using director for common configurations
margherita = PizzaDirector.make_margherita()
pepperoni = PizzaDirector.make_pepperoni()

print(custom_pizza)
print(margherita)
print(pepperoni)

8. Context Manager Pattern

Python's context managers provide a clean way to manage resources.

import sqlite3
from contextlib import contextmanager

class DatabaseManager:
    def __init__(self, db_name):
        self.db_name = db_name
        self.connection = None
    
    def __enter__(self):
        self.connection = sqlite3.connect(self.db_name)
        return self.connection
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            self.connection.rollback()
        else:
            self.connection.commit()
        self.connection.close()

@contextmanager
def file_manager(filename, mode='r'):
    print(f"Opening file {filename}")
    file = open(filename, mode)
    try:
        yield file
    finally:
        print(f"Closing file {filename}")
        file.close()

@contextmanager
def timer_context():
    import time
    start = time.time()
    try:
        yield
    finally:
        end = time.time()
        print(f"Operation took {end - start:.4f} seconds")

# Usage
with DatabaseManager('example.db') as db:
    cursor = db.cursor()
    cursor.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER, name TEXT)')
    cursor.execute('INSERT INTO users VALUES (1, "John")')

with file_manager('test.txt', 'w') as f:
    f.write('Hello, World!')

with timer_context():
    time.sleep(1)  # Some operation

When to Use Each Pattern

  • Singleton: Database connections, loggers, configuration objects
  • Factory: Creating objects based on user input or configuration
  • Observer: Event systems, model-view architectures
  • Decorator: Adding functionality to existing functions (logging, caching, authentication)
  • Strategy: Algorithm selection, payment processing, data formatting
  • Command: Undo/redo functionality, queuing operations, macro recording
  • Builder: Complex object construction with many optional parameters
  • Context Manager: Resource management, temporary state changes

Best Practices

  1. Don't overuse patterns: Apply them when they solve real problems
  2. Favor composition over inheritance: Python's duck typing makes this natural
  3. Use Python's built-in features: Many patterns are simplified by Python's syntax
  4. Keep it readable: The pattern should make code clearer, not more complex
  5. Test your patterns: Ensure they work correctly and provide the expected benefits

Design patterns are tools in your programming toolkit. Understanding when and how to apply them will make you a more effective Python developer, leading to more maintainable and robust code.

Further Reading and References

Author

  • Mohammad Golam Dostogir, Software Engineer specializing in Python, Django, and AI solutions. Active contributor to open-source projects and tech communities, with experience delivering applications for global companies.
    GitHub

    View all posts
Index