
Enhancing Your Python Code with Design Patterns: A Practical Approach
Learn how to apply classic design patterns in Python to write cleaner, more maintainable, and performant code. This post provides step-by-step explanations, practical examples (with code you can run), and ties design patterns to related skills like using f-strings, multiprocessing, and efficient sorting/searching algorithms.
Introduction
Design patterns are repeatable solutions to common software engineering problems. They provide a shared vocabulary and a set of proven techniques that help you structure code for clarity, extensibility, and maintainability. But how do design patterns translate into Python — a dynamic, expressive language with its own idioms?
In this post you'll get a practical, example-driven tour of several design patterns applied in realistic Python contexts. We'll explain why and when to use each pattern, show working code, and discuss performance considerations and edge cases. Along the way we'll reference related skills like decoding Python's f-strings, mastering multiprocessing, and creating efficient algorithms for sorting and searching — because patterns rarely live in isolation.
What will you gain?
- Concrete, runnable examples of common patterns (Singleton, Strategy, Factory, Observer, Decorator).
- Guidance on combining patterns with concurrency (multiprocessing) and algorithmic choices (sorting/searching).
- Best practices, pitfalls, and advanced tips to apply immediately.
Prerequisites
This article assumes:
- Familiarity with Python 3.x (functions, classes, decorators, basic modules).
- Comfort reading and running Python code locally.
- Basic understanding of algorithms and concurrency concepts.
- Python 3.8+ (for nice f-string features and typing).
- A terminal or IDE to run examples.
- f-strings and string formatting: https://docs.python.org/3/reference/lexical_analysis.html#f-strings
- multiprocessing: https://docs.python.org/3/library/multiprocessing.html
- Sort/search algorithms: many resources; Python built-ins: https://docs.python.org/3/library/functions.html#sorted
Core Concepts
Before diving into code, let’s define a few core concepts:
- Design Pattern: A reusable solution to a commonly occurring problem in software design.
- Idiomatic Python: Use of Python language features to keep code concise and readable; sometimes that means deviating from classical OOP-heavy pattern implementations.
- Composition over Inheritance: Prefer composing behavior from smaller components rather than deep class hierarchies.
- Performance Impact: Patterns influence structure, but algorithmic choices (e.g., sorting/searching) and concurrency (multiprocessing) often dominate runtime performance.
- Identify the problem: object creation, behavior change, communication between components, extension points.
- Consider Pythonic alternatives: sometimes a simple function or closure is enough.
- Favor readability and testability.
Pattern 1 — Singleton (Configuration object)
Use case: You want a single configuration object shared across your app without passing it everywhere.
Pythonic concerns:
- Be careful with global state.
- For multiprocessing, singletons aren't automatically shared across processes; they are per-process.
# singleton_config.py
import threading
from typing import Dict
class Config:
_instance = None
_lock = threading.Lock()
def __init__(self, settings: Dict[str, str]):
# private init; don't call directly after an instance exists
self._settings = dict(settings)
@classmethod
def initialize(cls, settings: Dict[str, str]):
with cls._lock:
if cls._instance is None:
cls._instance = cls(settings)
return cls._instance
@classmethod
def instance(cls):
if cls._instance is None:
raise RuntimeError("Config is not initialized")
return cls._instance
def get(self, key, default=None):
return self._settings.get(key, default)
Line-by-line:
- import threading: we use a lock to be thread-safe.
- _instance and _lock: class-level singletons and synchronization.
- initialize: sets the singleton once and returns it.
- instance: returns the existing instance (raises if not set).
- get: simple accessor.
- Multiprocessing: Each process has its own singleton instance. For shared config across processes, use a Manager or a shared file.
- Reinitializing: This pattern prevents reinitialization which avoids accidental state changes.
from singleton_config import Config
cfg = Config.initialize({"db": "sqlite:///data.db", "debug": "true"})
print(cfg.get("db"))
Pattern 2 — Strategy (Swappable Algorithms)
Use case: Switch sorting or searching algorithms at runtime without changing client code.
This is perfect to demonstrate integrating efficient algorithms for sorting and searching. Imagine you need different sorting strategies: quick_sort, merge_sort, or simply Python's sorted() for built-in optimization.
Example: Strategy pattern that allows choosing a sorting algorithm.
# strategy_sort.py
from typing import Callable, List
SortFunc = Callable[[List[int]], List[int]]
def python_sort(arr: List[int]) -> List[int]:
return sorted(arr)
def reverse_sort(arr: List[int]) -> List[int]:
return sorted(arr, reverse=True)
class Sorter:
def __init__(self, strategy: SortFunc = python_sort):
self._strategy = strategy
def set_strategy(self, strategy: SortFunc):
self._strategy = strategy
def sort(self, arr: List[int]) -> List[int]:
return self._strategy(arr)
Explanation:
- We define a SortFunc type alias for clarity.
- python_sort uses built-in
sorted()(usually highly optimized — prefer it unless you have special constraints). - Sorter holds a reference to a strategy function; set_strategy changes it at runtime.
s = Sorter()
data = [5, 2, 9, 1]
print(s.sort(data)) # [1, 2, 5, 9]
s.set_strategy(reverse_sort)
print(s.sort(data)) # [9, 5, 2, 1]
Why use Strategy?
- Swap algorithms to optimize for different data shapes or to compare algorithm performance.
- Useful for testing — inject a deterministic sorting function.
Pattern 3 — Factory (Controlled Creation)
Use case: Encapsulate object creation; return different implementations based on input.
Example: A factory for data processors (CSV, JSON, XML). This pattern reduces coupling between client code and concrete classes.
# factory_processor.py
from abc import ABC, abstractmethod
import json
import csv
from typing import Any, Dict, List, Iterable
class Processor(ABC):
@abstractmethod
def parse(self, raw: str) -> Any:
pass
class JSONProcessor(Processor):
def parse(self, raw: str) -> Any:
return json.loads(raw)
class CSVProcessor(Processor):
def parse(self, raw: str) -> List[Dict[str, str]]:
reader = csv.DictReader(raw.splitlines())
return list(reader)
def processor_factory(kind: str) -> Processor:
kind = kind.lower()
if kind == 'json':
return JSONProcessor()
elif kind == 'csv':
return CSVProcessor()
else:
raise ValueError(f"Unknown processor kind: {kind}")
Line-by-line highlights:
- Abstract base class
Processordefines the interface. - Concrete processors implement
parse(). processor_factory()chooses the right implementation.
- Invalid kind raises a clear ValueError — clients can catch this.
- If processors need expensive setup, consider caching instances (and thread/process safety).
Pattern 4 — Observer (Event Notification)
Use case: Notify multiple listeners when something happens — relevant for UI, logs, or monitoring.
Basic observer in-process:
# observer.py
from typing import Callable, Dict, List
Listener = Callable[[str, Dict], None]
class EventBus:
def __init__(self):
self._listeners: Dict[str, List[Listener]] = {}
def subscribe(self, event_type: str, listener: Listener):
self._listeners.setdefault(event_type, []).append(listener)
def publish(self, event_type: str, payload: Dict):
for listener in self._listeners.get(event_type, []):
try:
listener(event_type, payload)
except Exception as e:
# robust: one bad listener shouldn't break others
print(f"Listener error for {event_type}: {e}")
Usage example:
bus = EventBus()
def log_listener(ev, p):
print(f"LOG: {ev} -> {p}")
bus.subscribe("task.completed", log_listener)
bus.publish("task.completed", {"id": 42, "status": "ok"})
Considerations:
- Multiprocessing: event objects and listeners need to be picklable to be used across processes. For cross-process messaging, prefer multiprocessing.Queue or third-party message brokers (Redis, RabbitMQ).
- Error handling: make listeners fail-safe so one error doesn't prevent other listeners running.
Pattern 5 — Decorator (Behavior Augmentation)
Use case: Add logging, retry logic, caching, or timing without changing core functions.
This ties conveniently to f-strings for readable, efficient logging.
Example: A timing decorator that logs using f-strings.
# decorator_timing.py
import time
from functools import wraps
from typing import Callable, Any
def timeit(logger: Callable[[str], None] = print):
def decorator(func: Callable[..., Any]):
@wraps(func)
def wrapper(args, kwargs):
start = time.perf_counter()
result = func(args, *kwargs)
elapsed = time.perf_counter() - start
# Using f-strings: prefer simple expressions, and format floats for readability
logger(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
return decorator
@timeit()
def compute(n):
return sum(ii for i in range(n))
Line-by-line:
- Use functools.wraps to keep function metadata.
- time.perf_counter() gives accurate timing.
- f-string with format specifier .6f keeps the printed string readable and efficient.
- Keep expressions inside f-strings simple. Heavy computation inside f-strings is harder to debug.
- Use format specifiers for alignment and precision, e.g., {value:.2f}.
- For debugging, Python 3.8+ supports the = specifier: f"{variable=}" prints both name and value.
Combining Patterns with Multiprocessing
What if you need concurrency? Suppose you want to process large data files with different parsing strategies and parallel workers. Use Factory + Strategy + multiprocessing's Pool.
Example: Parallel processing using a factory-created parser and Pool (note: functions and objects must be picklable).
# mp_processing.py
from multiprocessing import Pool
from factory_processor import processor_factory
from typing import List
def worker(args):
kind, raw = args
processor = processor_factory(kind)
return processor.parse(raw)
if __name__ == "__main__":
data: List[tuple] = [
('json', '{"a":1}'),
('csv', "name,age\nAlice,30\nBob,25")
]
with Pool(processes=2) as p:
results = p.map(worker, data)
print(results)
Important notes:
workeris a top-level function so it's picklable.- Objects created inside worker (processor) are local to each process.
- For large shared data or configuration, use multiprocessing.Manager or shared memory.
- Capturing closures or lambdas that aren't picklable will fail on Windows (spawn) and in many multiprocessing setups.
- Careful with global singletons; each process gets its own copy — this may be desired or not.
Example Project: Plug-in Sorter with Strategy and Multiprocessing
Scenario: You have multiple large lists that you want to sort with different strategies in parallel. We'll use Strategy for the sorting algorithm and multiprocessing Pool for parallelism. We'll also show performance considerations.
# parallel_sorter.py
from multiprocessing import Pool
from typing import List, Callable
from strategy_sort import python_sort, reverse_sort, Sorter
def sort_worker(args):
strategy_func, arr = args
sorter = Sorter(strategy=strategy_func)
return sorter.sort(arr)
if __name__ == "__main__":
datasets = [
(python_sort, list(range(100000, 0, -1))),
(reverse_sort, list(range(100000))),
]
with Pool(2) as p:
results = p.map(sort_worker, datasets)
# results now holds two sorted lists
print([len(r) for r in results])
Performance tips:
- Sorting large arrays often best served by
sorted()(TimSort) unless you have specialized constraints. - Avoid large data copies between processes; consider memory mapping (mmap) or shared arrays if datasets are huge.
- When sending functions across processes, ensure they are top-level and picklable.
Best Practices
- Prefer idiomatic Python: sometimes patterns are simpler with functions or modules.
- Use composition and small interfaces — small, well-tested components are easier to replace.
- Document extension points — factories and strategy hooks should be obvious to API users.
- Consider performance tradeoffs: design patterns improve structure but algorithm complexity usually dominates runtime.
- For concurrency, always think about serialization (pickling), shared state, and process boundaries.
- Use f-strings for readable, efficient string formatting; keep expressions simple and use format specifiers.
Common Pitfalls
- Overengineering: applying patterns mechanically can add unnecessary complexity.
- Globals and singletons: make debugging and testing harder; prefer dependency injection when possible.
- Pickle woes: lambdas, nested functions, and some objects can't be shared across processes.
- Poor error handling in observers or decorators can crash your system — isolate and log exceptions.
- Choosing a slower algorithm for clarity without profiling: always profile before optimizing.
Advanced Tips
- Use typing and protocols (from typing) to define interfaces for strategies and processors.
- For Observer in distributed systems, use a message broker (Redis, RabbitMQ, Kafka) instead of in-process EventBus.
- Combine design patterns with data structures and algorithms knowledge. For example, Strategy + efficient search:
- For CPU-bound tasks, prefer multiprocessing (not threading); for I/O-bound tasks, use async IO.
- When mixing patterns with multiprocessing, consider initializing heavy resources in each worker process via initializer functions of Pool.
Example: Using Strategy to Choose Search Algorithm
# search_strategy.py
from bisect import bisect_left
from typing import Callable, List, Optional
SearchFunc = Callable[[List[int], int], Optional[int]]
def linear_search(arr: List[int], target: int) -> Optional[int]:
for i, v in enumerate(arr):
if v == target:
return i
return None
def binary_search(arr: List[int], target: int) -> Optional[int]:
i = bisect_left(arr, target)
if i != len(arr) and arr[i] == target:
return i
return None
class Searcher:
def __init__(self, strategy: SearchFunc = linear_search):
self.strategy = strategy
def find(self, arr: List[int], target: int) -> Optional[int]:
return self.strategy(arr, target)
Pick the strategy based on preconditions:
- Use binary search for sorted arrays (O(log n)).
- For small lists, linear search can outperform binary search due to lower overhead.
Conclusion
Design patterns are powerful tools when applied thoughtfully. They help you create extensible, maintainable, and testable code. In Python, leverage language features (f-strings for clear logging, built-in sorted for performance, multiprocessing for parallelism) while applying patterns like Singleton, Strategy, Factory, Observer, and Decorator where they add value.
Try the examples:
- Swap strategies in the Sorter.
- Run the multiprocessing examples.
- Experiment by replacing the sort algorithm with a custom implementation and profile the differences.
Further Reading and References
- Python's official docs: f-strings and lexical analysis — https://docs.python.org/3/reference/lexical_analysis.html#f-strings
- Multiprocessing — https://docs.python.org/3/library/multiprocessing.html
- Collections and algorithms: built-in sorted and bisect — https://docs.python.org/3/library/bisect.html
- "Design Patterns: Elements of Reusable Object-Oriented Software" (Gang of Four) — classic patterns book
- Fluent Python by Luciano Ramalho — idiomatic Python approaches to common problems
Was this article helpful?
Your feedback helps us improve our content. Thank you!