We can easily see the sequence of events by using a little helper function foo:
def foo():
for i in l:
l.pop()
and dis.dis(foo) to see the Python byte-code generated. Snipping away the not-so-relevant opcodes, your loop does the following:
2 LOAD_GLOBAL 0 (l)
4 GET_ITER
>> 6 FOR_ITER 12 (to 20)
8 STORE_FAST 0 (i)
10 LOAD_GLOBAL 0 (l)
12 LOAD_ATTR 1 (pop)
14 CALL_FUNCTION 0
16 POP_TOP
18 JUMP_ABSOLUTE 6
That is, it get's the iter for the given object (iter(l) a specialized iterator object for lists) and loops until FOR_ITER signals that it's time to stop. Adding the juicy parts, here's what FOR_ITER does:
PyObject *next = (*iter->ob_type->tp_iternext)(iter);
which essentially is:
list_iterator.__next__()
this (finally*) goes through to listiter_next which performs the index check as @Alex using the original sequence l during the check.
if (it->it_index < PyList_GET_SIZE(seq))
when this fails, NULL is returned which signals that the iteration has finished. In the meantime a StopIteration exception is set which is silently suppressed in the FOR_ITER op-code code:
if (!PyErr_ExceptionMatches(PyExc_StopIteration))
goto error;
else if (tstate->c_tracefunc != NULL)
call_exc_trace(tstate->c_tracefunc, tstate->c_traceobj, tstate, f);
PyErr_Clear(); /* My comment: Suppress it! */
so whether you change the list or not, the check in listiter_next will ultimately fail and do the same thing.
*For anyone wondering, listiter_next is a descriptor so there's a little function wrapping it. In this specific case, that function is wrap_next which makes sure to set PyExc_StopIteration as an exception when listiter_next returns NULL.