Here’s a quirky little piece of Python code:

1
2
3
4
5
6
7
8
9
class countdown:
	def __init__(self, n):
		self.n = n

	def __getitem__(self, k):
		if v := self.n - k:
			return print(v),

print("rocket launching 🚀") in countdown(10)

What does it output, and why?

Output
$ python3 countdown.py
rocket launching 🚀
10
9
8
7
6
5
4
3
2
1
Hint 1

print returns None, so after the initial log it remains to evaluate None in countdown(10).

Hint 2

Suppose X is a list. What does None in X do internally? By analogy, what might None in countdown(10) do internally?

Explanation

Note first that print("rocket launching 🚀") returns None, after which it remains to evaluate None in countdown(10).

Since countdown objects don’t define __contains__(), Python tries to iterate over countdown(10), viewed as a sequence, until it finds a match for None. However, countdown doesn’t define __iter__() either, so what exactly determines how iteration proceeds?

It turns out that, in the absence of __iter__(), Python falls back to using the so-called “old-style iteration protocol” in which

given C = countdown(10),

    iter(C)

implicitly corresponds to the sequence

    C.__getitem__(0),
    C.__getitem__(1),
    C.__getitem__(2),
    C.__getitem__(3),
    ...

That is, since countdown objects define __getitem__ but not __iter__, Python assumes that countdown can be treated as a sequentially indexed sequence! Thus, to determine whether None in countdown(10), Python invokes __getitem__(k) on countdown(10) with indexes $k = 0, 1, 2, \dots$ in order until it encounters None or an IndexError.

Recall now that countdown.__getitem__ is defined by

1
2
3
4
def __getitem__(self, k):
	if v := self.n - k:
		return print(v),
	# implicit `return None`

For $k = 0, 1, 2, \dots, 9$, the number v := self.n - k = 10 - k takes on the values $v = 10, 9, \dots, 1$. Each of these values is nonzero, so the if succeeds and $v$ is printed. Then, since print returns None, __getitem__ returns the 1-tuple (None,) (note the trailing comma on line 3!) Since (None,) != None, Python continues iterating.

On the other hand, when $k = 10$, the number v := self.n - k = 10 - 10 is zero and hence falsy, so None is implicitly returned. Now that None has been found in the sequence, Python stops iterating and the expression None in countdown(10) evaluates to True. (This value is then thrown away.)

Isn’t that fun? :)


For some backstory, I learned about Python’s old-style iteration protocol via reading issue #137473 in the CPython repository on a particularly slow afternoon, and subsequently contorted it into this puzzle. The precise behavior of the in operator abused here is specified by the second-last paragraph of Section 6.10.2: Membership test operations of the Python reference:

Lastly, the old-style iteration protocol is tried: if a class defines __getitem__(), x in y is True if and only if there is a non-negative integer index i such that x is y[i] or x == y[i], and no lower integer index raises the IndexError exception. (If any other exception is raised, it is as if in raised that exception).

How do LLMs do on this puzzle?

I provided the first two models I thought of with the Python program here, and asked it to predict and explain the runtime behavior. (By no means do I believe this is a fair question; I just thought it’d be fun.)

The free version of GPT-5 one-shots my question and correctly explains what’s going on. I’m impressed!

Claude Sonnet 4 (also free) gets pretty close, but erroneously claims that the code errors at the end with a bogus argument:

[…] when __getitem__ returns None (at k=10), Python tries to iterate over None to continue the membership test, causing the error.

When I hint that its answer is incorrect without further elaboration, it hallucinates more.

I expect Opus 4.1 does better and would be a more fair comparison with GPT-5, but did not test it.

I also expect that nearly all new models would explain the behavior correctly if provided the output (or, equivalently, were able to run the code), but did not test this hypothesis either.