Skip to content
Preptima
hardConceptMidSeniorStaff

When would you reach for an abstract base class rather than a Protocol, and what does isinstance check in each case?

An ABC is nominal: you inherit from it and instantiation fails until every abstract method is overridden. A Protocol, available since Python 3.8, is structural and checked by the type checker; making it runtime-checkable only tests that the members exist, not that their signatures match.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate distinguish nominal from structural typing rather than treating both as interfaces
  • Whether ABC enforcement is correctly placed at instantiation rather than at class definition
  • That a runtime-checkable protocol verifies member presence only, and the answer says why that matters
  • Whether the protocol is declared on the consumer's side rather than in the implementer's package
  • Does the candidate say when neither construct earns its place

Answer

Two different claims about a type

An abstract base class makes a nominal claim: this class is a PaymentGateway because it inherits from PaymentGateway. A protocol makes a structural one: this class is usable as a payment gateway because it has the methods a payment gateway needs, whether or not it has ever heard of your abstraction.

The distinction decides who has to know about whom. An ABC requires the implementer to import your base class and declare the relationship, which is fine inside one codebase and impossible for a third-party class you do not control. A protocol requires nothing of the implementer at all, which is why it is the right tool for describing something that already exists — a file-like object, a third-party client, a stub in a test — and for keeping a module from depending on the package that defines its collaborators.

What an ABC enforces, and the moment it fires

Inheriting from abc.ABC and decorating methods with @abstractmethod gives you a real runtime check. It is worth being precise about when it happens, because candidates routinely get this wrong: defining an incomplete subclass is perfectly legal, and the error appears the first time you try to construct one.

class Gateway(abc.ABC):
    @abc.abstractmethod
    def charge(self, amount: int) -> str: ...

class Stripe(Gateway):
    pass          # defining this raises nothing at all

Stripe()          # TypeError: abstract method charge is not implemented

That timing is a design consequence rather than a quirk. It allows intermediate abstract classes that implement some methods and leave others open, and it means the guarantee you get is that any instance which exists has all the methods. What it does not check is signatures: a subclass whose charge takes different parameters satisfies the ABC completely, and the mismatch surfaces at the call site.

The ABC's second and often better-value feature is inherited behaviour. collections.abc is the canonical example: implement __getitem__, __len__ and __iter__, inherit from Mapping, and you are given get, keys, items and values for free. A protocol cannot match that, because it describes an interface rather than supplying an implementation.

ABCMeta.register lets you declare an existing class a virtual subclass without touching it, which makes isinstance say yes. It checks nothing whatsoever, so it is an escape hatch for adapting third-party types rather than a safety mechanism.

What a Protocol enforces, and where

typing.Protocol arrived in Python 3.8. A protocol is primarily a static construct: you declare the shape you consume, and a type checker verifies at analysis time that every argument passed in has matching members with compatible signatures. That signature checking is the part an ABC cannot do, and it is checked before the code runs rather than at the moment of a mismatched call.

Because it is structural, the protocol belongs with the code that consumes it. A module that needs somewhere to store bytes declares a two-method protocol next to its own function signature, and the storage classes elsewhere satisfy it without importing anything. Do this and your dependency graph points from implementation towards interface without the interface living in a shared package that everything depends on.

Protocols can also carry default implementations, but they are only inherited if a class explicitly subclasses the protocol, which is the point at which you have chosen nominal typing again and should probably have used an ABC.

The runtime check that agrees too easily

Adding @runtime_checkable permits isinstance against a protocol, and this is where a strong answer separates itself, because the check is much weaker than it appears. It verifies that the named members exist on the object. It does not look at their parameters, their return types, or whether they are even callable with the arguments you are about to pass.

@typing.runtime_checkable
class Closable(typing.Protocol):
    def close(self) -> None: ...

class Ticket:
    def close(self, reason: str) -> None: ...   # unrelated meaning, wrong arity

isinstance(Ticket(), Closable)   # True

So a runtime protocol check is a duck-typing convenience, not a validation, and using it as a gate before calling into an object gives you confidence you have not earned. There is a related rule worth knowing: issubclass is rejected for a protocol that declares non-method members, because a class object cannot be inspected for instance attributes that only exist after __init__ has run. The library refuses rather than answering wrongly, which is the right choice and surprises people.

Choosing between them

Reach for an ABC when you own the hierarchy and want the runtime guarantee, when there is shared behaviour the base class should supply, or when the abstraction is a genuine taxonomic statement about your domain that you want implementers to opt into deliberately. Reach for a protocol when you are describing something you do not control, when the implementers should not depend on you, when signature checking matters more than an instantiation-time error, or when you want to type a duck-typed interface that has been working for years without naming it.

They also compose. A protocol for the consumer's expectations and an ABC providing a convenient default implementation for the common case is a reasonable pairing, and it lets an unusual implementer satisfy the interface without inheriting anything.

Where neither is worth it

The failure mode this question is really probing is the reflex to create an abstraction for a single implementation. An ABC with one subclass, or a protocol with one implementer, is an extra file and an extra hop that buys nothing: it does not enable substitution that anybody performs, it does not make the code testable that was not already testable, and it doubles the number of places to read when following a call. Python's dynamism means you can introduce either one later without changing any call site, so the honest position is that a second implementer, or a test double that needs to be checkable, is what justifies the interface. Being willing to say that in an interview reads as judgement rather than as ignorance of the tools.

An ABC is a runtime promise about inheritance that fires at instantiation and cannot see signatures. A protocol is a static promise about shape that can, and its runtime check only asks whether the names exist. Pick by who must depend on whom.

Likely follow-ups

  • Why does an abstract method raise at instantiation rather than when the subclass is defined?
  • What does ABCMeta.register buy you, and what does it deliberately not check?
  • Why is issubclass rejected for a protocol that declares data attributes?
  • How do the mixin methods in collections.abc change the argument for inheriting from one?

Related questions

Further reading

abstract-base-classesprotocolsstructural-typingisinstanceinterfaces