When to Use Protocol Instead of ABC or isinstance()?
I use Python as an automation tool for most of my daily tasks, from deploy scripts to monitoring alerts. One problem I frequently encounter is: code is written, tests pass, but when integrating a new component into the system, runtime errors appear — usually because a class is missing a method that the code expects.
Python is inherently a duck typing language: “if it can quack like a duck, it’s a duck.” Python doesn’t care what class an object belongs to — it only needs to have the required method. The problem is that this check only happens at runtime — when the code actually runs, not when it’s being written.
Abstract Base Class (ABC) addresses part of this, but requires explicit inheritance — which isn’t ideal when working with third-party libraries or when you want to keep your code loosely coupled.
Protocol (PEP 544, Python 3.8+) was introduced to solve exactly this problem: static duck typing — checking duck typing at development time with a type checker like mypy, rather than at runtime.
Setting Up the Environment
Protocol is available in the typing module from Python 3.8 onward. If you’re using Python 3.7 or earlier, install typing_extensions:
pip install typing_extensions mypy
Verify your Python and mypy versions:
python --version
# Python 3.8+ is sufficient, Protocol is available in stdlib
pip install mypy
mypy --version
mypy is the official static type checker for Python — use it to catch Protocol errors while you write code.
Defining and Using Protocol — In Depth
1. Basic Protocol
Instead of subclassing ABC and calling register(), you define a Protocol that describes the expected “shape” of an object:
from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str:
...
def from_json(self, data: str) -> None:
...
Method bodies in Protocol are typically .... This is an interface definition, not an implementation.
2. Using Protocol in Function Signatures
def save_to_file(obj: Serializable, filepath: str) -> None:
data = obj.to_json()
with open(filepath, 'w') as f:
f.write(data)
# This class does NOT inherit from Serializable,
# but has all the required methods — mypy still accepts it
class UserConfig:
def __init__(self, username: str, theme: str):
self.username = username
self.theme = theme
def to_json(self) -> str:
import json
return json.dumps({"username": self.username, "theme": self.theme})
def from_json(self, data: str) -> None:
import json
d = json.loads(data)
self.username = d["username"]
self.theme = d["theme"]
config = UserConfig("admin", "dark")
save_to_file(config, "/tmp/config.json") # Works with mypy!
This is the key insight: UserConfig doesn’t need class UserConfig(Serializable). Having the right methods is all that mypy requires.
3. Protocol with @runtime_checkable
By default, Protocol only works with static type checkers. If you want to use isinstance() at runtime, add the @runtime_checkable decorator:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None:
...
class DatabaseConnection:
def close(self) -> None:
print("DB connection closed")
db = DatabaseConnection()
print(isinstance(db, Closeable)) # True
print(isinstance("hello", Closeable)) # False
Important note: isinstance() with @runtime_checkable only checks for the existence of methods, not their signatures (parameter types, return types). Full signature checking is mypy’s job.
4. Real-World Example: Plugin System for Alert Handlers
I often use this pattern when building extensible tools — a monitoring alert system is a typical example:
from typing import Protocol
from dataclasses import dataclass
@dataclass
class AlertEvent:
level: str # "info", "warning", "critical"
message: str
source: str
class AlertHandler(Protocol):
def handle(self, event: AlertEvent) -> bool:
"""Returns True if handled successfully"""
...
def is_available(self) -> bool:
...
# Handler 1: Send via Telegram
class TelegramHandler:
def __init__(self, bot_token: str, chat_id: str):
self.bot_token = bot_token
self.chat_id = chat_id
def handle(self, event: AlertEvent) -> bool:
print(f"[Telegram] {event.level}: {event.message}")
return True
def is_available(self) -> bool:
return bool(self.bot_token and self.chat_id)
# Handler 2: Write to log file
class FileLogHandler:
def __init__(self, log_path: str):
self.log_path = log_path
def handle(self, event: AlertEvent) -> bool:
with open(self.log_path, 'a') as f:
f.write(f"[{event.level}] {event.source}: {event.message}\n")
return True
def is_available(self) -> bool:
import os
return os.access(os.path.dirname(self.log_path) or '.', os.W_OK)
# dispatch_alert accepts list[AlertHandler] — no inheritance required
def dispatch_alert(event: AlertEvent, handlers: list[AlertHandler]) -> None:
for handler in handlers:
if handler.is_available():
success = handler.handle(event)
if not success:
print(f"{type(handler).__name__} failed")
# Usage
event = AlertEvent(level="critical", message="CPU > 95%", source="server-01")
handlers: list[AlertHandler] = [
TelegramHandler("TOKEN", "CHAT_ID"),
FileLogHandler("/var/log/alerts.log"),
]
dispatch_alert(event, handlers)
The benefit is clear: to add a new handler (Slack, PagerDuty…), you just create a class with the right two methods — no need to touch existing code. This follows the Open/Closed Principle.
Type Checking and Monitoring with mypy
Running mypy to Catch Errors Early
# Check the entire project
mypy .
# Strict mode — catches more errors
mypy alert_system.py --strict
Example of mypy errors when passing an object that doesn’t satisfy the Protocol:
class BadHandler:
def handle(self, event: AlertEvent) -> None: # Wrong! Protocol requires -> bool
pass
# Missing is_available()
# mypy reports:
# error: Argument 2 to "dispatch_alert" has incompatible type
# "list[BadHandler]"; expected "list[AlertHandler]"
Configuring mypy with pyproject.toml
[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true
Integrating into CI/CD
# In GitHub Actions
pip install mypy
mypy . --exit-code # non-zero exit code if there are type errors
# Combined with pytest
pytest tests/ && mypy .
I usually add mypy to a pre-commit hook to catch errors before committing:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
When to Use Protocol vs. ABC?
- Protocol: When you want loose coupling, don’t want to enforce inheritance, or work with third-party code
- ABC: When you want strong coupling, need
@abstractmethodto enforce implementation, or want to share code viasuper() - TypedDict: When working with fixed-structure dicts, not class instances
For me, Protocol is the default choice when designing interfaces for plugin systems or components that need clear boundaries. ABC is only used when I want to share implementation through inheritance.

