Go's implicit interfaces in Python: Protocols
Table of Contents
If you’ve written much Go, you probably like its interfaces. A type never declares what it implements – if it has the right methods, it fits. That shapes how Go code gets written: small interfaces, defined by the code that needs them, satisfied by types that have never heard of them.
We recently came back to Python after not touching it since 2.7, and our first instinct was to reach for abstract base classes (which make you inherit from them) or plain duck typing (which nothing checks). Neither felt like Go.
Python has had a third option in the standard library since 3.8: typing.Protocol. Below we’ll go through how it maps onto the Go patterns you already know, using the same example in both languages. The Python examples assume 3.12 or later. Short on time? The cheat sheet at the end sums it all up.
A lot of what we’ve relearned about structuring modern Python came from Architecture Patterns with Python by Harry Percival and Bob Gregory, which is free to read online at cosmicpython.com. If you’re coming back to Python after a long break, or arriving from Go, it’s well worth a read.
A quick refresher: Go’s implicit interfaces
Our example sends a notification when a job fails.
// Notifier is declared by the code that *uses* it, not by the code that implements it.
type Notifier interface {
Notify(message string) error
}
type SlackNotifier struct {
Channel string
}
func (s SlackNotifier) Notify(message string) error {
fmt.Printf("[slack #%s] %s\n", s.Channel, message)
return nil
}
type EmailNotifier struct {
To string
}
func (e EmailNotifier) Notify(message string) error {
fmt.Printf("[email %s] %s\n", e.To, message)
return nil
}
func AlertOnFailure(n Notifier, job string, err error) error {
if err == nil {
return nil
}
return n.Notify(fmt.Sprintf("job %q failed: %v", job, err))
}
Neither SlackNotifier nor EmailNotifier mentions Notifier. They could live in a different package, written by a different team, years before Notifier existed. The compiler only checks that they have a Notify(string) error method when they’re passed to AlertOnFailure.
This is structural typing: a type is compatible if it has the right methods, whatever it inherits from.
What Python gave us before Protocols
Python has historically offered two ways to express “something that can notify”.
Abstract base classes are nominal. A class only counts as a Notifier if it explicitly inherits from it:
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def notify(self, message: str) -> None: ...
class SlackNotifier(Notifier): # <- has to opt in
def notify(self, message: str) -> None: ...
This works, but every implementation now depends on the module that defines the interface. For a class you don’t own there is Notifier.register(TheirClass), which makes isinstance agree at runtime, but neither mypy nor pyright honours it, so the type checker still says no.
Duck typing is structural, but nothing checks it until the code runs. Misspell notify as notfy and you find out when an alert doesn’t fire.
What we wanted was something structural like duck typing, but checked before the code runs like an ABC.
Enter typing.Protocol
PEP 544 introduced Protocol, which the PEP describes as “static duck typing”. Here’s the same example:
from dataclasses import dataclass, field
from typing import Protocol
class Notifier(Protocol):
def notify(self, message: str) -> None: ...
@dataclass
class SlackNotifier:
channel: str
def notify(self, message: str) -> None:
print(f"[slack #{self.channel}] {message}")
@dataclass
class EmailNotifier:
to: str
def notify(self, message: str) -> None:
print(f"[email {self.to}] {message}")
def alert_on_failure(notifier: Notifier, job: str, error: Exception | None) -> None:
if error is None:
return
notifier.notify(f"job {job!r} failed: {error}")
As in the Go version, SlackNotifier and EmailNotifier don’t inherit from or import Notifier. Anything with a notify(self, message: str) -> None method is a Notifier.
The return type is different: Go returns an error, while Python raises an exception, so notify returns None.
The type checker does the compiler’s job
Python itself doesn’t enforce any of this at runtime. The interpreter will call alert_on_failure with whatever you pass it. The checking is done by a static type checker such as mypy or pyright, which takes the place of go build.
Here are two classes that don’t fit:
from notifier import alert_on_failure
class PagerNotifier:
def notify(self, message: str, urgent: bool) -> None:
print(message)
class Typo:
def notfy(self, message: str) -> None:
print(message)
alert_on_failure(PagerNotifier(), "nightly-build", RuntimeError("boom"))
alert_on_failure(Typo(), "nightly-build", RuntimeError("boom"))
Running mypy --strict:
bad.py:14: error: Argument 1 to "alert_on_failure" has incompatible type "PagerNotifier"; expected "Notifier" [arg-type]
bad.py:14: note: Following member(s) of "PagerNotifier" have conflicts:
bad.py:14: note: Expected:
bad.py:14: note: def notify(self, message: str) -> None
bad.py:14: note: Got:
bad.py:14: note: def notify(self, message: str, urgent: bool) -> None
bad.py:15: error: Argument 1 to "alert_on_failure" has incompatible type "Typo"; expected "Notifier" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
That’s the same feedback you’d get from the Go compiler. If you’re going to rely on Protocols, run the type checker in CI and treat its failures like build failures. Without it, a Protocol is just documentation.
Compile-time assertions
Go developers often add a one-liner to prove a type satisfies an interface, even when nothing in the package uses it that way yet:
// Compile-time assertion that SlackNotifier satisfies Notifier.
var _ Notifier = SlackNotifier{}
The Python equivalent is an annotated assignment that the type checker will validate:
_: Notifier = SlackNotifier(channel="alerts")
There’s one catch when you have several implementations. In Go _ is the blank identifier, not a variable, so you can repeat the line as often as you like. In Python _ is an ordinary name, and annotating it a second time is a redefinition that mypy rejects (Name "_" already defined). Annotate it once, then reassign; each reassignment is still checked against Notifier:
_: Notifier = SlackNotifier(channel="alerts")
_ = EmailNotifier(to="ops@example.com")
Unlike the Go version this does construct objects at import time, so keep it to cheap constructors, or tuck it under if TYPE_CHECKING: so it only exists for the checker.
You can also go one step further than Go and opt in explicitly. A class may subclass a Protocol:
class SlackNotifier(Notifier):
def notify(self, message: str) -> None: ...
Notifier is still a Protocol to everyone else, but now the type checker validates SlackNotifier where it’s defined: get the signature wrong and the error points at the class, not at some distant call site. Leave a method out and the checker won’t let you instantiate the class at all. It’s the closest thing Python has to an implements keyword, and it’s optional.
Accepting the standard library’s types
Some of Go’s most used interfaces are one-method types from the standard library, like io.Writer. Anything with a Write([]byte) (int, error) method can be written to – a file, a buffer, a network connection, an HTTP response:
func WriteReport(w io.Writer, failures []string) error {
if _, err := fmt.Fprintf(w, "%d failed job(s)\n", len(failures)); err != nil {
return err
}
for _, job := range failures {
if _, err := fmt.Fprintf(w, " - %s\n", job); err != nil {
return err
}
}
return nil
}
// Both of these just work:
// WriteReport(os.Stdout, failures)
// WriteReport(&bytes.Buffer{}, failures)
Because Protocols are structural, you can do the same in Python with classes you don’t own:
import io
import sys
from typing import Protocol
class Writer(Protocol):
def write(self, s: str, /) -> int: ...
def write_report(w: Writer, failures: list[str]) -> None:
w.write(f"{len(failures)} failed job(s)\n")
for job in failures:
w.write(f" - {job}\n")
write_report(sys.stdout, ["nightly-build"])
buf = io.StringIO()
write_report(buf, ["nightly-build", "deploy"])
Neither sys.stdout nor io.StringIO knows about our Writer protocol, and the type checker accepts both. You can’t do this with an ABC, and it’s the part that felt most like Go to us.
The / marks s as positional-only, and it matters more than it looks. Without it, the protocol promises that callers can pass write(s=...). The standard library’s type stubs declare write’s parameter as positional-only, so pyright rejects both sys.stdout and io.StringIO against a Writer without the /. It also rejects any implementation that names the parameter something other than s. mypy is more lenient and accepts all of them. Go never cares about parameter names.
Composing interfaces
In Go you build bigger interfaces by embedding smaller ones:
type Closer interface {
Close() error
}
type NotifyCloser interface {
Notifier
Closer
}
func NotifyAndClose(nc NotifyCloser, message string) error {
defer nc.Close() // error ignored for brevity
return nc.Notify(message)
}
In Python you combine Protocols by inheriting from them, but you have to list Protocol again as a base. If you don’t, the new class is a regular class that inherits from two protocols, and implementations would have to subclass it explicitly:
class Closer(Protocol):
def close(self) -> None: ...
class NotifyCloser(Notifier, Closer, Protocol): # <- Protocol again, or it isn't one
pass
@dataclass
class BufferedNotifier:
buf: list[str] = field(default_factory=list)
def notify(self, message: str) -> None:
self.buf.append(message)
def close(self) -> None:
print("[buffered] flushing:", "; ".join(self.buf))
self.buf.clear()
def notify_and_close(nc: NotifyCloser, message: str) -> None:
try:
nc.notify(message)
finally:
nc.close()
try/finally stands in for defer, and BufferedNotifier satisfies NotifyCloser without naming it, as it would in Go.
Type assertions and @runtime_checkable
Go lets you ask, at runtime, whether a value also satisfies some other interface:
var n Notifier = SlackNotifier{Channel: "alerts"}
if c, ok := n.(Closer); ok {
c.Close()
}
By default, Python Protocols are a static-only construct and isinstance against them raises a TypeError. To opt in, decorate the protocol with @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closer(Protocol):
def close(self) -> None: ...
n: Notifier = SlackNotifier(channel="alerts")
if isinstance(n, Closer):
n.close()
Unlike Go’s type assertion, a runtime check only looks at whether the methods exist, not at their signatures:
class Door:
def close(self, slam: bool) -> str:
return "BANG" if slam else "click"
print(isinstance(Door(), Closer)) # True
Door.close takes an extra argument and returns a str, so it isn’t a Closer, but isinstance says it is because it has an attribute called close. The type checker would catch this (pyright even flags the isinstance call itself as an error: “Class overlaps ‘Closer’ unsafely”); the runtime check doesn’t. We use @runtime_checkable sparingly and don’t rely on it in place of a type checker.
Under the hood: same idea, very different machinery
On the surface the two look alike. Underneath, Go builds interfaces into the compiler and the runtime, while Python leaves them entirely to an external tool.
Go: checked by the compiler, called through a method table
-
Every conversion is checked at compile time. Wherever a concrete value becomes an interface, whether passed to
AlertOnFailure, returned, or assigned tovar _ Notifier, the compiler compares method names and full signatures. It also checks the receiver kind. IfNotifyhad a pointer receiver,SlackNotifier{}would be rejected (method Notify has pointer receiver) and only&SlackNotifier{}would fit. Python has no equivalent distinction. -
An interface value is two words: a pointer to an itab (“interface table”, a struct inside the Go runtime) and a pointer to the data. The itab is built once per (interface, concrete type) pair and holds the two types plus one function pointer per interface method, in a fixed order. When the compiler can see the conversion it puts the itab in the binary; otherwise the runtime builds it on first use (
runtime.getitab) and caches it.n ──► [ itab ptr | data ptr ] │ └──► SlackNotifier{Channel: "alerts"} ▼ itab(Notifier, SlackNotifier) └─ methods: [0] → SlackNotifier.Notify -
Method calls don’t look anything up by name. The compiler knows
Notifyis slot 0 ofNotifier’s itab, son.Notify(msg)compiles to “read slot 0 fromn’s itab, call it withn’s data pointer”. It’s one memory load and an indirect call. Where the compiler can see the concrete type, it skips the table and calls the method directly. -
Type assertions use the same tables.
n.(Closer)finds or builds the itab, so it compares full signatures. That’s why theDoorabove, withClose(bool) string, is not aCloserin Go, while Python’sisinstancesays it is. -
The two-word layout has costs. Putting a non-pointer value in an interface can allocate.
go build -gcflags=-mshows thatvar n Notifier = SlackNotifier{...}used locally stays on the stack, but passing the same value toAlertOnFailureor storing it in a package-level variable reportsSlackNotifier{...} escapes to heap. And an interface holding a nil pointer is not itself nil, because its type word is set:var p *SlackNotifier; var n Notifier = pgivesn != nil. It’s one of Go’s best-known gotchas.
These are details of the standard Go toolchain rather than the language spec (the struct has been renamed across releases), but the design has been stable for over a decade.
Python: checked by a separate tool, looked up by name
- The interpreter never looks. CPython compiles to bytecode without reading annotations. mypy or pyright does the structural comparison, reports any errors, and none of it ends up in the running program.
- At runtime a Protocol is just a class, with metaclass
_ProtocolMeta(a subclass ofABCMeta). The annotationnotifier: Notifieris stored in__annotations__and never consulted. Nothing is wrapped, boxed or allocated: the object is passed as it is. - Method calls are looked up by name.
notifier.notify(msg)searches the object’s class and its parents (the MRO) and the instance’s own attributes, then binds the function. Since 3.11 the specialising interpreter (PEP 659) caches that lookup per call site, keyed on the object’s type. A call site that always sees the same class gets fast; one that sees many implementations falls back to the general path. It’s the nearest thing Python has to an itab, built per call site at runtime, and it knows nothing about Protocols. @runtime_checkablechecks names and nothing else.isinstancewalks the Protocol’s members and checks that each one exists, usinginspect.getattr_staticsince 3.12, so properties aren’t even evaluated. That’s also slow: in our measurements on CPython 3.14 it was roughly 10× slower thanisinstancewith an ordinary class, and the Python docs warn about it too. Protocols with data members can’t be used withissubclass()at all.
| Go | Python | |
|---|---|---|
| When the fit is checked | By the compiler, at every conversion | By mypy / pyright; never at runtime |
| What is checked | Names, full signatures, receiver kind | Type checker: names and signatures. @runtime_checkable: names only |
| What a value looks like at runtime | Two words: itab pointer + data pointer | The object itself; the Protocol is an ordinary class |
| How a method call finds the code | Reads a function pointer from a fixed slot in the itab | Looks the method up by name, cached per call site |
| Cost of using the interface | May allocate when boxing a value | None |
nil / None | An interface holding a nil pointer is not nil | None is just None |
For the full Go story, Russ Cox’s Go Data Structures: Interfaces is still the classic explanation.
Generics with constraints
Since Go 1.18, interfaces double as type constraints:
type Sized interface {
Size() int
}
type File struct{ Bytes int }
func (f File) Size() int { return f.Bytes }
func Largest[T Sized](items []T) T {
largest := items[0]
for _, item := range items[1:] {
if item.Size() > largest.Size() {
largest = item
}
}
return largest
}
Protocols do the same job as bounds on a type variable. With the PEP 695 syntax in Python 3.12 and later, it looks similar:
class Sized(Protocol):
def size(self) -> int: ...
@dataclass
class File:
size_bytes: int
def size(self) -> int:
return self.size_bytes
def largest[T: Sized](items: list[T]) -> T:
return max(items, key=lambda item: item.size())
As in Go, largest([File(10), File(42)]) returns a File, not a Sized, so callers keep the concrete type.
Where Protocols go further than Go
A few things Protocols can express have no direct Go equivalent, and most come with a surprise.
Function types. Go has type Handler func(string) error. The Python equivalent is a Protocol with a __call__ method, which a plain function satisfies:
class Retrier(Protocol):
def __call__(self, job: str, *, attempts: int = 3) -> bool: ...
def retry(job: str, *, attempts: int = 3) -> bool:
return attempts > 0
r: Retrier = retry # a function, no class needed
You could write Callable[[str], bool] instead, but Callable can’t describe keyword-only or optional parameters. A __call__ Protocol can, and it gives the type a name and a docstring.
Attributes. Go interfaces only hold methods. A Protocol can also require data:
class Named(Protocol):
name: str
Any object with a name: str attribute fits, but only if the attribute can be assigned to. A bare name: str in a Protocol means a settable attribute, so a @dataclass(frozen=True) or a class exposing name as a @property doesn’t fit (mypy: Protocol member Named.name expected settable variable, got read-only attribute). If read access is all you need, say so in the Protocol:
class Named(Protocol):
@property
def name(self) -> str: ...
Now plain attributes, frozen dataclasses and properties all fit.
Method bodies are not inherited. A Protocol method can have a body, which looks like a default implementation:
class Reporter(Protocol):
def lines(self) -> list[str]: ...
def report(self) -> str:
return "\n".join(self.lines())
It only works for classes that subclass Reporter explicitly. A class that matches structurally gets neither the code nor a pass: the type checker reports report as a missing member. If you want shared behaviour, write a plain function that takes the Protocol.
async is part of the signature. A Protocol method declared async def notify(...) returns a coroutine, so a class with an ordinary def notify(...) does not fit, and both mypy and pyright say so. Go has nothing like it, and it matters in async services, where a sync fake passed in for an async dependency is an easy mistake.
“Accept interfaces, return structs” – and test with fakes
The Go idiom “accept interfaces, return structs” isn’t one of the official Go Proverbs, but the Go code review guidelines say the same thing: interfaces “generally belong in the package that uses values of the interface type”, and the implementing package “should return concrete (usually pointer or struct) types”. It works because interfaces are cheap to declare and live with the code that uses them. It helps most in tests, where you can pass in a fake without a mocking library.
type fakeNotifier struct {
messages []string
}
func (f *fakeNotifier) Notify(message string) error {
f.messages = append(f.messages, message)
return nil
}
func TestAlertOnFailureSendsMessage(t *testing.T) {
fake := &fakeNotifier{}
if err := AlertOnFailure(fake, "nightly-build", errors.New("exit status 1")); err != nil {
t.Fatal(err)
}
want := `job "nightly-build" failed: exit status 1`
if len(fake.messages) != 1 || fake.messages[0] != want {
t.Fatalf("got %v, want [%s]", fake.messages, want)
}
}
The same approach works in Python. Instead of unittest.mock.MagicMock, which accepts any method call including typos, write a small fake that the type checker can verify against the Protocol:
@dataclass
class FakeNotifier:
messages: list[str] = field(default_factory=list)
def notify(self, message: str) -> None:
self.messages.append(message)
def test_alert_on_failure_sends_message() -> None:
fake = FakeNotifier()
alert_on_failure(fake, "nightly-build", RuntimeError("exit status 1"))
assert fake.messages == ["job 'nightly-build' failed: exit status 1"]
If someone later changes the signature of Notifier.notify, the fake stops type checking, as long as the type checker runs over your tests as well as your source. Many projects leave tests/ out of their mypy config, so check yours. A MagicMock wouldn’t tell you either way: it’s typed as Any, so the checker accepts it anywhere. Mock(spec=SlackNotifier) or create_autospec will catch a misspelt method, but only at runtime, when the test runs.
Go’s advice about where to declare interfaces applies too: define the Protocol next to the function that uses it, keep it as small as that function needs, and leave implementations unaware of it.
The “return structs” half carries over as well. Functions that build an implementation should return the concrete class, not the Protocol:
def make_slack_notifier(channel: str) -> SlackNotifier: # not -> Notifier
return SlackNotifier(channel=channel)
Callers keep access to everything SlackNotifier offers, such as its channel, and can still pass it anywhere a Notifier is expected. As in the generics example above, the Protocol constrains what goes in, not what comes out.
What this looks like in a real service
Four patterns have held up across our async Python services (FastAPI, SQLAlchemy).
Repositories behind Protocols. Services depend on a repository Protocol, not the database code: production passes a SQLAlchemy implementation, tests pass an in-memory fake. It’s the repository pattern from Architecture Patterns with Python, except the book uses ABCs and we use Protocols, so the database class never imports the interface.
Narrow Protocols at the consumer. When a service only reads, it declares a smaller Protocol with just the method it calls:
class RuleRepository(Protocol):
async def get(self, rule_id: str) -> Rule | None: ...
async def list_rules(self, *, status: str | None = None) -> list[Rule]: ...
async def save(self, rule: Rule) -> None: ...
class RuleReader(Protocol): # all that rule evaluation needs
async def list_rules(self, *, status: str | None = None) -> list[Rule]: ...
The database repository satisfies both unchanged, and that service’s test fake is one method long: the Go proverb “the bigger the interface, the weaker the abstraction”, applied after the fact.
The docstring is part of the contract. Rules that types can’t express go on the Protocol:
class PresenceCheck(Protocol):
"""True means present, False means gone, None means unknown.
Implementations must never raise: callers treat this as best effort."""
async def __call__(self, file_id: str) -> bool | None: ...
Signatures drift. One Protocol describes an HTTP client from another service. If that client’s signature changes, it stops matching and nothing complains at runtime; @runtime_checkable still says yes. Only the type checker notices, so it runs in CI on every change, tests included.
Cheat sheet
| Go | Python | Difference |
|---|---|---|
type Notifier interface { ... } | class Notifier(Protocol): ... | – |
| Implicit satisfaction | Implicit satisfaction (no inheritance required) | Python can also opt in by subclassing the Protocol |
Checked by go build | Checked by mypy / pyright | The interpreter checks nothing; without CI, nothing does |
var _ Notifier = SlackNotifier{} | _: Notifier = SlackNotifier(...) | _ is a real variable: annotate once, then reassign |
| Parameter names ignored | Names matter unless positional-only (/) | pyright enforces names, and rejects stdlib types without /; mypy doesn’t |
Return an error | Raise an exception | notify returns None |
Embedding: interface { Notifier; Closer } | class NotifyCloser(Notifier, Closer, Protocol) | Forget Protocol and it’s an ordinary class |
v, ok := n.(Closer) | isinstance(n, Closer) with @runtime_checkable | Checks method names only, not signatures |
| Interface value = itab + data | The object itself | Go dispatches through a table; Python looks methods up by name |
Nil pointer in an interface != nil | – | No typed-nil gotcha in Python |
func F[T Sized](...) | def f[T: Sized](...) (3.12+) | – |
io.Writer | A one-method Protocol | Matches stdlib types you don’t own, as in Go |
type F func(...) | A Protocol with __call__ | Can describe keyword-only and default arguments |
| Methods only | Methods and attributes | Bare attributes must be settable; use @property for read-only. Method bodies aren’t inherited structurally |
| – | async def in a Protocol | A sync method doesn’t satisfy an async one |
Wrapping up
Protocols don’t map exactly onto Go interfaces: the runtime doesn’t enforce them, and @runtime_checkable checks less than a Go type assertion. But most of the way we write Go interfaces carries over: small interfaces, defined where they’re used, that any type with the right methods satisfies.
If you’re moving from Go to a Python codebase, turn on a type checker, run it in CI, and use Protocol wherever you’d have used an interface in Go.