skip to content

Which methods does Python's `in` operator try when testing membership?

level: middleimportance: must knowfreq 55%

answer

  1. Three chances, tried in order
  2. One dunder covers both directions
  3. Falls back to iterating the object
  4. Then legacy integer indexing
  5. `__contains__`, `__iter__`, `__getitem__`

basics

~20 s

x in c calls c.__contains__(x) and coerces the result with bool(). If the type defines no __contains__, Python iterates it via __iter__, then falls back to integer indexing through __getitem__. not in negates the same lookup.

solid answer

~50 s

The membership operator resolves in three steps, and the method is looked up **on the type**, not on the instance dictionary. First Python tries `__contains__`; whatever it returns is coerced with `bool()`, so `in` always yields a real `True` or `False`. If the type has no `__contains__`, Python iterates the object through `__iter__` and compares each item, so any iterable supports `in` for free. Failing that it falls back to the legacy sequence protocol, calling `__getitem__` with 0, 1, 2, ... until `IndexError`. `not in` is a single operator with inverted result - there is no `__not_contains__` to implement. The semantics are per type: `in` on a mapping tests keys, on `str` or `bytes` it tests for a **subsequence** rather than an element, and on a sequence it compares with `x is e or x == e`, an identity shortcut that makes a NaN object a member of a list containing that very object.

code

python · 13 lines
python
class EvenNumbers:
    def __contains__(self, item):
        return isinstance(item, int) and item % 2 == 0


class Countdown:
    def __iter__(self):
        yield from (3, 2, 1)


print(4 in EvenNumbers(), 5 in EvenNumbers(), 5 not in EvenNumbers())
print(2 in Countdown())
print("ell" in "hello", 2 in {1: "a", 2: "b"})

go deeper

for a junior

Know that in tests membership, that on a mapping it checks keys and on a str it checks for a substring, and that not in is its negation. Being able to say __contains__ is the method behind it already puts you ahead.

for a middle

Explain the full resolution order - __contains__, then __iter__, then __getitem__ - the bool() coercion of the result, and that lookup happens on the type. Be ready to write a small class that answers membership without storing any items.

for a senior

Show the operational consequences: membership silently consuming a generator object before the real pass over it, an unhashable or mutated key going missing from a set, and str versus bytes left operands raising. These are the bugs that reach production.

for a principal

Own the API guidance for your codebase: which of your types should expose membership at all, whether a virtual container that computes membership is honest or surprising, and how the answer is documented so callers do not assume a cheap lookup where none exists.

## First, disambiguate the keyword `in` appears in two unrelated grammatical roles. In `for item in items:` it is part of the `for` statement's syntax and no membership test happens at all. Only in an expression position - `x in container`, `key not in mapping` - is it the membership *operator* described here. Interviewers sometimes probe that distinction directly. ## The three-step resolution Given `x in obj`, the interpreter looks for support on `type(obj)`, in this order: 1. **`__contains__`.** If the type defines it, Python calls `obj.__contains__(x)` and converts the result with `bool()`. That coercion is real: a `__contains__` returning a non-empty list makes `in` produce `True`, and the expression's type is always `bool`. 2. **`__iter__`.** With no `__contains__`, Python iterates the object and stops at the first item equal to `x`. Every iterable therefore supports `in` without writing anything. 3. **`__getitem__`.** Failing both, Python uses the old sequence protocol, requesting indices 0, 1, 2, ... until `IndexError` is raised, comparing as it goes. A class defining only `__getitem__` still answers membership questions. If none of the three exist, `in` raises `TypeError: argument of type 'X' is not iterable`. Two structural facts sit underneath this. Special methods are looked up on the **type**, so assigning a `__contains__` function onto an *instance* does not change `in`. And `not in` is a single operator token, not `not` applied to a separate expression: the compiler emits one membership operation with an inversion flag. You never implement a second method for it - defining `__contains__` gives you both `in` and `not in`. ## The iteration fallback has teeth Because step 2 *consumes* the object it iterates, membership on a one-shot iterator is destructive: ```python digits = iter([1, 2, 3, 4]) 2 in digits # True list(digits) # [3, 4] - the first two items are gone ``` This is a genuine production bug source: a generator object passed around, membership-tested for validation, then iterated for work, quietly loses its leading items. It also means `in` on a never-ending iterator loops forever when the item is absent. ## Semantics differ by type, and that is the interview trap * **Mappings** test **keys**, never values. `2 in {1: "a", 2: "b"}` is `True`; `"b" in {1: "a", 2: "b"}` is `False`. This is why `in` on a mapping is a hash lookup, and why `if key in mapping` is the idiomatic guard. * **`str` and `bytes`** test for a **subsequence**, not an element. `"ell" in "hello"` is `True`. These types are also strict about the left operand: `1 in "abc"` raises `TypeError: 'in <string>' requires string as left operand, not int`, and mixing `str` with `bytes` raises too - a real trap in code that has not settled which of the two it is handling. * **Sets and mappings** find the item by hash, so membership depends on `__hash__` and `__eq__` agreeing; an object mutated after insertion can become unfindable in a container that still holds it. * **Sequences** compare with `x is e or x == e` - identity is checked first. That shortcut is why a float NaN object is `in` a list holding that same object, even though `n == n` is `False`. ## Implementing it yourself Any class can answer membership questions by defining `__contains__`, including one that holds no items at all - a virtual container that computes the answer: ```python class EvenNumbers: def __contains__(self, item): return isinstance(item, int) and item % 2 == 0 4 in EvenNumbers() # True 5 not in EvenNumbers() # True ``` Return a real `bool` even though Python will coerce for you; a caller that inspects the method directly gets a surprise otherwise. If your class is conceptually a container, registering with or inheriting from `collections.abc.Container` documents that intent and gives type checkers something to work with, but it is not required for `in` to function - the protocol is duck-typed. ## The function form `operator.contains(container, item)` is the callable equivalent, useful with higher-order code. Note the argument order is container-first, the reverse of how the operator reads, which makes it a frequent source of transposed arguments in `filter` and `map` calls.

  • What does `in` return if `__contains__` returns something that is not a bool?
    The result is coerced with `bool()`, so `in` still evaluates to `True` or `False`. A `__contains__` returning a non-empty list yields `True`, an empty one `False`, and the expression's type is always `bool`. Returning a genuine bool is still the right practice, because code that calls the method directly sees whatever you returned.
  • Why does `'b' in {1: 'a', 2: 'b'}` evaluate to False?
    Membership on a mapping tests **keys**, not values, so it asks whether `'b'` is a key - it is not. To search values you need `'b' in d.values()`, which is a linear scan over a view rather than a hash lookup. Confusing the two is one of the most common membership bugs in review.
  • What happens when you test membership against a class that defines only `__getitem__`?
    Python falls back to the legacy sequence protocol: it calls `__getitem__` with 0, 1, 2, ... comparing each result to the item and stopping when `IndexError` is raised. So an old-style sequence supports `in` without defining `__contains__` or `__iter__` - and it will loop indefinitely if the indexing never raises.
  • Do you need to implement anything extra to support `not in`?
    No. `not in` is a single operator whose result is the inverse of the membership test, compiled as one operation with an inversion flag. There is no separate method to define - implementing `__contains__` gives you both directions - and you cannot make `x not in c` mean anything other than the negation of `x in c`.

saying these in an interview costs you the question

  • Says `in` on a dict searches the values
  • Believes `not in` calls a separate `__not_contains__` method
  • Thinks a class must subclass a built-in container to support `in`
  • Looks up `__contains__` on the instance rather than the type
  • Unaware that `in` on a `str` tests substrings, not characters only
  • Does not realise membership can consume a one-shot iterator

context