7 Advanced Python Design Patterns for Data Scientists
Learn seven design patterns for data science code, from Strategy and Adapter to Command and Proxy, each matched to the pressure it solves, with the Python shape it takes and the caveat that keeps it honest.

Most training functions start out small and readable. Give one six months in a shared repository, though, and it accumulates model-specific conditionals, data-source branches, a hand-rolled cache check and logging calls to three destinations. No single line is wrong. The function has simply absorbed another branch every time the project changed, and now every caller inherits the whole pile.
KDnuggets has already covered Python patterns for intermediate data scientists, where pipeline, factory and decorator handle the everyday cases. This article starts where that guide stops: seven patterns, each matched to one pressure that shows up once a notebook becomes code other people depend on.
One rule of restraint applies to everything below. If nothing else will ever call the code, a pattern is ceremony, and a plain function remains the better tool. Patterns pay for themselves only when they remove a source of change that keeps coming back.
| Pattern | Recurring pressure | Python shape | Main caveat |
|---|---|---|---|
| Strategy | A branch per metric or validation policy | evaluate(y_true, y_pred, scorer) takes a callable |
Stateless behavior should stay a plain function, not a class |
| Adapter | Model APIs and output shapes that differ by library | One local Protocol plus a thin wrapper that reshapes outputs |
Translates the interface only; semantics still pass through |
| Template Method | Sibling experiments copying the same run order | ABC owns run(); subclasses fill selected hooks |
Inheritance couples variants; prefer Strategy for free mixing |
| Observer | Training loop hard-wired to loggers and dashboards | Frozen event dataclass sent to a list of callbacks | Order, errors and cost must stay explicit as callbacks grow |
| Command | Runs that must queue, retry or travel | Frozen dataclass with IDs and execute(runner) |
Seeds, data and code versions still need explicit identifiers |
| Facade | Five imports and a setup dance for one task | One task-level method that delegates to components | Keep it thin or it becomes the new monolith |
| Proxy | Expensive model or dataset loaded before it is needed | Same predict() contract; _load() runs once on first use |
Invalidation, thread safety and load failure need decisions |
1. Swap Evaluation Logic With Strategy
An experiment runner that picks scoring with if metric == "accuracy" grows a new branch for every metric the team adopts. Strategy removes the branching by making the scoring behavior a value the caller passes in:
def evaluate(y_true, y_pred, scorer):
return scorer(y_true, y_pred)
evaluate(y_true, y_pred, accuracy) # 0.6
evaluate(y_true, y_pred, recall_pos) # 0.667
Both calls run against the same five predictions, but the runner never learns which policy it executed, and that ignorance is the payoff. Each scorer can now be tested on its own, and adding a new one means writing a function rather than editing the experiment. Strategy also gets confused with Command often enough that the line is worth drawing here. Strategy gives the caller interchangeable ways of doing the same job right now, while Command, further down this list, packages one operation with its arguments so the work can run later or somewhere else.
The caveat comes from Python itself. A strategy with no state should stay a plain callable like the scorers above, because a class only earns its place once configuration or related methods need to travel with the behavior.
2. Normalize Model APIs With Adapter
Evaluation code that checks which library produced a model has already leaked an interface problem downstream. The fix is one local contract, written as a typing.Protocol, plus a thin wrapper around anything that fails to match it:
class Predictor(Protocol):
def predict_proba(self, X) -> list: ...
class LegacyAdapter:
def __init__(self, model):
self._model = model
def predict_proba(self, X):
scores = self._model.score_rows(X)
return [[1 - s, s] for s in scores]
The legacy model exposes score_rows() and returns bare scores, so the adapter reshapes them into the two-column probability format everything downstream expects. Once that wrapper exists, switching libraries no longer means rewriting the evaluation layer. Protocol carries a warning of its own here. Its checking is static, and decorating it with @runtime_checkable only makes isinstance() test for attribute presence, meaning a class whose predict_proba takes the wrong arguments still passes the check.
The bigger danger sits one level deeper, at the semantics. An adapter translates an interface and nothing more, so differences in class order, decision thresholds or calibrated versus uncalibrated probabilities pass straight through it. An adapter that papers over those is a bug generator wearing a compatibility costume.
3. Fix the Experiment Skeleton With Template Method
Closely related experiments tend to copy their orchestration from each other, and the copies drift. Template Method fixes the lifecycle once, in a base class built on abc, and lets subclasses fill in the steps that legitimately vary:
class Experiment(ABC):
def run(self):
self.load()
self.fit()
self.evaluate()
self.save()
@abstractmethod
def fit(self): ...
A tree-model subclass implements fit() and inherits everything else, so running it executes load, fit, evaluate and save in that order, every time, for every variant. Nobody overrides run(), because the stable skeleton is the entire point. The orchestration that used to be copied between sibling experiments now lives in exactly one place, and consistent cleanup and reporting come along for free.
What the pattern gains through inheritance it also pays for through inheritance, though. Every variant stays coupled to the base lifecycle. The moment steps need to change independently or combine freely, they belong in Strategy objects composed at runtime rather than in a deeper subclass tree.
4. Broadcast Run Events With Observer
A training loop that calls the experiment tracker, the console, the dashboard and the early-stopping check by name has four reasons to change that have nothing to do with training. Observer flips the dependency, so the loop emits one immutable event and a list of callbacks decides what happens next:
@dataclass(frozen=True)
class EpochEnded:
epoch: int
loss: float
for cb in observers:
cb(EpochEnded(epoch, loss))
Two observers with separate jobs show why this works. A console logger formats each event into a line, while a best-score tracker keeps the lowest loss it has seen, and neither knows the other exists. Adding a dashboard writer later just means appending one callable to the list, with the training loop untouched. Freezing the event matters more than it looks, too, since any observer that tries to mutate it gets a FrozenInstanceError instead of silently corrupting what the next observer receives.
The trouble with Observer arrives at scale. A visible list of three callbacks is easy to reason about, but event order, error handling and per-event cost all need explicit answers before this grows into an invisible event bus nobody can trace.
5. Package Reproducible Runs as Commands
Some runs should not execute the moment somebody thinks of them. A sweep might need to wait for overnight capacity, and a run that produced an interesting result last week might deserve an exact repeat today. Command turns the request itself into an object built from a frozen dataclass:
@dataclass(frozen=True)
class TrainModel:
dataset_id: str
model_name: str
config_id: str
def execute(self, runner):
return runner(self)
The object serializes cleanly through asdict(), so it can be logged, queued, retried or shipped to a worker on another machine. The frozen flag, meanwhile, guarantees the request that executes is the request that was recorded. What should run stops depending on when and where it runs, which is the real point. Just don't let the word "reproducible" do more work than it has earned. A Command captures a request, not an environment. Dataset versions, code versions, random seeds and platform assumptions still need explicit identifiers inside the object, or the replay will produce a different model under the same name.
6. Hide Subsystem Noise Behind a Facade
When scoring one configuration in a notebook requires five imports and a setup dance, every consumer of the pipeline is paying an integration tax. Facade collects the subsystem behind one task-level method:
class ExperimentFacade:
def train_and_report(self, config):
cleaned = self._preprocessor.clean(config)
model = self._trainer.fit(cleaned)
return self._reporter.report(model)
The facade performs none of the real work; it delegates to focused components and only decides the order, which keeps it three lines long and leaves the components testable on their own. It is also the mirror image of the Adapter from earlier. An adapter makes one incompatible object fit a local interface, while a facade simplifies access to a whole subsystem that already works.
The caller gets a stable entry point out of the deal, and the internals stay free to change behind it. The risk is appetite. A facade that starts owning validation rules, retry policy and state stops being a doorway and becomes the new monolith, so split it while it is still thin.
7. Control Expensive Access With Proxy
A three-gigabyte model loaded at import time makes every notebook restart and every service deploy pay for a resource that might never be used. Proxy defers the cost while preserving the contract consumers already code against:
class LazyModelProxy:
def __init__(self):
self._model = None
def predict(self, X):
if self._model is None:
self._model = RealModel()
return self._model.predict(X)
The real model loads on the first predict() and never again, while consumers keep calling the same method they always did, unaware a boundary exists. The same shape guards an expensive dataset read just as well.
What the pattern will not do is make the operational decisions for you. Cache invalidation, thread and process safety, and behavior when the load fails all need answers, written down, before the proxy ships. A proxy that silently serves a stale artifact is strictly worse than an explicit slow load, because the slow load at least tells the truth.

Naming the Pressure Before Naming the Pattern
Three questions separate a useful pattern from an impressive class diagram. What changes repeatedly? What contract has to stay stable while it changes? And what new failure mode does the abstraction itself introduce? Answer those and the choice mostly makes itself. Behavior that keeps varying points to Strategy, a mismatched interface to Adapter, and a fixed lifecycle with a few movable steps to Template Method. Multiplying listeners want Observer, while work that has to travel belongs in a Command, a noisy subsystem behind a Facade, and an expensive resource behind a Proxy.
All of these are code-level boundaries, and the discipline around them lives at the project level, where the step-by-step guide to structuring a data science project is the natural next read. Once the pressures move past code shape into feature stores, training-serving skew and model cascades, design patterns for MLOps picks up where object design stops.
The measure of success never changes, though. A pattern has earned its place when the caller got simpler and the boundary got easier to test. The class diagram is nobody's deliverable.
Nahla Davies is a software developer and tech writer. Before devoting her work full time to technical writing, she managed—among other intriguing things—to serve as a lead programmer at an Inc. 5,000 experiential branding organization whose clients include Samsung, Time Warner, Netflix, and Sony.