← Back to list

The Interface Segregation Principle for Python Protocols

Small protocols compose. Large protocols constrain. Here is how to tell the difference.

Ricardo García Ramírez in Python in Plain English · 2026-07-06 06:44 · 1 claps · 7.3 min read
#isp #python #clean-code #best-practices #protocol
Open on Medium ↗

The Interface Segregation Principle for Python Protocols

Small protocols compose. Large protocols constrain. Here is how to tell the difference.

The Protocol That Grew Too Wide

You define a DataSource protocol with six methods: load, save, validate, schema, preview, and count. It looks clean. One concept, one interface.

Then you audit the callers. Your training pipeline calls only load. Your validation module calls only validate and schema. Your reporting code calls only preview and count. No single caller uses more than two of the six methods, but every caller depends on all six.

The problem surfaces when you write a test double for the training pipeline. You must implement all six methods or your static type checker complains. When a new contributor adds a CSV reader with no save capability, they either add a save that raises NotImplementedError or they abandon protocols and fall back to Any. The interface you designed to enforce good contracts became a source of useless coupling.

This is an Interface Segregation Principle violation, and Python’s Protocol class makes it both easy to create and straightforward to fix.

I’ve already talked about this when I focused on SOLID principles early this year, but since I just had an interview call this week and they asked so much about my main issues with SOLID implementation in my day-to-day, I decided to revamp this one with a more DS and ETL focus.

By the end of this article, you will have replaced a fat protocol with a set of narrow, composable protocols; verified the design with mypy, and confirmed that your implementation class satisfies all of them without any inheritance.

What ISP Actually Says

You can re-read the whole article where I talk about this, but in a nutshell, clients should not be forced to depend on methods they do not use.

In Python, typing.Protocol (we have it since Python 3.8) is structural typing. A class satisfies a protocol if it has the required attributes and methods. No inheritance or registration is needed. So a protocol is not a description of a class. It is a description of what a specific caller needs. If a caller needs one method, the protocol should declare one method.

The Fat Protocol in Practice

Here is the problem made concrete. Assume you have a data ingestion system where different components consume the same source object.

from typing import Protocol
import pandas as pd

class DataSource(Protocol):
    def load(self) -> pd.DataFrame: ...
    def save(self, df: pd.DataFrame) -> None: ...
    def validate(self, df: pd.DataFrame) -> bool: ...
    def schema(self) -> dict[str, str]: ...
    def preview(self, n: int = 5) -> pd.DataFrame: ...
    def count(self) -> int: ...

def train_model(source: DataSource) -> None:
    _df = source.load()
    # source.save, source.validate, source.schema,
    # source.preview, source.count are never called here

def check_schema(source: DataSource, df: pd.DataFrame) -> bool:
    _schema = source.schema()
    return source.validate(df)

def generate_report(source: DataSource) -> None:
    print(source.preview())
    print(f"Total rows: {source.count()}")

Every function takes DataSource. Every function depends on six methods. None of them uses more than two.

The dependency is not just conceptual. It is enforced by the type checker. Any object passed to train_model must satisfy the full six-method contract, even though train_model will never touch five of them.

Split the Protocol Around the Caller

The fix is to define each protocol based on what a single caller requires, not on what an existing class provides.

from typing import Protocol
import pandas as pd

class Loadable(Protocol):
    def load(self) -> pd.DataFrame: ...

class Saveable(Protocol):
    def save(self, df: pd.DataFrame) -> None: ...

class Validatable(Protocol):
    def validate(self, df: pd.DataFrame) -> bool: ...
    def schema(self) -> dict[str, str]: ...

class Previewable(Protocol):
    def preview(self, n: int = 5) -> pd.DataFrame: ...
    def count(self) -> int: ...

def train_model(source: Loadable) -> None:
    _df = source.load()

def check_schema(source: Validatable, df: pd.DataFrame) -> bool:
    _schema = source.schema()
    return source.validate(df)

def generate_report(source: Previewable) -> None:
    print(source.preview())
    print(f"Total rows: {source.count()}")

Each function now declares exactly what it needs. A function that calls load depends on Loadable. Nothing else.

Classes Satisfy Multiple Narrow Protocols for Free

Splitting protocols does not require splitting classes. A single implementation class can satisfy all four protocols without inheriting from any of them.

class InMemoryDataSource:
    def __init__(self, data: pd.DataFrame, required_columns: list[str]) -> None:
        self._data = data
        self.required_columns = required_columns

    def load(self) -> pd.DataFrame:
        return self._data.copy()

    def save(self, df: pd.DataFrame) -> None:
        self._data = df.copy()

    def validate(self, df: pd.DataFrame) -> bool:
        return all(col in df.columns for col in self.required_columns)

    def schema(self) -> dict[str, str]:
        return {col: str(dtype) for col, dtype in self._data.dtypes.items()}

    def preview(self, n: int = 5) -> pd.DataFrame:
        return self._data.head(n)

    def count(self) -> int:
        return len(self._data)

sample = pd.DataFrame({"id": [1, 2, 3], "value": [10, 20, 30]})
source = InMemoryDataSource(sample, required_columns=["id", "value"])

train_model(source)                          # OK: satisfies Loadable
print(check_schema(source, source.load()))   # OK: satisfies Validatable
generate_report(source)                      # OK: satisfies Previewable
True
   id  value
0   1     10
1   2     20
2   3     30
Total rows: 3

mypy accepts all three calls without any # type: ignore comments. Python’s structural typing checks the presence of methods, not the inheritance chain. The class implements the methods. The protocols describe them. The type checker connects the two at call sites.

Verification with mypy

The claim is that a class implementing only load satisfies Loadable but not the fat DataSource. Here is the proof.

# minimal_check.py
from typing import Protocol
import pandas as pd

class Loadable(Protocol):
    def load(self) -> pd.DataFrame: ...

class DataSource(Protocol):
    def load(self) -> pd.DataFrame: ...
    def save(self, df: pd.DataFrame) -> None: ...
    def validate(self, df: pd.DataFrame) -> bool: ...
    def schema(self) -> dict[str, str]: ...
    def preview(self, n: int = 5) -> pd.DataFrame: ...
    def count(self) -> int: ...

class MinimalLoader:
    def load(self) -> pd.DataFrame:
        return pd.DataFrame({"id": [1], "value": [10]})

def train_model_narrow(source: Loadable) -> None:
    _df = source.load()

def train_model_fat(source: DataSource) -> None:
    _df = source.load()

train_model_narrow(MinimalLoader())   # accepted
train_model_fat(MinimalLoader())      # rejected: missing 5 methods
$ mypy --strict minimal_check.py
minimal_check.py:32: error: Argument 1 to "train_model_fat" has incompatible type "MinimalLoader"; expected "DataSource"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

The narrow protocol accepts MinimalLoader without modification. The fat protocol rejects it because five methods are absent, even though train_model_fat never calls them.

Test Doubles Get Simpler

The ISP violation in protocols is usually invisible until you write a test. That is when the coupling becomes concrete.

# BEFORE: test double for train_model must satisfy all six methods
class FakeSource:
    def load(self) -> pd.DataFrame:
        return pd.DataFrame({"id": [1], "value": [42]})

    def save(self, df: pd.DataFrame) -> None: ...       # never called
    def validate(self, df: pd.DataFrame) -> bool: ...   # never called
    def schema(self) -> dict[str, str]: ...             # never called
    def preview(self, n: int = 5) -> pd.DataFrame: ...  # never called
    def count(self) -> int: ...                         # never called
# AFTER: test double for train_model needs exactly one method
class FakeLoadable:
    def load(self) -> pd.DataFrame:
        return pd.DataFrame({"id": [1], "value": [42]})

The reduction is not aesthetic. Fewer stub methods means fewer places for a type signature mismatch to silently pass. It also means a new contributor can read the test double and immediately understand what the function under test actually depends on.

Protocol Composition

Narrow protocols can be combined when a single type needs to express the full capability. With the four narrow protocols already defined, you can compose them at system boundaries such as factory functions or plugin registries.

class FullDataSource(Loadable, Saveable, Validatable, Previewable, Protocol):
    pass

FullDataSource composes the four narrow protocols via multiple inheritance. Any class that satisfies all four narrow protocols automatically satisfies FullDataSource. Use this where the complete contract matters. For individual callers, keep using the narrow protocols.

Gotchas and Tradeoffs

runtime_checkable checks names, not signatures. If you decorate a protocol with @runtime_checkable, isinstance(obj, Loadable) will return True if obj has a load attribute. It will not verify that load accepts no arguments and returns a DataFrame. The following standalone example illustrates why this matters.

from typing import Protocol, runtime_checkable
import pandas as pd

@runtime_checkable
class Loadable(Protocol):
    def load(self) -> pd.DataFrame: ...

class Broken:
    def load(self, extra_arg: str) -> str:   # wrong signature
        return "not a dataframe"

print(isinstance(Broken(), Loadable))  # True — runtime check is shallow

Do not use isinstance checks with protocols as a substitute for type annotations. Use them only for narrowing at runtime boundaries where you cannot control the input type.

Do not split if every caller uses all methods. If you have one caller that uses validate, schema, preview, and count together in a single function, splitting them into four protocols adds indirection without removing coupling. The heuristic is: one caller, one protocol. When a caller uses three methods together on every call path, those three methods belong in one protocol.

Protocol inheritance in mypy requires Protocol in every base. When composing protocols, every class in the chain must explicitly inherit from Protocol. Omitting it causes mypy to treat the class as a concrete type, not a structural interface, and structural matching breaks.

# Wrong: Validatable is not recognized as a Protocol by mypy
class Validatable:
    def validate(self, df: pd.DataFrame) -> bool: ...
    def schema(self) -> dict[str, str]: ...

# Correct
class Validatable(Protocol):
    def validate(self, df: pd.DataFrame) -> bool: ...
    def schema(self) -> dict[str, str]: ...

TypeVar bounds interact with narrow protocols. If you write T = TypeVar("T", bound=Loadable), a function parameterized on T can only use load. If the caller later needs save as well, they need a second TypeVar or a composed protocol. Plan the TypeVar bounds around the narrowest protocol the generic function actually uses.

Takeaways

Most protocol designs start from the class and ask “what should this expose?” The ISP asks the opposite question: “what does each caller actually need?” The answer is usually less than you think.

Look at the largest protocol in your current codebase. Count how many methods a typical caller uses. If the answer is fewer than half the protocol’s methods, you have a candidate for splitting.

What would your test doubles look like if every function depended on exactly the methods it called?

If you found this post helpful, don’t forget to 👏 clap to show your support!

I’d also love to hear your thoughts and insights on the techniques covered in this article. 💡 Feel free to share your experiences in the comments 💬.

Connect with Me on LinkedIn!

You can also connect with me on LinkedIn for updates on my latest posts and projects. 🌐 Let’s keep the conversation going!

Thousands of developers share what they’re building, learning, and discovering across our publications every month. One account connects you to our entire network of publications and communities. Explore more at plainenglish.io.


메타데이터
post_id
8090dfcbcbc0
slug
the-interface-segregation-principle-for-python-protocols-8090dfcbcbc0
url
https://python.plainenglish.io/the-interface-segregation-principle-for-python-protocols-8090dfcbcbc0
canonical_url
https://python.plainenglish.io/the-interface-segregation-principle-for-python-protocols-8090dfcbcbc0
author_url
https://medium.com/@ricardogr07
status
ok
fetched_at
2026-07-08 21:34:33