Protocols: Say What You Use

The Course
•
September 19, 2026
•
7 min read

save(self, connection) — what is a connection?

The easy answer, sqlite3.Connection, is a decision with consequences: the library now only works with one driver, and every test needs a real database. The better answer describes what the library actually uses, which is two methods.

Nominal and structural

Two ways to say what a value must be:

  • Nominal: it must inherit from this class. isinstance(x, Connection) with an ABC, and the author of x must have known about you.
  • Structural: it must have these methods. A Protocol, and the author of x needs to know nothing at all.

For a library parameter, structural is nearly always right, because it lets callers pass things you never anticipated: a connection pool, a wrapper that logs slow queries, a fake in a test.

from collections.abc import Sequence
from typing import Any, Protocol, runtime_checkable


@runtime_checkable
class Connection(Protocol):
    def execute(self, sql: str, parameters: Sequence[Any] = (), /) -> Cursor: ...
    def commit(self) -> None: ...

Nothing in the library imports sqlite3 after this — only db.py, the scaffolding, does.

The positional-only slash

parameters: Sequence[Any] = (), / marks the parameters as positional-only, so the protocol doesn’t promise anything about their names. Without the /, any implementation would have to call its first parameter sql, and a driver that called it query would stop matching. When you describe someone else’s API, positional-only is usually what you mean.

The mistake that takes an hour: a plain attribute

save() needs cursor.lastrowid, so Cursor needs it too. The obvious spelling is wrong:

@runtime_checkable
class Cursor(Protocol):
    lastrowid: int | None            # wrong

A bare annotation in a Protocol means a mutable attribute: readable and writable. sqlite3.Cursor.lastrowid is a read-only property, so a real connection no longer matches, and mypy says so at every call site:

error: Argument 1 to "fetch" of "Model" has incompatible type
       "sqlite3.Connection"; expected "protocols.Connection"  [arg-type]
note: Following member(s) of "Connection" have conflicts: ...

The message names the argument, not the member that actually conflicts, so it reads as “my protocol is wrong somewhere”. It is: write the read-only member as a property.

@runtime_checkable
class Cursor(Protocol):
    @property
    def lastrowid(self) -> int | None: ...

Ask for read-write only when you write to it. A protocol is a list of demands, and each one rules out implementations.

Only a checker sees this

This was a real bug in this library, and the test suite passed the whole time it was there — runtime_checkable doesn’t look at types. Nothing but a type checker can catch a protocol that is too demanding, which is the strongest argument in this course for running one in CI.

What runtime_checkable actually checks

isinstance(x, SomeProtocol) is allowed only with @runtime_checkable, and then it checks that the attribute names exist. Not their signatures, not their types.

isinstance against a protocol Python
from typing import Protocol, runtime_checkable


@runtime_checkable
class Connection(Protocol):
    def execute(self, sql: str, parameters: tuple = (), /) -> object: ...
    def commit(self) -> None: ...


class Fake:
    def execute(self, sql, parameters=(), /):
        return None

    def commit(self):
        return None


class Nonsense:
    def execute(self):                      # wrong signature entirely
        return None

    commit = "not even a method"            # not even callable


class NoCommit:
    def execute(self, sql, parameters=(), /):
        return None


for candidate in (Fake(), Nonsense(), NoCommit()):
    print(f"{type(candidate).__name__:9} {isinstance(candidate, Connection)}")

Nonsense passes. Its execute takes no arguments and its commit is a string. The names are there, so isinstance is satisfied — and it would fail on the first real call.

So use runtime_checkable for what it’s good at: a friendly early error (“that doesn’t look like a connection”) or a branch in your own code. Don’t treat it as validation, and don’t let it replace the checker.

Protocols for your own objects, too

A protocol is also how a library describes the objects it hands back without tying callers to a class. Row in this library is three lines:

class Row(Protocol):
    def keys(self) -> Sequence[str]: ...
    def __getitem__(self, key: str, /) -> Any: ...

sqlite3.Row satisfies it; so does a plain dict; so does whatever a future driver returns. Model.get needs nothing else from a row, and saying exactly that is what keeps the library portable.

Exercise

A protocol for what a report needs

render_report currently demands a Post. Loosen it to a protocol so anything with the right shape works — including a summary object that was never designed for this function.

import unittest
from typing import Protocol, runtime_checkable


@runtime_checkable
class Summarisable(Protocol):
    """TODO: describe what render_report uses: a str title, an int views,
    and a summary() method returning a str."""


def render_report(items) -> str:
    """TODO: annotate items as a sequence of Summarisable."""
    lines = [f"{item.title} ({item.views} views): {item.summary()}" for item in items]
    return "\n".join(lines)


class Post:
    def __init__(self, title: str, views: int) -> None:
        self.title = title
        self.views = views

    def summary(self) -> str:
        return "a post"


class WeeklyDigest:
    """Written for something else entirely, and it fits."""

    title = "Week 12"
    views = 0

    def summary(self) -> str:
        return "3 posts"


class Broken:
    title = "no views attribute"

    def summary(self) -> str:
        return "?"


class Reports(unittest.TestCase):
    def test_renders_posts(self):
        report = render_report([Post("Hello", 3)])
        self.assertEqual(report, "Hello (3 views): a post")

    def test_renders_anything_with_the_shape(self):
        report = render_report([Post("Hello", 3), WeeklyDigest()])
        self.assertIn("Week 12 (0 views): 3 posts", report)

    def test_the_protocol_recognises_both(self):
        self.assertIsInstance(Post("Hello", 3), Summarisable)
        self.assertIsInstance(WeeklyDigest(), Summarisable)

    def test_and_rejects_something_missing_a_member(self):
        self.assertNotIsInstance(Broken(), Summarisable)


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Reports)
)
Hint 1: the idea

A protocol lists only what the function uses: a title, a views and a summary(). Anything with those members matches, with no inheritance needed. One question decides how to declare title and views: does render_report ever write them, or only read them?

Hint 2: a first step

Add def summary(self) -> str: ... to the protocol, and declare title and views. The isinstance tests use the runtime check, which looks only at names. Then annotate items with the widest type the function needs — it only loops over them.

Show one solution
from collections.abc import Sequence


@runtime_checkable
class Summarisable(Protocol):
    @property
    def title(self) -> str: ...
    @property
    def views(self) -> int: ...
    def summary(self) -> str: ...


def render_report(items: Sequence[Summarisable]) -> str:
    lines = [f"{item.title} ({item.views} views): {item.summary()}" for item in items]
    return "\n".join(lines)

title and views are properties in the protocol because render_report only reads them. Declared as plain attributes (title: str) the protocol would demand writability — and then WeeklyDigest, whose title is a class attribute, still matches, but a class using @property would not. Read-only is both weaker and more accurate.

Sequence[Summarisable] rather than list[Summarisable]: the function only iterates, and list would refuse a tuple for no reason. Iterable would be weaker still and correct here — the smallest type that does the job is the one to pick.

What you learned

  • A Protocol describes what your code uses, so callers can pass anything of the right shape and your library needn’t import a driver.
  • A plain annotation in a protocol demands read and write; use @property for things you only read.
  • Positional-only parameters keep a protocol from constraining argument names.
  • runtime_checkable checks that names exist and nothing more — an early smoke test, never validation.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.