Protocols & Duck Typing

Python cares more about what an object can DO than what class it inherits from — protocols formalize that 'duck typing' philosophy into checkable, sometimes even statically-typed, behavioral interfaces.

"If it walks like a duck and quacks like a duck, it's a duck" — duck typing is Python's long-standing convention of judging an object by the operations it supports (its methods and dunder methods) rather than its declared type or base class. A protocol formalizes this: a named set of methods/attributes that define a behavioral interface, with no inheritance relationship required. typing.Protocol (Python 3.8+) lets you express these implicit interfaces explicitly enough for static type checkers to verify structural compatibility, while still keeping Python's flexible, no-inheritance-required spirit at runtime.

What Is Duck Typing

Duck typing means Python code generally doesn't check isinstance(obj, SomeClass) before using an object — it just calls the methods it needs and trusts that the object supports them. Any object with a compatible __len__, __iter__, .read(), or whatever the calling code needs will work, regardless of what class hierarchy it belongs to. This is a deliberate design philosophy, not a limitation: it decouples code from specific class hierarchies and lets unrelated classes interoperate as long as they expose the same behavior.

make_it_speak never checks the type of thing; it only requires that thing.speak() exists and is callable, so Duck and Robot — unrelated classes with no shared base — both work.

class Duck:
    def speak(self) -> str:
        return "Quack!"

class Robot:
    def speak(self) -> str:
        return "Beep, I am pretending to quack."

def make_it_speak(thing) -> None:
    print(thing.speak())  # no isinstance check -- just calls .speak()

make_it_speak(Duck())    # Quack!
make_it_speak(Robot())   # Beep, I am pretending to quack.

Behavioral Interfaces vs Nominal Typing

Languages like Java or C# traditionally use nominal typing: a class must explicitly declare implements SomeInterface to be usable where that interface is expected — compatibility is by declared NAME. Python (and structurally-typed languages generally) favors structural compatibility: a class is compatible with an interface if it happens to provide the right methods, with no declaration required. A 'protocol' is simply the name for that implicit interface — a description of required behavior, decoupled from any particular class hierarchy.

Python's built-in protocols are everywhere: the iterator protocol (__iter__/__next__), the context manager protocol (__enter__/__exit__), the sequence protocol (__len__/__getitem__), the descriptor protocol (__get__/__set__), and more. Anything implementing the right methods participates, inheritance-free.

Special Methods as the Backbone of Duck Typing

Much of Python's duck typing operates through dunder (double-underscore) special methods that the interpreter calls implicitly. Implementing __len__ makes len(obj) work and makes obj participate in truthiness checks; implementing __iter__ makes obj work in a for loop and with *obj unpacking; implementing __enter__/__exit__ makes obj usable in a with statement. None of this requires inheriting from a specific base class — the presence of the method is the contract.

class Countdown:
    def __init__(self, start: int) -> None:
        self.start = start

    def __iter__(self):
        n = self.start
        while n > 0:
            yield n
            n -= 1

for number in Countdown(3):   # works because __iter__ exists -- no base class needed
    print(number)
# 3
# 2
# 1

Runtime Duck Typing: EAFP vs LBYL

Two idioms for using duck-typed objects: LBYL ("Look Before You Leap") checks capability first, typically with hasattr(); EAFP ("Easier to Ask Forgiveness than Permission") just attempts the operation and catches the exception if it fails. Python culture strongly favors EAFP — it avoids a redundant capability check followed by a repeated attribute lookup, and it correctly handles cases where an attribute exists but doing the operation still fails for another reason.

# LBYL -- check first
if hasattr(obj, "read"):
    data = obj.read()
else:
    data = None

# EAFP -- Pythonic preference
try:
    data = obj.read()
except AttributeError:
    data = None

The Problem: Duck Typing Has No Static Guarantees

Pure runtime duck typing has a cost: nothing verifies compatibility until the code actually runs and calls the missing method, at which point you get an AttributeError, potentially deep inside a call stack, far from the mistake. A static type checker (mypy, third-party) can't help either, because there's no declared contract to check against — until typing.Protocol gives it one.

typing.Protocol: Static Structural Typing

typing.Protocol (PEP 544, Python 3.8+) lets you define a class that specifies a set of required methods/attributes, without requiring implementers to inherit from it. Any class whose methods match the protocol's signatures is considered compatible by a static type checker (like mypy) — this is called structural subtyping, and it formalizes duck typing so tools can verify it before the code ever runs.

Neither Duck nor Robot inherits from SupportsSpeak; a type checker accepts both because their speak method matches the protocol's declared signature.

from typing import Protocol

class SupportsSpeak(Protocol):
    def speak(self) -> str: ...

class Duck:
    def speak(self) -> str:
        return "Quack!"

class Robot:
    def speak(self) -> str:
        return "Beep."

def make_it_speak(thing: SupportsSpeak) -> None:
    print(thing.speak())

make_it_speak(Duck())   # type-checks fine -- Duck matches the protocol structurally
make_it_speak(Robot())  # also fine -- no inheritance from SupportsSpeak needed

Protocol at Runtime: runtime_checkable

By default, Protocol classes are for static type-checking only — using isinstance() against a plain Protocol raises TypeError. Decorating the protocol with @runtime_checkable allows isinstance()/issubclass() checks against it at runtime, but with an important caveat: the runtime check only verifies that the named methods/attributes EXIST (by name), not that their signatures match — it's a much weaker guarantee than the static check a type checker performs.

collections.abc provides many pre-built runtime-checkable ABCs that serve a similar role for common protocols — Iterable, Sized, Callable, Hashable, and others — usable with isinstance() directly without defining your own Protocol.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Sized(Protocol):
    def __len__(self) -> int: ...

print(isinstance([1, 2, 3], Sized))  # True -- list has __len__
print(isinstance(42, Sized))         # False -- int has no __len__

Protocol vs Abstract Base Class (ABC)

Both Protocol and abc.ABC describe an interface, but they differ in how conformance is established. An ABC uses nominal typing: a class must explicitly subclass the ABC (or be registered with it) to count as an instance, and abc.abstractmethod can enforce that subclasses actually implement required methods at class-creation time. A Protocol uses structural typing: no inheritance is required at all — any class with matching methods qualifies, checked either statically (by a type checker) or, for @runtime_checkable protocols, loosely at runtime by name only.

from abc import ABC, abstractmethod

class Speaker(ABC):        # nominal -- must inherit
    @abstractmethod
    def speak(self) -> str: ...

class Duck(Speaker):       # explicit inheritance required
    def speak(self) -> str:
        return "Quack!"

# Duck() # works: Speaker() directly would raise TypeError (abstract method not overridden)

Defining Protocols with Attributes and Properties

A Protocol can require plain attributes as well as methods, by annotating them like dataclass fields inside the protocol body. Implementing classes can satisfy an attribute requirement with a plain instance attribute, a @property, or anything else that produces the right type when accessed by that name — structural typing only cares about the resulting interface, not how it's implemented.

from typing import Protocol

class HasName(Protocol):
    name: str

class Person:
    def __init__(self, name: str) -> None:
        self.name = name

class Robot:
    @property
    def name(self) -> str:
        return "R2D2"

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

print(greet(Person("Ada")))  # Hello, Ada!
print(greet(Robot()))        # Hello, R2D2!

API Design with Protocols

When designing a function or class that accepts 'anything with a certain capability,' prefer typing the parameter as the narrowest Protocol (or collections.abc ABC) that expresses exactly what's used, rather than a concrete class. This documents the real requirement, lets callers pass in any compatible object (including ones from unrelated libraries or ad-hoc classes) without artificial inheritance, and is a form of the Interface Segregation idea: depend on the smallest interface that satisfies your needs.

from typing import Protocol

class SupportsWrite(Protocol):
    def write(self, data: str) -> int: ...

def log_message(destination: SupportsWrite, message: str) -> None:
    destination.write(message + "\n")

# Works with a real file, an io.StringIO, a custom logger object --
# anything with a matching .write(str) -> int method.

Practical Usage

Reach for plain duck typing (no Protocol at all) in small scripts or internal code where static checking isn't in play. Reach for typing.Protocol when writing a library or larger codebase using type hints and a checker like mypy, especially for functions that should accept diverse, unrelated types. Reach for collections.abc ABCs when the interface you need (iterable, sized, callable, hashable, mapping, sequence) is already one of the standard ones — no need to redefine it as a custom Protocol.

Best Practices

Name protocols descriptively around capability, often SupportsX or XLike (e.g. SupportsWrite, Comparable) so the required behavior is obvious from the name. Keep protocols small and focused — one capability, not a bundle of unrelated methods — so implementers only need to support what's actually used. Only add @runtime_checkable when you genuinely need isinstance() checks at runtime, and remember that check only verifies method names exist, not that signatures or behavior are correct.

Key Takeaways

Duck typing judges objects by the behavior they support (their methods/dunders), not their declared type or class hierarchy.

A protocol is a named behavioral interface; Python's built-in protocols (iterator, context manager, sequence, descriptor) all work this way.

typing.Protocol (3.8+) formalizes duck typing for static type checkers using structural subtyping — no inheritance required for compatibility.

@runtime_checkable enables isinstance() against a Protocol, but only checks that named methods exist, not their signatures.

ABCs use nominal typing (explicit inheritance/registration); Protocols use structural typing (matching shape is enough) — choose based on whether you control the implementing classes.

EAFP (try/except) is the Pythonic idiom for using duck-typed objects, preferred over LBYL (hasattr checks) in most cases.

from typing import Protocol class Sized(Protocol): def __len__(self) -> int:... isinstance([1, 2], Sized) # TypeError: not runtime-checkable from typing import Protocol, runtime_checkable @runtime_checkable class Sized(Protocol): def __len__(self) -> int:... isinstance([1, 2], Sized) # True

A plain Protocol supports only static type checking; isinstance() against it raises TypeError unless the protocol is decorated with @runtime_checkable.

if hasattr(obj, "read"): data = obj.read() # hasattr passed but.read() might still fail for other reasons try: data = obj.read() except AttributeError: data = None

EAFP (try/except) is the idiomatic and more robust approach: hasattr only confirms the attribute exists, not that calling it will succeed, and it does an extra redundant lookup.

@runtime_checkable class Adder(Protocol): def add(self, x: int, y: int) -> int: ... class Bad: def add(self, x: str) -> str: # wrong signature, but isinstance still says True return x isinstance(Bad(), Adder) # True -- misleading!

Runtime protocol checks only confirm a method by that name exists, not that its parameters or return type match — don't rely on isinstance() against a Protocol for real signature safety; let a static checker verify that instead.

What does 'duck typing' mean, and where does the name come from?

It means judging an object's suitability by the operations it supports rather than its declared type — from the saying 'if it walks like a duck and quacks like a duck, it's a duck.' In Python, code typically calls the methods it needs without checking the object's class first.

How does typing.Protocol differ from abc.ABC in how compatibility is determined?

ABC uses nominal typing — a class must explicitly subclass or register with the ABC to count. Protocol uses structural typing — any class whose methods match the protocol's shape qualifies, with no inheritance relationship required.

Why does isinstance(x, SomeProtocol) raise TypeError by default, and what fixes it?

Because plain Protocol classes exist for static type-checking only; decorating the protocol with @runtime_checkable enables isinstance()/issubclass() checks against it at runtime.

What exactly does a @runtime_checkable Protocol's isinstance() check verify, and what does it NOT verify?

It verifies that the object has attributes/methods with the required NAMES. It does not verify method signatures (parameter types, count, or return type), so a class with a same-named but incompatible method still passes the check.

Why is EAFP generally preferred over LBYL in idiomatic Python?

EAFP (try the operation, catch the exception) avoids a redundant capability check followed by a second attribute lookup, and correctly handles cases where an attribute exists but the operation still fails for an unrelated reason — LBYL's hasattr check can pass yet the subsequent call can still fail.

Can a Protocol require a plain attribute (not just methods)? How would an implementing class satisfy it?

Yes — annotate it like a field in the protocol body (e.g. name: str). An implementing class can satisfy it with a plain instance attribute, a @property, or anything else producing the right value under that name; structural typing only cares about the resulting interface.

Why should a well-designed protocol be small and focused on one capability rather than bundling many methods?

So implementers only need to support the specific behavior actually used by the code depending on the protocol, keeping the interface easy to satisfy and the dependency minimal — this mirrors the Interface Segregation principle.

Write a typing.Protocol named Drawable requiring a draw(self) -> str method, and a function render(shapes: list[Drawable]) -> None that prints each shape's .draw() result. Demonstrate it with two unrelated classes.

from typing import Protocol class Drawable(Protocol): def draw(self) -> str: ... class Circle: def draw(self) -> str: return "O" class Square: def draw(self) -> str: return "[]" def render(shapes: list[Drawable]) -> None: for shape in shapes: print(shape.draw()) render([Circle(), Square()]) # O # []

Neither Circle nor Square inherits from Drawable; because both provide a matching draw() -> str method, a static type checker (and the code at runtime) accepts them structurally — that's duck typing formalized.

What does this print?

True then False — Bird has a fly() method matching the protocol's name, so isinstance returns True; Fish has no fly() method at all, so it returns False.

Write a function safe_close(resource) using EAFP that calls resource.close() if it exists, silently doing nothing if the object has no close method.

def safe_close(resource) -> None: try: resource.close() except AttributeError: pass class HasClose: def close(self): print("closed") class NoClose: pass safe_close(HasClose()) # closed safe_close(NoClose()) # (nothing printed, no error)

The EAFP idiom attempts resource.close() directly and catches AttributeError if the method doesn't exist, avoiding a separate hasattr check followed by a second lookup.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Flyer(Protocol):
    def fly(self) -> None: ...

class Bird:
    def fly(self) -> None:
        print("flap flap")

class Fish:
    def swim(self) -> None:
        print("splash")

print(isinstance(Bird(), Flyer))
print(isinstance(Fish(), Flyer))