Python Protocols: Duck Typing with Type Safety
Python has a famous saying: "If it walks like a duck and quacks like a duck, then it's a duck." This is called duck typing — you don't care what an object is, only what it can do. But duck typing has a downside: your editor and type checker have no idea what you expect.
Protocols solve this. Introduced in Python 3.8, they let you define the shape of an object without forcing classes to inherit from anything. Think of a Protocol as a contract that says "any object with these methods will work here." Classes don't even need to know the Protocol exists.
In this tutorial, you'll learn how Protocols formalize duck typing, how they differ from ABCs, and how to use runtime_checkable for runtime checks.
What Problem Do Protocols Solve?
Imagine you write a function that logs messages. It works with files, network sockets, or any object that has a .write() method. With regular duck typing, there's no way to express that requirement in a type hint.
ABCs require nominal subtyping — a class must explicitly say "I am a Writable" by inheriting from it. You can't retroactively make open() return something that inherits from your custom ABC.
How Do You Define a Protocol?
A Protocol is a class that inherits from typing.Protocol. You define the methods an object must have, but you never implement them. Any class that happens to have those methods automatically satisfies the Protocol — no inheritance required.
ConsoleLogger never inherits from Writable, yet it satisfies the Protocol because it has a write method with the right signature. This is structural subtyping in action.
Can a Protocol Require Multiple Methods?
Absolutely. A Protocol can specify any number of methods and attributes. A class must have all of them to satisfy the Protocol.
Both Circle and Square satisfy Drawable because they each have both draw() and get_area(). Neither class knows Drawable exists.
What Is runtime_checkable?
By default, Protocols only work with static type checkers like mypy. You can't use isinstance() with them. But if you add the @runtime_checkable decorator, Python will check at runtime whether an object has the required methods.
Can Protocols Define Attributes?
Yes. You can declare attributes in a Protocol just like you would in a dataclass. Any class with those attributes satisfies the Protocol.
Both User and Product have a name: str attribute, so both satisfy the Named Protocol. This is incredibly useful for writing generic functions that work across unrelated types.
Protocol vs ABC: Side by Side
from abc import ABC, abstractmethod
class Serializable(ABC):
@abstractmethod
def to_json(self) -> str: ...
# Must inherit explicitly
class User(Serializable):
def to_json(self) -> str:
return '{"name": "Alice"}'from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str: ...
# No inheritance needed
class User:
def to_json(self) -> str:
return '{"name": "Alice"}'Practice Exercises
Create a Measurable Protocol class that requires a length() method returning an int. Then create a Playlist class with a songs list attribute and a length() method that returns the number of songs. Finally, write a function print_length(item) that takes a Measurable and prints its length.
Call print_length with a Playlist containing ['Song A', 'Song B', 'Song C'].
Read the code carefully and predict exactly what will be printed. Pay attention to which classes satisfy the Protocol and which do not.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Speakable(Protocol):
def speak(self) -> str: ...
class Dog:
def speak(self) -> str:
return "Woof"
class Rock:
pass
class Parrot:
def speak(self) -> str:
return "Polly wants a cracker"
print(isinstance(Dog(), Speakable))
print(isinstance(Rock(), Speakable))
print(isinstance(Parrot(), Speakable))Create a Renderable Protocol with two methods:
render() -> strsize() -> intThen create a Paragraph class whose __init__ takes a text string. Its render() returns "<p>{text}</p>" and its size() returns the length of the text.
Create a Header class whose __init__ takes a text string. Its render() returns "<h1>{text}</h1>" and its size() returns the length of the text.
Write a function display(item) that prints the render output and then prints the size.
Call display with Paragraph('Hello') and then Header('Title').
The code below defines a Cacheable Protocol and a UserProfile class. The cache_item function should work with any Cacheable, but there's a bug — UserProfile is missing a method required by the Protocol. Fix UserProfile so it satisfies Cacheable.
The cache_key() method should return f"user:{self.user_id}".
Build a simple plugin system using Protocols.
1. Define a Plugin Protocol with two methods:
- name() -> str
- execute(data: str) -> str
2. Create an UpperPlugin class where name() returns "upper" and execute(data) returns data.upper().
3. Create a ReversePlugin class where name() returns "reverse" and execute(data) returns data[::-1].
4. Write a function run_plugins(plugins, data) that takes a list of plugins and a string. For each plugin, print "{plugin.name()}: {plugin.execute(data)}".
5. Call run_plugins with both plugins and the string "hello".