Skip to content
Codeloom
Python

Python ABCs vs Protocols: Choosing the Right Abstraction

Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.

·6 min read · By Codeloom
Advanced 12 min read

What you'll learn

  • How Abstract Base Classes enforce interface contracts at instantiation
  • How Protocols enable structural (duck) typing with static analysis
  • Runtime checkability and registration with ABCs
  • Deciding between ABCs and Protocols for your architecture

Prerequisites

None — this post is self-contained.

Python gives you two ways to define interfaces: Abstract Base Classes (ABCs) from the abc module, and Protocols from typing. ABCs use nominal typing — a class must explicitly inherit from the ABC. Protocols use structural typing — any class with the right methods satisfies the protocol, no inheritance required. Both are valuable, and choosing the right one depends on whether you want enforcement at instantiation time or flexibility at type-checking time.

Abstract Base Classes

An ABC defines a contract. If a subclass does not implement all abstract methods, Python raises TypeError when you try to instantiate it.

from abc import ABC, abstractmethod

class Repository(ABC):
    @abstractmethod
    def get(self, id: str) -> dict:
        ...

    @abstractmethod
    def save(self, entity: dict) -> None:
        ...

    def exists(self, id: str) -> bool:
        """Concrete method -- subclasses inherit this."""
        try:
            self.get(id)
            return True
        except KeyError:
            return False

class InMemoryRepository(Repository):
    def __init__(self):
        self._store: dict[str, dict] = {}

    def get(self, id: str) -> dict:
        if id not in self._store:
            raise KeyError(f"Not found: {id}")
        return self._store[id]

    def save(self, entity: dict) -> None:
        self._store[entity["id"]] = entity

If you forget to implement save:

class BrokenRepository(Repository):
    def get(self, id: str) -> dict:
        return {}

repo = BrokenRepository()
# TypeError: Can't instantiate abstract class BrokenRepository
#            with abstract method save

This is the key advantage: you get a clear, immediate error rather than a mysterious AttributeError later.

Abstract Properties

ABCs can also enforce properties:

from abc import ABC, abstractmethod

class Shape(ABC):
    @property
    @abstractmethod
    def area(self) -> float:
        ...

    @property
    @abstractmethod
    def perimeter(self) -> float:
        ...

class Circle(Shape):
    def __init__(self, radius: float):
        self._radius = radius

    @property
    def area(self) -> float:
        import math
        return math.pi * self._radius ** 2

    @property
    def perimeter(self) -> float:
        import math
        return 2 * math.pi * self._radius

Virtual Subclasses with register

You can register a class as a “virtual subclass” of an ABC without inheritance:

from abc import ABC, abstractmethod

class Drawable(ABC):
    @abstractmethod
    def draw(self) -> str:
        ...

class ThirdPartyWidget:
    """From an external library -- you cannot modify it."""
    def draw(self) -> str:
        return "Drawing widget"

Drawable.register(ThirdPartyWidget)

print(isinstance(ThirdPartyWidget(), Drawable))  # True

This does not enforce method implementation, so use it carefully.

Protocols — Structural Typing

A Protocol defines what methods and attributes a type must have, but does not require inheritance. If a class has the right shape, it satisfies the protocol.

from typing import Protocol

class Renderable(Protocol):
    def render(self) -> str:
        ...

class HtmlPage:
    def render(self) -> str:
        return "<html>...</html>"

class JsonResponse:
    def render(self) -> str:
        return '{"status": "ok"}'

def display(item: Renderable) -> None:
    print(item.render())

# Both work -- no inheritance needed
display(HtmlPage())
display(JsonResponse())

mypy verifies that any object passed to display has a render method returning str. The classes never mention Renderable.

Protocol with Attributes

Protocols can define required attributes:

from typing import Protocol

class HasName(Protocol):
    name: str

class User:
    def __init__(self, name: str):
        self.name = name

class Product:
    def __init__(self, name: str, price: float):
        self.name = name
        self.price = price

def greet(entity: HasName) -> str:
    return f"Hello, {entity.name}"

greet(User("Ada"))      # OK
greet(Product("Widget", 9.99))  # OK -- has 'name' attribute

Runtime-Checkable Protocols

By default, Protocols are only enforced by static type checkers. To enable isinstance checks at runtime, use @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None:
        ...

import io
stream = io.StringIO()
print(isinstance(stream, Closeable))  # True

print(isinstance("hello", Closeable))  # False

Note: runtime checks only verify method existence, not signatures. Static type checkers do the full signature check.

Head-to-Head Comparison

# ABC approach -- explicit inheritance required
from abc import ABC, abstractmethod

class Cache(ABC):
    @abstractmethod
    def get(self, key: str) -> object | None: ...

    @abstractmethod
    def set(self, key: str, value: object) -> None: ...

class RedisCache(Cache):  # Must inherit
    def get(self, key: str) -> object | None:
        ...
    def set(self, key: str, value: object) -> None:
        ...

# Protocol approach -- no inheritance needed
from typing import Protocol

class Cache(Protocol):
    def get(self, key: str) -> object | None: ...
    def set(self, key: str, value: object) -> None: ...

class RedisCache:  # No inheritance
    def get(self, key: str) -> object | None:
        ...
    def set(self, key: str, value: object) -> None:
        ...

When to Use Which

CriteriaABCProtocol
You own all implementationsGood fitGood fit
Third-party classes must conformAwkward (needs register)Natural fit
Want instantiation-time errorsYesNo (static only)
Want shared default methodsYesNo
Want to avoid couplingNo (requires import)Yes
Runtime isinstance checksBuilt-inOpt-in with runtime_checkable

Use ABCs When:

  • You are building a framework where subclasses must implement specific methods
  • You want shared concrete methods in the base class
  • You need guaranteed failure at instantiation, not at call time
  • You are defining an internal plugin system where you control all implementations

Use Protocols When:

  • You want to accept third-party objects that happen to have the right interface
  • You are writing library code that should not force users to inherit from your types
  • You want to keep modules decoupled (no import dependency on the interface)
  • You are adding type hints to existing duck-typed code

Combining Both

You can use both in the same project. A common pattern is ABCs for your internal hierarchy and Protocols for the public API:

from abc import ABC, abstractmethod
from typing import Protocol

# Public API -- any object with .execute() works
class Executable(Protocol):
    def execute(self, context: dict) -> dict: ...

# Internal base class with shared logic
class BaseTask(ABC):
    @abstractmethod
    def execute(self, context: dict) -> dict:
        ...

    def log(self, message: str) -> None:
        print(f"[{self.__class__.__name__}] {message}")

def run_pipeline(tasks: list[Executable]) -> None:
    """Accepts any Executable, whether it inherits BaseTask or not."""
    context = {}
    for task in tasks:
        context = task.execute(context)

Key Takeaways

ABCs enforce contracts at instantiation and let you share implementation. Protocols describe shape without requiring inheritance and keep modules decoupled. Neither is universally better. Use ABCs when you want strict enforcement in code you control. Use Protocols when you want flexibility and compatibility with code you do not control.