Protocol vs ABC for Interfaces in Python

TL;DR

typing.Protocol describes an interface structurally: any class whose methods and attributes match the shape satisfies it, with no inheritance and no registration. An abstract base class (abc.ABC) is nominal: a class only counts as a subtype if it explicitly inherits from it (or is .register()-ed). Reach for a Protocol to type duck-typed objects you do not own; reach for an ABC when you want enforced inheritance plus shared implementation and constructor guarantees.

Both a Protocol and an ABC let you say “this argument must support these methods.” The difference is how membership is decided. Choosing wrong tends to surface as either a [misc] “Cannot instantiate abstract class” error you did not expect, or an interface that silently fails to match a perfectly good implementation. This page is part of Protocol and structural subtyping and contrasts the two mechanisms directly.

Structural Protocol matching vs nominal ABC inheritance A FileSink class with a write method matches SupportsWrite structurally with no inheritance, but only counts as an AbstractSink if it explicitly inherits. class FileSink: def write(self, s): ... SupportsWrite(Protocol) matches by shape no inheritance needed AbstractSink(ABC) matches only if class FileSink(AbstractSink) automatic requires subclassing
A Protocol matches on shape; an ABC matches only through explicit inheritance.

Structural: a Protocol matches on shape

A Protocol declares the members an object must expose. Any class that provides them conforms, whether or not its author ever heard of your Protocol. That makes Protocols ideal for typing objects from third-party libraries or standard-library duck types. Protocol arrived in Python 3.8 via PEP 544; on 3.7 and earlier you import it from typing_extensions instead, which back-ports the identical behaviour so the type checker sees the same structural relationship.

Member-by-member structural matching against a Protocol Each method and attribute the Protocol requires is checked one at a time against the concrete class, and all must match for conformance. SupportsWrite (Protocol) requires FileSink provides write(self, data: str) -> int write(self, data: str) -> int name: str (attribute) name: str flush(self) -> None flush(self) -> bool (wrong return) All required members must match; one mismatch fails the whole structural check.
Structural conformance is decided member by member, not by any declared base class.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import Protocol

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

class FileSink:            # no base class, no import of SupportsWrite
    def write(self, data: str) -> int:
        return len(data)

def emit(sink: SupportsWrite, line: str) -> None:
    sink.write(line)

emit(FileSink(), "ok")     # accepted structurally

If FileSink.write had the wrong signature — say it returned None — mypy would reject the call with [arg-type]: Argument 1 to "emit" has incompatible type "FileSink"; expected "SupportsWrite". The match is checked member by member, including method signatures and attribute types. Pyright reports the same rejection under reportArgumentType and, in strict mode, spells out exactly which member failed and why (for example "write" is incompatible: return type "None" is not assignable to "int").

The matching rules are stricter than “has an attribute of that name.” A method must be compatible in the sense of function subtyping: the parameter types are checked contravariantly and the return type covariantly, so a write that narrowed its parameter to bytes would not satisfy a Protocol asking for str. Attributes declared in a Protocol are treated as read-write by default, which makes them invariant — a subtype that offers a narrower attribute type will be rejected unless you mark the member read-only. You get a read-only attribute by declaring it as a @property (or by combining Final), which is the usual way to express “the object must expose a name, but callers only read it”:

from typing import Protocol

class Named(Protocol):
    @property
    def name(self) -> str: ...   # read-only: covariant, easier to satisfy

class User:
    name: str = "ada"           # a plain str attribute satisfies the property

def greet(x: Named) -> str:
    return f"hi {x.name}"

Because conformance is structural, a class can satisfy several unrelated Protocols at once without any of them appearing in its bases, and the standard library leans on exactly this: collections.abc.Iterable, Sequence, and friends double as both runtime ABCs and structural bases, so an object with __iter__ is accepted as Iterable[T] whether or not it inherits from anything. One thing structural matching does not give you is shared behaviour: if your Protocol declares a default method body, a conforming class does not automatically receive it. To inherit a default implementation you must subclass the Protocol explicitly — a Protocol can be used as a concrete base class, and only then does the class actually get the method. See Typing Collections and collections.abc for the standard-library duck types that are already Protocols.

Nominal: an ABC demands inheritance

An abstract base class defines the same surface, but conformance is by name. A class is an AbstractSink only if it inherits from it. In exchange, the ABC can carry concrete helper methods and enforce that abstract methods are implemented before instantiation. abc.ABC is a convenience base whose metaclass is abc.ABCMeta; inheriting from ABC is equivalent to writing class AbstractSink(metaclass=ABCMeta). That metaclass is what refuses to build an instance while any @abstractmethod remains unimplemented.

Nominal inheritance through an abstractmethod enforcement gate A subclass must inherit AbstractSink and implement every abstract method to pass the gate that ABCMeta imposes at instantiation. AbstractSink(ABC) @abstractmethod write NetworkSink(AbstractSink) def write(...): implemented BadSink(AbstractSink) write left abstract ABCMeta gate at instantiation: NetworkSink() ✓ BadSink() ✗ TypeError [abstract]
Membership is by inheritance, and the abstractmethod gate blocks incomplete subclasses at instantiation time.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from abc import ABC, abstractmethod

class AbstractSink(ABC):
    @abstractmethod
    def write(self, data: str) -> int: ...

    def write_line(self, data: str) -> int:   # shared implementation
        return self.write(data + "\n")

class NetworkSink(AbstractSink):
    def write(self, data: str) -> int:
        return len(data)

NetworkSink()             # fine — write is implemented

Forget to implement write and the class cannot be instantiated: mypy reports [abstract] (Cannot instantiate abstract class "NetworkSink" with abstract attribute "write") and CPython raises TypeError at runtime. That enforcement — plus the shared write_line — is exactly what a Protocol cannot give you. Note the division of labour: the static [abstract] error is emitted by the type checker before you run anything, while the TypeError: Can't instantiate abstract class ... with abstract method write is raised by ABCMeta.__call__ at runtime even if you never ran a type checker. The two backstops are independent, which is a large part of an ABC’s appeal.

ABCs give you three tools a Protocol has no equivalent for. First, shared concrete methods like write_line above: every subclass inherits real behaviour, not just a shape. Second, __init_subclass__, an ordinary hook (not typing-specific, added in Python 3.6) that runs whenever a subclass is created — handy for registering plugins or validating that a subclass set a required class attribute:

from abc import ABC, abstractmethod

class Plugin(ABC):
    registry: dict[str, type["Plugin"]] = {}

    def __init_subclass__(cls, *, name: str, **kw: object) -> None:
        super().__init_subclass__(**kw)
        Plugin.registry[name] = cls          # runs at class-creation time

    @abstractmethod
    def run(self) -> None: ...

class Echo(Plugin, name="echo"):
    def run(self) -> None: ...

Third, virtual subclasses via ABC.register(). Calling AbstractSink.register(SomeClass) makes issubclass(SomeClass, AbstractSink) and isinstance return True at runtime without any inheritance — this is how collections.abc.Sequence claims list and tuple. The important caveat for typing: mypy and pyright do not honour register() statically. A registered class is a runtime subtype only; the type checker still expects real inheritance, so register() buys you runtime isinstance behaviour but no static guarantee. For a wider tour of the standard collections.abc hierarchy, see Typing Collections and collections.abc.

isinstance and @runtime_checkable

A plain Protocol is a static-only construct; isinstance(obj, SupportsWrite) raises TypeError. Decorate it with @runtime_checkable to allow the check, but understand its limit: it verifies only that the named attributes exist, never their signatures or types.

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import Protocol, runtime_checkable

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

class Broken:
    write = "not callable"   # attribute exists, wrong kind

isinstance(Broken(), SupportsWrite)   # True at runtime — only presence is checked

An ABC’s isinstance is reliable by comparison, because membership is recorded on the class. If you need trustworthy runtime checks, an ABC (or an explicit registration) is the sounder tool.

Two further limits are worth committing to memory. First, @runtime_checkable is available for isinstance on any Protocol, but issubclass is only allowed against a non-data Protocol — one whose members are all methods. The moment a Protocol declares a plain data attribute, issubclass(cls, Proto) raises TypeError: Protocols with non-method members don't support issubclass(), because there is no reliable way to test an attribute’s presence on a class object rather than an instance. Second, the presence check really is just hasattr: it walks the required member names and confirms each exists, so a class that stores a non-callable under a method’s name, or that computes the attribute lazily in __getattr__, will still pass. That is why the Broken object below is reported as an instance despite being unusable.

runtime_checkable isinstance tests presence only, not shape A Broken object with a non-callable write attribute passes the hasattr presence check and returns True even though it is unsound. Broken() write = "text" isinstance(obj, SupportsWrite) hasattr(obj, "write")? presence only True (but unsound) signature never checked The runtime check confirms the name exists; it cannot confirm write is callable or typed correctly.
A runtime_checkable Protocol answers "does the name exist?", not "is the shape right?".
Runtime vs static analysis A @runtime_checkable Protocol's isinstance check inspects attribute *names* only, so Broken above passes at runtime even though mypy would reject it structurally. The static checker verifies signatures; the runtime check does not. Never rely on a runtime Protocol check to validate method shapes.

There is also a real runtime cost to be aware of on older interpreters: before Python 3.12, isinstance against a @runtime_checkable Protocol re-computed the member set on every call and could be markedly slow in hot paths; Python 3.12 rewrote the check to be substantially faster and to cache the members. If you are on 3.8–3.11 and calling such checks in a loop, hoist the result or prefer an ABC. runtime_checkable itself is importable from typing since 3.8 and from typing_extensions for earlier versions.

Choosing between them

Prefer a Protocol when the interface is a lightweight capability (“anything with .write”), when the implementers are outside your control, or when you want to avoid coupling callers to a class hierarchy. Prefer an ABC when you own the hierarchy, want to ship shared behaviour, need constructor or invariant enforcement, or depend on reliable isinstance. The two are not exclusive — a Protocol can describe the public capability while an ABC provides one concrete family that satisfies it.

Decision tree for choosing an ABC or a Protocol If you own the hierarchy, need shared implementation, or need reliable isinstance, choose an ABC; otherwise choose a Protocol. Own the type hierarchy? no yes use a Protocol Need shared implementation? no yes Need reliable isinstance? use an ABC no yes Protocol is fine use an ABC
Ownership, shared code, and runtime checks push you toward an ABC; everything else favours a Protocol.

A useful tie-breaker is who writes the implementers. If the conforming classes live in code you do not control — plugins, third-party adapters, standard-library objects — a Protocol is the only option that does not force those authors to import and inherit your base. If every implementer is yours and you want to hand them constructor logic, invariants enforced in __init__, or a write_line-style default, an ABC pays for its extra weight. A common mature design uses both layers: publish a Protocol as the contract callers depend on, and ship an ABC as one convenient starter implementation of that contract. Callers stay decoupled from the hierarchy while your own subclasses get the shared behaviour. Note also that a Protocol can itself be generic — class SupportsWrite[T](Protocol) on 3.12+ (PEP 695) — so choosing structural typing does not cost you parametrisation.

Common mistakes

Most Protocol-versus-ABC bugs come from expecting one mechanism to behave like the other — asking a Protocol for runtime guarantees, or asking an ABC to match on shape. The pairs below show the wrong instinct against the correct one.

Wrong versus right habits with Protocols and ABCs Each row pairs a common mistake with its correct counterpart across four Protocol and ABC pitfalls. ✗ wrong ✓ right ✗ isinstance on a bare Protocol ✓ add @runtime_checkable first ✗ trust runtime check for signatures ✓ let the static checker be the gate ✗ __init__ logic on a Protocol ✓ put constructor code in an ABC ✗ rely on register() statically ✓ inherit for static subtyping Pick the mechanism whose guarantees match what you actually need.
Each mistake maps to a correct habit — the fix is almost always "use the other mechanism, or add the missing decorator."
  • Calling isinstance on a bare Protocol: without @runtime_checkable you get TypeError: Instance and class checks can only be used with @runtime_checkable protocols. mypy also flags the call site with [misc]. Add the decorator, and remember it enables isinstance but not always issubclass (data-member Protocols reject issubclass).
  • Instantiating an ABC with a missing method: mypy reports [abstract] and Python raises TypeError. Implement every @abstractmethod in the concrete subclass. An abstract property or an abstract classmethod/staticmethod counts too — stack @abstractmethod innermost (closest to def) or the method will not be registered as abstract.
  • Expecting @runtime_checkable to verify signatures: it checks name presence only; a wrongly-typed attribute still passes isinstance. Keep the static check as your real gate, and treat the runtime check as a coarse “has these attributes” filter, not a validator.
  • Adding __init__ logic to a Protocol: Protocols are not meant to be instantiated or subclassed for behaviour; putting real constructor code there is an ABC’s job. mypy treats explicit Protocol instantiation as an error (Cannot instantiate protocol class "SupportsWrite"), and CPython raises TypeError at runtime for the same reason.
  • Assuming ABC.register() gives a static subtype: it only affects runtime isinstance/issubclass. Type checkers ignore it, so a registered class passed where the ABC is expected still needs real inheritance to satisfy mypy or pyright. Use register() for runtime interop, inheritance for static guarantees. For the neighbouring case of variance surprises, see mypy vs pyright on Protocol Variance.

FAQ

Can a class satisfy a Protocol and an ABC at once? Yes. A class can inherit an ABC (making it a nominal subtype) while also structurally matching an unrelated Protocol. Static checkers evaluate each relationship independently, so one implementation can be passed to functions typed either way.

Is a Protocol slower or heavier than an ABC? At runtime a Protocol usually costs nothing because the conformance check is erased; only @runtime_checkable isinstance calls do work. An ABC participates in the MRO and metaclass machinery, so it carries slightly more runtime weight — though rarely enough to matter.

Back to Protocol and Structural Subtyping