Back to Blog
Python

Python Abstract Class vs Protocol: When to Use Each

python abstract class vs protocol: Compare Python abstract classes and typing.Protocol to decide when each fits your design, with syntax examples and tradeoffs.

abstract classprotocolstructural subtypingtype hintsPython OOP
A visual comparison of Python abstract class inheritance and protocol structural typing.

When designing a Python API, the choice between an abstract base class (ABC) and a typing.Protocol often comes down to how you expect consumers to use your types. The difference matters at runtime and for static type checkers. This article compares python abstract class vs protocol, showing the syntax, runtime behavior, and the conditions that should drive your decision.

What an Abstract Base Class Provides

An abstract base class is a class that cannot be instantiated directly. It defines a contract for subclasses through abstract methods. Subclasses must override those methods before they can be instantiated. ABCs are part of the abc module and use the @abstractmethod decorator.

from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: """Return the area of the shape.""" def describe(self) -> str: return f"Shape with area {self.area():.2f}"

Any subclass of Shape must implement area. The concrete method describe is inherited and can use the abstract method. This is a classic nominal typing approach: a class is a Shape only if it explicitly inherits from it.

At runtime, isinstance(obj, Shape) returns True only for instances of subclasses. This check is fast because it walks the MRO. The inheritance relationship is explicit and static.

What a Protocol Provides

A protocol defines a set of methods and attributes that a class must have to be considered compatible, without requiring inheritance. This is structural subtyping. Protocols are defined using typing.Protocol and are primarily used by static type checkers like mypy.

from typing import Protocol class HasArea(Protocol): def area(self) -> float: ...

Any class with an area method that returns a float satisfies this protocol, even if it does not inherit from HasArea. For example:

class Rectangle: def __init__(self, width: float, height: float) -> None: self.width = width self.height = height def area(self) -> float: return self.width * self.height

Rectangle is structurally compatible with HasArea. A function that accepts HasArea will accept a Rectangle without any explicit relationship.

To use protocols at runtime with isinstance, you must decorate the protocol with @runtime_checkable. Even then, the check only verifies that the required methods and attributes exist on the object; it does not validate signatures or return types.

from typing import Protocol, runtime_checkable @runtime_checkable class HasArea(Protocol): def area(self) -> float: ... print(isinstance(Rectangle(2, 3), HasArea)) # True

Key Differences in Runtime and Type Checking

The most significant difference is the typing philosophy. ABCs use nominal typing: compatibility is based on explicit inheritance. Protocols use structural typing: compatibility is based on the presence of the required members.

AspectAbstract Base ClassProtocol
Inheritance requiredYesNo
Runtime isinstanceAlways worksOnly with @runtime_checkable
Signature validationNot enforced at runtimeNot enforced at runtime
Static type checkingWorks with mypyWorks with mypy
Use caseShared implementation, common baseDuck typing, external code

At runtime, isinstance with an ABC is a simple MRO lookup. With a runtime-checkable protocol, Python must inspect the object's attributes, which is slower. For hot paths, this difference can matter, but in most applications the cost is negligible.

Static type checkers treat both approaches similarly. Mypy will enforce that a class passed to a function expecting a HasArea actually has an area method. The difference is that with a protocol, the class does not need to know about the protocol at all.

When to Use an Abstract Base Class

Use an ABC when you control the class hierarchy and want to provide a common implementation. ABCs are ideal for frameworks where subclasses should share behavior. For example, a plugin system where every plugin must implement run and also inherits a start helper.

class Plugin(ABC): @abstractmethod def run(self, context: dict) -> None: ... def start(self) -> None: print("Starting plugin") self.run({})

Subclasses get the start method for free. This is not possible with a protocol, because protocols do not provide implementation. If you need default behavior, an ABC is the right choice.

ABCs also work well when you want to enforce a specific class hierarchy for documentation and tooling. The inheritance relationship is explicit and discoverable.

When to Use a Protocol

Use a protocol when you want to accept any object that satisfies an interface, especially when you do not control the classes. This is common in libraries that operate on user-defined types. For example, a function that serializes any object with a to_dict method.

class Serializable(Protocol): def to_dict(self) -> dict: ... def save(obj: Serializable) -> None: data = obj.to_dict() # write to file

Any class that has to_dict can be passed to save, even if it was defined in another module and has no relationship to your code. This promotes loose coupling and makes your library more flexible.

Protocols are also useful when you want to avoid forcing users to inherit from your base class. Inheritance can be a burden, especially if the class already has a different base. Protocols allow you to define an interface without imposing a hierarchy.

Combining ABC and Protocol

You can use both together. A class can inherit from an ABC and also satisfy a protocol. This is common when you want to provide a default implementation via the ABC while still allowing external classes to be used through the protocol.

class BaseShape(ABC): @abstractmethod def area(self) -> float: ... def describe(self) -> str: return f"Area: {self.area():.2f}" class HasArea(Protocol): def area(self) -> float: ... class Circle(BaseShape): def __init__(self, radius: float) -> None: self.radius = radius def area(self) -> float: return 3.14159 * self.radius ** 2

Circle is a BaseShape and also satisfies HasArea. External classes that do not inherit from BaseShape can still be used where HasArea is expected. This gives you the best of both: shared implementation for your own classes and structural compatibility for others.

Performance and Maintainability Considerations

The runtime cost of isinstance checks differs. ABC checks are O(1) MRO lookups. Runtime-checkable protocol checks require attribute lookups on the instance, which can be slower. If you call isinstance in a tight loop, an ABC is more efficient. However, the difference is usually negligible compared to the actual work performed.

Maintainability is where the choice has the biggest impact. ABCs create a strong coupling between the base class and its subclasses. Changing the ABC can break all subclasses. Protocols, on the other hand, allow you to change the interface without affecting implementations, as long as the required methods remain present. This makes protocols more suitable for public APIs where you cannot predict all consumers.

However, protocols can be harder to debug because the relationship is implicit. A class that accidentally has a method with the same name but a different signature will still pass a runtime-checkable protocol check. Static type checkers catch this, but only if you run them.

Common Pitfalls and Edge Cases

A common mistake is relying on @runtime_checkable to validate method signatures. It does not. The check only verifies that the attribute exists. For example:

@runtime_checkable class HasArea(Protocol): def area(self) -> float: ... class BadShape: def area(self, scale: float) -> float: return 0.0 print(isinstance(BadShape(), HasArea)) # True

BadShape passes the check even though area takes an extra argument. Static type checkers would flag this if the protocol is used in a typed context, but at runtime it slips through.

Another edge case is that protocols cannot have concrete implementations that are inherited. If you define a method body in a protocol, it is ignored for structural compatibility. Only the signature matters.

Finally, remember that ABCs are classes and can hold state. Protocols are not meant to be instantiated. They exist purely for type checking and optional runtime checks.

When you need to enforce a contract with shared behavior, an ABC is the right tool. When you need to accept any object that looks right, a protocol gives you flexibility without inheritance. Understanding the distinction between python abstract class vs protocol will help you design APIs that are both safe and adaptable.

python abstract class vs protocol: Practical Usage and Code | RYUSLOG DEV