An inventory sync runs isinstance() against a runtime_checkable Protocol per record and has slowed; what do you change?
answer
- The check is doing repeated identical work
- Ask what actually varies per record
- Attribute walk, not pointer comparison
- Validate once at the boundary
- Nominal checks consult inheritance instead
basics
~20 sHoist the check out of the per-record loop: the adapter's shape does not change between records, so validate it once at the boundary. A protocol check is an attribute walk per member, far dearer than a nominal isinstance against a concrete class.
solid answer
~40 sA runtime-checkable protocol check is not a pointer comparison. Each call resolves every member name the protocol declares, using `inspect.getattr_static()` since Python 3.12, and for a protocol with data members that work repeats on every call because conformance depends on the individual object. Timed against `isinstance()` on a concrete class it lands roughly an order of magnitude slower. The fix is structural, not micro-optimisation: the feed object's shape is invariant across records, so check it once when the adapter is selected and pass a validated handle into the loop. If a per-item check is genuinely required, make it nominal — have implementations subclass the protocol, or use an `abc.ABC` and `abc.ABCMeta.register` for classes you do not own — so the check consults the inheritance graph instead of walking attributes.
code
python · 22 linesimport timeit
from typing import Protocol, runtime_checkable
@runtime_checkable
class InventoryFeed(Protocol):
cursor: int
def fetch(self, since: int) -> list[int]: ...
class WarehouseFeed:
def __init__(self) -> None:
self.cursor = 0
def fetch(self, since: int) -> list[int]:
return [since + 1]
feed = WarehouseFeed()
print("protocol", timeit.timeit(lambda: isinstance(feed, InventoryFeed), number=50000))
print("concrete", timeit.timeit(lambda: isinstance(feed, WarehouseFeed), number=50000))go deeper
Recall that a runtime protocol check does real work per call — it looks up every member the protocol declares — so it does not belong inside a tight loop when the object being checked never changes.
Explain the mechanics: a per-member static attribute lookup, cost scaling with the protocol's member count, and per-instance work whenever the protocol declares data members rather than only methods.
Show the diagnosis-then-fix habit: measure, notice the check is invariant across records, hoist it to the boundary, re-measure. Then weigh nominal alternatives, including what abc registration gives up.
Own the tradeoff and the delivery path: whether adapters must inherit a base class is a cross-team API decision, and a change of that shape belongs on a planned release rather than a hotfix, unlike a contained hoist.
### Why the check is not free `isinstance(obj, C)` against an ordinary class is close to free: the interpreter walks a short inheritance chain. A runtime-checkable protocol takes a different route. It resolves each member name the protocol declares against the object, using `inspect.getattr_static()` on Python 3.12 and later, which searches the type, its bases and the instance `__dict__` without invoking descriptors or `__getattr__`. Two things follow. The cost scales with the number of members on the protocol, not with the depth of an inheritance chain. And when the protocol declares data members, conformance genuinely depends on the individual object — one instance may have set the attribute in `__init__` and another may not — so per-object work is unavoidable. A quick `timeit` comparison on 3.14 puts a data-member protocol check roughly an order of magnitude above a method-only protocol check and far above a plain concrete-class check. At one check per record in an import loop, that is real time; at one check per adapter it is invisible. ### The diagnosis Confirm before changing anything. Profile the loop, or time the check directly with `timeit` against a representative object, and compare with the same loop with the check hoisted. What you should find is that the check is invariant: the feed object is chosen once per sync run, and its shape cannot change between records. A per-record check is answering the same question thousands of times. ### The fixes, strongest first 1. **Move the check to the boundary.** Validate the adapter once, where it is selected or constructed, and let the loop assume a validated object. This is the whole fix in most real cases, it costs nothing, and it also improves the error: a bad adapter is rejected before any record is touched, rather than partway through a run. 2. **Make the per-item check nominal, if one is truly needed.** Where the loop handles heterogeneous items and must branch, have the implementations inherit from the protocol so `isinstance()` succeeds through the inheritance graph without attribute inspection, and the type checker validates each implementation at its own definition. 3. **`abc.ABCMeta.register` for classes you do not own.** Declaring an `abc.ABC` and registering third-party classes with `register()` gives a nominal check for types you cannot modify. The trade is explicit: registration is a claim, not a verification — nothing checks that the registered class has the methods — and a static type checker does not follow the registration, so you have moved the guarantee from the checker into a line of runtime code you must maintain. 4. **Drop the runtime check entirely.** If the static checker already proves the adapter satisfies the protocol at every call site, the runtime check is duplicating work that the checker did for free in CI. Keep it only at genuinely untyped boundaries — plugins, deserialized input. ### The trap to avoid Do not "optimise" by caching the check result in a dictionary keyed by `type(obj)`. For a method-only protocol that is roughly what the machinery already does; for a data-member protocol it is *wrong*, since two instances of the same class can differ in whether the attribute was assigned. Silent wrong answers under load are a far worse outcome than the microseconds saved. ### The reporting angle There is a second-order judgment to state out loud. If the next release train is three weeks out, a hot-path fix that is a one-line hoist with an unchanged public contract can ship on the normal cadence; anything that changes the protocol's shape, or that asks every adapter author to add a base class, is a coordinated change across teams and belongs in a planned release rather than a hotfix. The engineering choice and the delivery choice are separate decisions, and a senior answer names both.
- What does abc.ABCMeta.register verify about the class being registered?Nothing. It records a claim that the class is a virtual subclass, so `isinstance()` and `issubclass()` answer True, and that is all. A registered class missing a required method fails at the call, not at registration. Static type checkers do not follow registrations either, so the guarantee you had from structural checking is traded for a runtime assertion you maintain by hand.
- Why is caching the protocol check keyed on type(obj) unsafe?Because for a protocol with data members conformance is a property of the instance, not the class. Two instances of one class differ whenever the attribute is assigned conditionally in `__init__` or deleted later, so a type-keyed cache returns a confidently wrong answer. Only a protocol whose members are all defined in class bodies could be cached that way, and there the win is small.
- How would you demonstrate that the check, and not the work in the loop, is the cost?Profile the loop, then time the check in isolation with `timeit` against a representative object and multiply by the record count. If the product is a meaningful share of the loop's runtime, hoist the check and re-measure. Reporting the before-and-after number is what turns a plausible explanation into a verified fix.
saying these in an interview costs you the question
- Assuming a protocol isinstance costs the same as a class check
- Caching protocol results keyed on the object's type
- Reaching for micro-optimisation before hoisting the invariant check
- Treating abc register as verification of the registered class
- Claiming the check compares signatures and is therefore slow