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
Structural vs nominal subtyping Left side shows structural subtyping: any class with a matching to_dict method satisfies the Serializable protocol. Right side shows nominal subtyping: a class must explicitly inherit from SerializableBase to be accepted. Structural (Protocol) Protocol: Serializable requires: to_dict() → dict class User: def to_dict(self) → dict: ... ✓ no inheritance needed Nominal (ABC) ABC: SerializableBase abstractmethod: to_dict() class User(SerializableBase): def to_dict(self) → dict: ... explicit inheritance required
Protocol validates structure at the call site; ABCs require an explicit inheritance declaration.
Runtime vs static analysis @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.

Structural member matching against a Protocol Members of a concrete User class are mapped to the required members of the Serializable protocol; because every required member is present, the class conforms without inheritance. class User (no base) to_dict() → dict save() → None id: int Protocol Serializable to_dict() → dict (required) id: int (required) save() is extra — allowed ✓ User conforms structurally
Every required member must be present; extra members on the concrete class are fine.
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.

mypy and pyright strictness ladder Configuration flags stacked from lenient defaults at the bottom to full strict mode and pyright strict at the top, each rung adding one class of checks. mypy defaults — untyped bodies skipped, params are Any disallow_untyped_defs — every def must be annotated warn_return_any + warn_unused_ignores strict = true — bundles ~10 flags incl. no-untyped-call pyright typeCheckingMode = "strict" — reportIncompatibleMethodOverride stricter →
Raise strictness one rung at a time and commit a clean baseline at each 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.

Protocol compliance CI pipeline A commit flows through a local pre-commit mypy hook, then a GitHub Actions type-check job, then a branch-protection merge gate before merging to main. git commit staged files pre-commit mirrors-mypy --strict GitHub Actions mypy src/ (full tree) merge gate ✓ → main fast local feedback → authoritative clean-env check → branch protection
The local hook is fast; the Actions job is the authoritative gate protecting the branch.

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.

Variance directions for protocol members Parameters are contravariant and may accept wider types, return types are covariant and may narrow, and mutable attributes are invariant and must match exactly. Protocol method def handle(x: T) -> R parameter x contravariant — may widen str required → accept object ✓ return R covariant — may narrow object promised → return str ✓ mutable attribute: items: list[int] read + write → invariant, must match exactly Why an override is rejected
Widen parameters, narrow returns; a read-write attribute must match exactly.

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 @override decorator (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.

Protocol mistakes, causes, and corrections Three rows pair overusing runtime_checkable, ignoring variance, and confusing protocols with ABCs against the reason each fails and its fix. Mistake Why it fails Correction trust @runtime_checkable for validation checks names, not types validate types explicitly ignore variance unsound substitution rejected by checker widen params, narrow returns treat Protocol like an ABC no inheritance needed rely on structure
Three recurring protocol mistakes, the reason each fails, and its correction.
  • Overusing @runtime_checkable on large protocols: Runtime checks bypass static analysis and add execution overhead. Reserve @runtime_checkable for 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 passes isinstance and then fails at the call site. For real validation, check the concrete types yourself or route the data through a validating TypedDict or 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) or reportIncompatibleMethodOverride (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 runtime isinstance guarantees 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.

Back to Advanced Typing Patterns & Generics