5 Python Design Patterns

5 Python Design Patterns

Most data engineers meet design patterns in a CS course and never connect them to the actual job. But the messes that slow a data team down - the same connection code written three times, an 800-line transform full of nested conditionals, a new source that means copy-pasting from five files - are pattern problems, not skill problems. Here are five patterns that map directly onto data work, each with the problem it solves, a tight example, and an honest note on when not to reach for it.

1. Factory Method - flexible source connectors

Your pipeline reads from Postgres today, MongoDB tomorrow, S3 next week, and the orchestration code fills up with if source_type == ... branches. A factory moves that decision to one place behind a common interface, so the rest of the code just asks for a connector and uses it.

from abc import ABC, abstractmethod

class DataConnector(ABC):
    @abstractmethod
    def read(self, query: str): ...

class PostgresConnector(DataConnector):
    def read(self, query): ...   # returns a DataFrame

class S3Connector(DataConnector):
    def read(self, key): ...

def make_connector(cfg: dict) -> DataConnector:
    return {"postgres": PostgresConnector,
            "s3": S3Connector}[cfg["type"]](**cfg["params"])

Use it when you support several interchangeable sources. Skip it when there's one source and likely always will be - the indirection is just overhead.

2. Builder - constructing complex transformations

A transformation that takes ten optional parameters becomes an unreadable constructor call, and order starts to matter in ways nobody documents. A builder assembles the object step by step with named, chainable calls.

class PipelineBuilder:
    def __init__(self): self._steps = []
    def filter(self, cond): self._steps.append(("filter", cond)); return self
    def dedupe(self, keys): self._steps.append(("dedupe", keys)); return self
    def build(self): return Pipeline(self._steps)

pipeline = (PipelineBuilder()
            .filter("amount > 0")
            .dedupe(["id"])
            .build())

Use it for objects with many optional parts. Skip it for two or three arguments - a plain function is clearer.

3. Decorator - observability without touching core logic

You want timing, logging, and retries around your transform functions without burying the business logic in instrumentation. A decorator wraps behavior around a function, leaving its body untouched.

import time, functools, logging

def timed(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        logging.info("%s took %.2fs", fn.__name__, time.perf_counter() - start)
        return result
    return wrapper

@timed
def transform(df): ...

Use it for cross-cutting concerns - timing, logging, retries, caching. Skip it when the wrapper hides control flow a reader needs to see.

4. Adapter - wrapping legacy systems and external APIs

A third-party API or a legacy system speaks a shape your pipeline doesn't. Rather than litter your code with its quirks, an adapter translates it to the interface the rest of your code already expects.

class LegacyExportClient:
    def fetch_xml(self): ...   # ugly, not yours

class LegacySourceAdapter(DataConnector):
    def __init__(self, client): self._client = client
    def read(self, _query=None):
        return xml_to_dataframe(self._client.fetch_xml())

Use it to isolate code you don't control behind a clean boundary. Skip it when you can call the API directly without the impedance mismatch.

5. Abstract Factory - families of related objects

When a whole set of objects must vary together - say a connector, a writer, and a schema validator that all have to match "Snowflake" or all match "BigQuery" - an abstract factory produces a consistent family so you can't accidentally mix a Snowflake reader with a BigQuery writer.

class WarehouseFactory(ABC):
    @abstractmethod
    def reader(self) -> DataConnector: ...
    @abstractmethod
    def writer(self): ...

class SnowflakeFactory(WarehouseFactory):
    def reader(self): return SnowflakeConnector()
    def writer(self): return SnowflakeWriter()

Use it when objects come in matched sets that must stay consistent. Skip it if your objects vary independently - it adds structure you won't use.

The point isn't the patterns

Patterns are a vocabulary, not a goal. The reason to know them is that the next time you feel a pipeline getting brittle, you recognize the shape - "this is a factory problem" - and reach for a known fix instead of another nested conditional. Use them where they remove duplication and clarify intent; resist them where they add ceremony to code that was already simple. The best pattern is often the one you didn't need.

Leave a Comment