7 Python Design Patterns That Make Data Science Code Maintainable
Strategy: when the branch you keep editing is the algorithm
The pressure: your training script has an if model_type == "xgboost" block that has grown a sibling for every experiment, and each new scoring rule touches the same function.
The strategy pattern pulls each interchangeable algorithm behind a common interface so you can swap it at runtime. In Python you rarely need a class hierarchy. A plain callable often suffices:
# skeletal: bodies omitted for brevity
def train_xgboost(X, y): ...
def train_linear(X, y): ...
def run_experiment(train_fn, X, y):
return train_fn(X, y)
When the variants carry configuration, promote them to classes sharing a fit and predict signature, which is also the shape scikit-learn already expects. The caveat: if you have exactly two branches and no sign of a third, a strategy is ceremony. Write the if and move on.
Adapter: when three libraries speak three dialects
The pressure: scikit-learn estimators, PyTorch modules and an internal prediction service all need to be called from the same evaluation harness, and each expects a different method name, input shape and output convention.
The adapter wraps each inconsistent API behind a uniform interface. Composition is the tool: hold the wrapped object, expose the methods your pipeline expects, and translate. Protocol classes (typing.Protocol) let you declare that interface structurally, so adapters do not need to inherit from anything. The caveat is indirection: when a stack trace passes through three adapters before reaching the real error, debugging gets slower. Keep adapters thin and let exceptions propagate with context rather than swallowing them.
Factory: when construction logic is scattered everywhere
The pressure: every notebook and script builds its model with slightly different hyperparameters, and you cannot tell from a results table which configuration actually ran.
A factory centralizes construction. In Python this is usually a function returning a fully configured object:
# assumes: DEFAULTS and MODEL_REGISTRY are defined in this module
def build_model(name: str, **overrides):
config = {**DEFAULTS[name], **overrides}
return MODEL_REGISTRY[name](**config)
The same shape works for datasets and experiment configs. The caveat: factories attract responsibility. Add dataset loading, logging and path resolution and you have a god object with a friendly name. Scope each factory to one kind of construction.
Observer: when logging is wired into the training loop
The pressure: your training loop writes to a CSV, pings a metrics service and saves checkpoints, all inline. Adding one more side effect means editing the loop again.
The observer pattern decouples the subject from its listeners. A callback list is the Python-native form:
class Trainer:
def __init__(self):
self.callbacks = []
def on_epoch_end(self, metrics):
for cb in self.callbacks:
cb(metrics)
This is the callback model Keras popularized, and it is worth copying. The caveat: observers hide control flow. When a callback mutates state or raises, the failure surfaces far from its cause. Log callback registration and keep callbacks free of side effects on the model itself.
Command: when an experiment step must be repeatable or reversible
The pressure: a pipeline step exists only as a closure inside a script, so you cannot serialize it, reorder it, or rerun it in isolation.
Encapsulate each step as an object with an execute method. A dataclass carrying its parameters is usually enough:
# assumes: from dataclasses import dataclass
@dataclass
class Normalize:
method: str = "zscore"
def execute(self, df):
...
Because the object holds its configuration, it can be logged, stored alongside results and replayed, which is exactly what reproducibility asks for. The caveat: for a one-off script, wrapping every line in a command object is pure overhead. Reserve it for steps that recur across experiments.
Proxy: when loading everything upfront is wasteful
The pressure: a dataset or model checkpoint is several gigabytes, and most runs touch a fraction of it.
A proxy stands in for the real object and loads it on first access. Python’s __getattr__ makes this compact: intercept attribute access, load the underlying resource, then delegate. Proxies also handle access control, such as read-only views of a shared dataset. The caveat: proxies mask performance. A lazy load that fires inside a tight loop turns a fast script into a slow one, and nothing in the call site hints at it. Instrument first access and log it. If data volume itself is the bottleneck, techniques like those in streaming JSON parsing that cut memory use by 95% address the problem at a different layer, and the two approaches compose.
Decorator: when the same cross-cutting concern repeats
The pressure: caching, timing and input validation appear in a dozen data functions, copy-pasted and drifting.
Decorators wrap a function without changing it, and functools.wraps preserves the name and docstring so tooling still works:
# assumes: import functools, time, logging; log = logging.getLogger(__name__)
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
log.info("%s took %.3fs", fn.__name__, time.perf_counter() - start)
return result
return wrapper
The caveat: stacked decorators distort stack traces and make it harder to see which layer raised. Limit stacks to two or three and keep each decorator single-purpose.
When a pattern earns its place
Before reaching for any of these, run a short checklist. Does the pressure recur, or is this the first time? Will a second person read this code within a month? Does the pattern remove a branch, a copy-paste or an edit to a stable file? If the answers are no, the plain version is better. Patterns are a vocabulary for communication first and a structural tool second. The moment one is added to signal sophistication rather than to relieve a named pressure, it has become the problem it was meant to solve.
For more on this, see build agent python openai.