Back to Blog
Python

Python Protocol vs Inheritance: Which to Use

python protocol vs inheritance: Compare Python Protocol classes with inheritance for defining interfaces. Learn structural vs nominal typing, runtime checks, and when...

Protocol classesstructural typingtype hintsabstract base classesduck typing
Diagram comparing Python Protocol structural typing with inheritance-based nominal typing for interface contracts.

When you need to define a contract in Python, typing.Protocol and class inheritance are the two main tools. The choice between python protocol vs inheritance changes how your code behaves at runtime, how static type checkers treat it, and how easily unrelated classes can satisfy the same interface.

Protocols use structural subtyping: any class that has the required attributes and methods satisfies the protocol, whether or not it declares any relationship to it. Inheritance uses nominal subtyping: a class satisfies the contract only if it explicitly derives from the base class. That distinction drives most of the practical differences between the two approaches.

How Protocol Classes Define a Contract

A Protocol class is declared with typing.Protocol as its base, and its methods are usually left unimplemented:

from typing import Protocol class Serializable(Protocol): def to_dict(self) -> dict[str, object]: ...

Any class with a to_dict method that returns a dictionary satisfies Serializable, even if it never mentions the protocol. Static type checkers accept it, and isinstance(obj, Serializable) can also work when the protocol is decorated with @runtime_checkable.

Protocols are useful when you want to accept any object that behaves a certain way, regardless of its position in a class hierarchy. This is especially valuable for third-party classes you cannot modify, or for code where forcing a common base class would create awkward coupling.

How Inheritance Defines a Contract

Inheritance requires the implementing class to explicitly derive from the base. With an abstract base class, you can enforce that certain methods exist:

from abc import ABC, abstractmethod class Serializable(ABC): @abstractmethod def to_dict(self) -> dict[str, object]: ...

A subclass must implement to_dict before it can be instantiated. The relationship is explicit and visible in the class definition, which makes the contract discoverable through the class hierarchy.

Inheritance also carries implementation. A base class can provide shared state, concrete helper methods, and default behavior that subclasses reuse. Protocol classes, by contrast, are meant to describe structure rather than provide implementation.

The Runtime Difference: isinstance() and issubclass()

This is where the two approaches diverge most visibly at runtime.

A Protocol class decorated with @runtime_checkable supports isinstance() checks, but only for methods and attributes that are present on the class. It does not verify signatures, and it does not check that methods are implemented in a meaningful way:

from typing import Protocol, runtime_checkable @runtime_checkable class Serializable(Protocol): def to_dict(self) -> dict[str, object]: ... class Fake: def to_dict(self): # wrong signature, but check passes return 42 print(isinstance(Fake(), Serializable)) # True

The check only confirms that the attribute exists. It cannot validate the return type or the parameter list. Inheritance, on the other hand, gives you a reliable isinstance() check because the relationship is declared explicitly. An object either derives from the base class or it does not.

This matters in production code where runtime type checks guard behavior. With a Protocol, a false positive is possible if an unrelated class happens to define a method with the same name but a different contract. With inheritance, the check is exact.

Static Type Checking Behavior

Both mypy and pyright understand Protocol classes and treat them as structural types. When you annotate a function parameter with a Protocol, the checker accepts any argument that structurally matches, without requiring an explicit subclass relationship:

def serialize(obj: Serializable) -> dict[str, object]: return obj.to_dict() class User: def to_dict(self) -> dict[str, object]: return {"name": "Ada"} serialize(User()) # accepted

Inheritance is checked nominally. A function expecting Serializable accepts only objects that derive from it. This is stricter and often clearer, but it excludes classes that happen to have the right shape without declaring the relationship.

For large codebases where you control all the classes, inheritance gives stronger guarantees because the relationship is explicit. For libraries that accept user-provided objects, Protocols are more flexible because they do not force users to inherit from a library-specific base class.

Choosing Between Protocol and Inheritance

The decision depends on whether you control the classes that must satisfy the contract, and whether the contract carries implementation.

Use inheritance when:

  • The contract includes shared implementation, default behavior, or state that subclasses should inherit.
  • You control the full class hierarchy and can require an explicit base class.
  • You need reliable runtime isinstance() checks without the limitations of @runtime_checkable.
  • The relationship is conceptually an "is-a" relationship.

Use a Protocol when:

  • You need to accept objects from third-party code or from parts of the system that cannot share a base class.
  • The contract is purely structural, with no shared implementation.
  • You want to avoid forcing an artificial inheritance relationship just to satisfy a type checker.
  • You are building a plugin-style API where many unrelated implementations may exist.

A common middle ground is to define a Protocol for the public API and an abstract base class for the default implementation. Consumers can depend on the Protocol, while the library provides a base class that implements common behavior.

Combining Protocol with Inheritance

Protocols and inheritance are not mutually exclusive. A class can inherit from a base class and still satisfy a Protocol. More importantly, an abstract base class can also be declared as a Protocol, giving you both explicit inheritance and structural acceptance:

from abc import ABC, abstractmethod from typing import Protocol class Serializable(Protocol, ABC): @abstractmethod def to_dict(self) -> dict[str, object]: ...

Subclasses inherit the abstract method and must implement it. At the same time, any unrelated class with a matching to_dict method is accepted as a Serializable by static type checkers. This hybrid is useful when you want to provide a default base class while still allowing structural matches from outside the hierarchy.

Performance and Maintainability Considerations

The runtime cost of isinstance() against a @runtime_checkable Protocol is higher than against a regular class, because the check inspects the object's attributes rather than a single type lookup. For hot paths that perform many such checks, this can matter, though the difference is usually small compared to the work the check guards.

Maintainability is the larger concern. A Protocol that grows many methods becomes hard to satisfy structurally, especially if method names collide with unrelated behavior. Inheritance makes the contract explicit in the class declaration, which helps developers understand what a class promises. However, inheritance hierarchies that become deep or tangled are harder to refactor than a set of small Protocols.

A practical rule: prefer Protocols for narrow, stable contracts that many unrelated classes must satisfy, and prefer inheritance when the contract includes behavior that should be shared. When both conditions apply, combine the two as shown above.

python protocol vs inheritance: Practical Usage and Code Exa | RYUSLOG DEV