Protocol and Structural Subtyping in Python: Static Duck Typing Workflows
Structural subtyping enables Python developers to define behavioral contracts without explicit inheritance. This guide details how to implement typing.Protocol, tune static analyzer strictness, and integrate compliance checks into CI pipelines. It serves as a practical extension to Advanced Typing Patterns & Generics for teams enforcing type safety at scale.
Key implementation goals include:
- Define structural contracts using
typing.Protocol - Configure mypy and pyright strictness flags
- Automate protocol validation in CI/CD pipelines
- Debug structural mismatches and variance issues
@runtime_checkable makes isinstance(obj, Serializable) work at runtime, but it only checks that the attribute names exist — not their signatures or return types. A static checker verifies full signature compatibility; the runtime check is a weaker, attribute-presence-only test. Use runtime checking sparingly for plugin discovery; rely on static analysis for correctness guarantees.
Defining Protocols and Enforcing Structural Contracts
Protocols replace nominal inheritance with structural compatibility. The type checker verifies that a class implements the required methods and attributes — regardless of its inheritance chain. A class conforms to a protocol the moment its public surface is a superset of the protocol’s declared members; there is no register() call and no base class to import. This is static duck typing: “if it has to_dict, treat it as Serializable”, verified before the program runs.
typing.Protocol was introduced by PEP 544 and shipped in Python 3.8. On 3.7 you import the identical class from typing_extensions (from typing_extensions import Protocol), which many libraries still do behind a version guard so a single codebase supports both. Use ... (Ellipsis) as the body to mark a member abstract — a protocol method body is never called for conformance, so ..., pass, or raise NotImplementedError are interchangeable there.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict[str, object]: ...
class User:
def to_dict(self) -> dict[str, object]:
return {"id": 1, "name": "Alice"}
def process(data: Serializable) -> None:
print(data.to_dict())
process(User()) # Passes structural check without inheritance
A protocol may also declare attributes, not just methods. Writing id: int in the body requires a matching instance (or class) attribute on the implementer; a read-only requirement is expressed with @property. Members can carry defaults: if a protocol method or attribute has a real body or an assigned value, an implementer that omits it still conforms, which lets you add optional hooks to a contract without breaking existing callers.
class Plugin(Protocol):
name: str
def setup(self) -> None: ...
def teardown(self) -> None: # default → optional to override
return None
You can also subclass a protocol explicitly (class User(Serializable): ...). Doing so is optional but useful: the checker then verifies conformance at the definition site and reports a missing method where the class is written rather than where an instance is passed, and the subclass inherits any default bodies. A protocol that explicitly subclasses another protocol stays a protocol only if it still lists Protocol among its bases; mixing a protocol with a non-protocol base turns it into an ordinary concrete class. The @runtime_checkable decorator enables isinstance() validation at runtime, but only for method/attribute presence — it never inspects signatures or return types, and on data protocols it checks attribute names only. Reserve it for plugin discovery or dynamic dispatch; static analysis remains the primary enforcement mechanism. See Generics and TypeVar for parameterizing these contracts.
Static Analyzer Configuration and Strictness Tuning
Type checker divergence directly impacts protocol validation. mypy defaults to lenient checking — untyped function bodies are effectively skipped and unannotated parameters are treated as Any, which silently swallows many mismatches. Pyright is stricter out of the box, infers Unknown rather than Any for unannotated values, and enforces parameter variance more aggressively. Ruff focuses on linting and delegates protocol compliance to a dedicated type checker. The practical goal is to raise strictness deliberately, one gate at a time, so that a protocol contract is actually enforced rather than nominally declared.
Rather than flipping strict = true on a legacy codebase and drowning in errors, ascend a ladder of individual flags. Each rung enables one class of checks, so you can commit a clean baseline at every step.
Configure strictness in pyproject.toml:
[tool.mypy]
strict = true
warn_return_any = true
warn_unreachable = true
disallow_untyped_defs = true
show_error_codes = true
[tool.pyright]
typeCheckingMode = "strict"
reportIncompatibleMethodOverride = true
reportMissingTypeStubs = false
Under strict, mypy reports protocol failures with precise error codes you can target: a protocol member with an incompatible signature surfaces as [override], a wrong argument type at a call site as [arg-type], and structural conformance failures involving abstract or protocol members as [misc] (for example, “Only concrete class can be given where Type[Serializable] is expected”). Pyright’s equivalent, reportIncompatibleMethodOverride, fires on the same variance violations but names the specific parameter or return type. When bridging legacy code you cannot yet fix, a narrowly scoped # type: ignore[override] suppresses exactly one code while warn_unused_ignores guarantees the comment is removed once the underlying issue is resolved. Reach for typing.cast() only when you can guarantee structural compliance at runtime — it asserts, it does not check. Generic protocols integrate seamlessly with Generics and TypeVar for reusable data-access contracts; on Python 3.12+ prefer the PEP 695 syntax class Repository[T](Protocol): ..., which drops the explicit TypeVar.
from typing import Protocol, TypeVar
T = TypeVar("T")
class Repository(Protocol[T]):
def get(self, id: int) -> T: ...
def save(self, entity: T) -> None: ...
# Python 3.12+ (PEP 695) — no TypeVar needed:
class Repository[T](Protocol):
def get(self, id: int) -> T: ...
def save(self, entity: T) -> None: ...
class UserRepository:
def get(self, id: int) -> dict:
return {"id": id}
def save(self, entity: dict) -> None:
pass
CI Integration and Automated Compliance Workflows
Embed protocol validation directly into your development lifecycle so a structural mismatch is caught by a machine, not by a reviewer or a production traceback. Two gates cover most teams: a local pre-commit hook that runs on staged files before a commit lands, and a GitHub Actions job that re-runs the full check on every push and pull request. The local hook gives fast feedback on the files you touched; the CI job is the authoritative gate because it checks the whole tree in a clean environment where a forgotten local stub cannot mask an error.
Configure .pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.17.0
hooks:
- id: mypy
args: [--strict, --show-error-codes, --warn-unused-ignores]
additional_dependencies: [types-requests]
Pair this with a GitHub Actions workflow:
# .github/workflows/type-check.yml
name: Type Check
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install mypy
- name: Run mypy
run: mypy src/ --config-file pyproject.toml
A subtle CI trap is that mirrors-mypy runs mypy inside its own isolated virtualenv, so third-party stubs must be declared under additional_dependencies — otherwise the hook sees bare Any where your editor sees rich types, and protocol checks that pass locally fail in CI (or worse, pass in CI for the wrong reason). Pin rev: to an exact mypy tag so the gate is reproducible.
In large or partially typed repositories, control how far mypy traverses imports. --follow-imports=skip treats unfollowed modules as Any so you can enforce a protocol locally without dragging in the whole dependency graph; --follow-imports=silent still checks them but hides their errors; --ignore-missing-imports only quiets the “missing stubs” complaint. To adopt strict checking on a legacy tree without a wall of red, generate a baseline — mypy’s own --write-baseline/--baseline-file (or the mypy-baseline tool) records existing errors so CI fails only on new protocol violations, and shrinks the baseline as you fix them. Failing fast on protocol mismatches this way prevents runtime AttributeError cascades — the classic symptom of a duck-typed object that was one method short of the contract. See GitHub Actions type checking for caching and matrix setups.
Debugging Protocol Violations and Edge Cases
Structural mismatches produce cryptic error traces because the failure is reported where an object is used as a protocol, which can be far from where the class is defined. Start by isolating the failing class and running the checker with error codes on. mypy reports something like Argument 1 to "process" has incompatible type "User"; expected "Serializable" followed by a note naming the offending member and why it is incompatible; pyright reports "User" is incompatible with protocol "Serializable" and lists each member with its expected versus actual signature. The single most useful tactic is reveal_type(obj): drop it above the failing line, run the checker (never the program — reveal_type is a checker-only pseudo-function, though it exists at runtime from typing since 3.11), and read the inferred type the checker actually sees, which is frequently Any or a narrower type than you assumed.
Most stubborn protocol errors are really variance errors, and variance is not a matter of taste — it is what keeps substitution sound. In a method, parameters are contravariant (an implementation may accept a wider type than the protocol promises) and return types are covariant (an implementation may return a narrower type). A mutable attribute is read and written, so it must be invariant — this is why declaring items: list[int] in a protocol will reject an implementer whose attribute is list[bool], even though bool is a subtype of int.
When you genuinely need to break the rule — say a subclass deliberately narrows an inherited parameter — annotate the method with # type: ignore[override] (mypy) and document why the narrowing is safe in practice. Self-referencing protocols, where a method returns the protocol type itself, are best expressed with typing.Self rather than a forward reference to the class name: Self binds to the concrete implementer, so a fluent .filter().order_by() chain keeps returning the subclass type instead of collapsing to the base. Combine this with Self and NotRequired Types to model optional attributes without breaking structural compliance.
from typing import Protocol, Self
class QueryBuilder(Protocol):
def where(self, cond: str) -> Self: ... # returns the concrete type
def build(self) -> str: ...
Debugging checklist:
- Verify method signatures match exactly, including
self, parameter names (mypy matches keyword-capable parameters by name), and default arguments. - Confirm variance: widen parameters, narrow returns, keep mutable attributes invariant.
- Check for a missing
@overridedecorator (PEP 698, 3.12+ /typing_extensions) that would have caught a typo’d override. - Ensure imported types resolve in the isolated CI environment, not just locally.
- Use
reveal_type()to inspect the inferred type at the failing boundary before guessing.
Common Mistakes
Most protocol bugs trace back to one of three misconceptions: that the runtime check is as strong as the static one, that signatures can differ “a little” and still match, or that a Protocol behaves like an ABC. Each has a concrete symptom and a direct correction.
- Overusing
@runtime_checkableon large protocols: Runtime checks bypass static analysis and add execution overhead. Reserve@runtime_checkablefor explicit plugin discovery or dynamic dispatch, not core type safety. The runtime check only validates attribute existence, not argument types or return types, so an object with a wrongly-typed member passesisinstanceand then fails at the call site. For real validation, check the concrete types yourself or route the data through a validatingTypedDictor dataclass. - Ignoring method signature variance: Protocols enforce strict signature matching. Covariant return types and contravariant parameters must align exactly — a parameter may only widen and a return may only narrow — otherwise static checkers flag structural mismatches with
[override](mypy) orreportIncompatibleMethodOverride(pyright). A mutable attribute is read and written, so it is invariant and must match its declared type exactly. - Confusing structural subtyping with ABCs: Abstract Base Classes require explicit registration or inheritance. Protocols rely on implicit structural compatibility, making them ideal for decoupled interfaces you do not own. If you find yourself calling
.register()or importing a base class only to satisfy a checker, a Protocol is the lighter tool; keep ABCs for when you need shared concrete implementation or runtimeisinstanceguarantees you control.
FAQ
When should I use Protocol instead of an Abstract Base Class?
Use Protocol for implicit structural matching and decoupled interfaces where you don’t control the implementing classes. Use ABCs when explicit inheritance, shared concrete implementation, or runtime register() is required.
How do I fix “Protocol member X is not implemented” errors in mypy?
Ensure the implementing class defines all required methods and attributes with matching signatures. Check for missing self parameters, incorrect return types, or parameter names that differ from the protocol definition.
Can Protocols enforce optional attributes?
Yes, by providing default implementations in the Protocol class body. typing.NotRequired is specific to TypedDict; for protocols, a default method body makes the attribute effectively optional for structural matching purposes.
Does pyright handle Protocols differently than mypy?
Both enforce structural subtyping, but pyright defaults to stricter variance checking and provides more granular error tracing for protocol mismatches. Pyright also infers Unknown for untyped parameters while mypy defaults to Any.