Feb 12 2026
Table of contents
- Chained assignment has a footgun
- Inspecting the bytecode
- Understanding the bytecode
- Doing it the right way
- References
In Python, chained assignment has a subtle (though well-known) footgun. The following function returns True:
def example():
a = b = [] # <-- oops!
a.append(1) # b gets modified as well
return b == [1] # True
I've known of this behavior for a long time, but every once in a while it catches me unaware. The problem is that in the line a = b = [], a Python list object is constructed once and assigned to both variables. When a.append(1) is called, the underlying list is modified, and b points back to that common underlying list. This is a bug if your intent was for a and b to refer to separate lists.
The most recent time I shot myself in the foot with this bug, I got curious: I know what happens at a semantic level when I write a = b = [], but what happens at the bytecode level? A quick Google search led me to the dis module, which allows you to inspect disassembled CPython bytecode.
Inspecting the bytecode🔗
Let's write a simple program in a new file chained-assignment-example.py using the dis module...
import dis
def example():
a = b = []
dis.dis(example)
...and then run python chained-assignment-example.py on it. On my machine, running Python 3.12.6, the output is:
3 0 RESUME 0
4 2 BUILD_LIST 0
4 COPY 1
6 STORE_FAST 0 (a)
8 STORE_FAST 1 (b)
10 RETURN_CONST 0 (None)Understanding the bytecode🔗
At this point, we're fairly close to understanding what chained assignment looks like at the bytecode level. A few important concepts we'll need to know:
- CPython is a stack-based virtual machine (VM). As it happens, the CPython implementation consists of two stacks, one of which is the evaluation stack, where data lives. In the remainder of this post, when I say "stack" I mean the evaluation stack. The other one is the block stack, which isn't relevant to this discussion.
- The numbers in the leftmost column refer to the source code line numbers. We asked for the disassembled bytecode for the
examplefunction, so our first line number was3, which matches what we wrote in our source file. - The blocks next to the source code line numbers are the bytecode instructions corresponding to each line. So for example, line
4, which is where we do our chained assignment, expands out into four bytecode instructions. - The numbers in the second column are the byte offsets into the generated bytecode. So for example, the fifth (index = 4) byte in our generated bytecode is the
COPYinstruction. It's not a coincidence that all the byte offsets are even. Since Python 3.6, every Python instruction has been given an odd number of arguments (even if it doesn't need any) so that the byte offsets are always even. - The numbers in the third column are instruction arguments, and so their meaning depends on the instruction itself.
- Each function object has a
__code__attribute that stores all the information the Python VM needs in order to execute that function. This code object contains, among other things, a tuple ofvarnameswhich is all the local variable names in the function. To access it, you can use the expressionexample.__code__.co_varnames. In our example, this evaluates to(a, b).
Let's go over the instructions for line 4 (a = b = []) one-by-one.
BUILD_LIST 0
BUILD_LIST N pops N items from the stack, turns them into a list, and then pushes a C pointer to the resultant list onto the evaluation stack (see code). To precise, it creates a PyListObject, casts it to a PyObject, and then returns it (see code). In this case, we have BUILD_LIST 0 which creates an empty list and pushes it onto the stack.
COPY 0
COPY N copies the N-th last item from the stack and pushes it onto the stack. In this example we have COPY 1 which means we copy the 1-th last item (i.e. the item at the top, the reference to the list) and push it to the top of the stack. So we now have two references to the same list on the stack.
STORE_FAST 0
STORE_FAST N pops the stack and stores the popped value into the N-th varname. In this example we have STORE_FAST 0. What's the 0-th varname? Recall that example.__code__.co_varnames was (a, b). So the 0-th varname is just a, which the dis.dis() function has already helpfully identified for us!
So now a refers to the newly created list.
STORE_FAST 1
The next instruction is STORE 1. Again, we pop the stack but this time we store the popped value — which is a pointer to the above list — in the 1-th varname, which is the variable b.
So now b refers to the same list object as before.
Doing it the right way🔗
The crux of our analysis is that in the compiled bytecode for the example() function there was only a single BUILD_LIST instruction. That means we only allocated a single PyListObject on the heap, which the Python VM then dutifully assigned to two different variable names. A recipe for subtle and annoying behavior that might cause someone to waste 10 minutes of their time. Not that I'd know anything about that.
What if we didn't use chained assignment? As above, let's write some code and inspect its disassembled bytecode. In a new file regular-assignment.py:
import dis
def example():
a = []
b = []
dis.dis(example)
Running python regular-assignment.py, we get:
3 0 RESUME 0
4 2 BUILD_LIST 0
4 STORE_FAST 0 (a)
5 6 BUILD_LIST 0
8 STORE_FAST 1 (b)
10 RETURN_CONST 0 (None)
There are two distinct BUILD_LIST instructions in the output bytecode, each followed by a STORE_FAST instructions! Two distinct list objects will be heap-allocated, each referred to by two different variable names.
References🔗
- While reading up on the Python VM I found James Bennett's PyCon 2018 talk to be very educational. He briefly discusses what a virtual machine is in the context of CPython, talks about how one can use the
dismodule to inspect the internals (consts, varnames, names and code) of a function object, and then walks the listener through some of the disassembled bytecode for a simple Fibonacci number function. A must-watch, IMO. - This section in the dis module docs has explanations for each bytecode instruction, along with some nice pseudocode to illustrate how the instructions actually work.