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.
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
- You pass an object with extra properties to something typed to accept fewer. When does TypeScript complain, and when does it not?mediumAlso on structural-typing4 min
- Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?hardAlso on interfaces4 min
- If you were writing a small value type in Python, which dunder methods would you implement, what would you let a dataclass generate, and how does super() decide what to call?mediumAlso on protocols4 min
- When would you choose UDP over TCP, given that TCP is reliable and UDP is not?mediumAlso on protocols4 min
- Design a parking lot system — you have 90 minutes, working code at the end.hardAlso on interfaces5 min
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on interfaces5 min
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumSame kind of round: concept5 min
- A promise rejects and nothing is awaiting it. What does Node do?hardSame kind of round: concept4 min