How global variables work in Python bytecode
Think globally, act locally. Who knew this oft-touted phrase was referring to Python bytecode? Last time we acted on learning how local variables work, today let’s think about how global variables might work. I came into my own bytecode journey assuming that a “global” was no different than a “local,” it was just at the outermost scope of a module. And boy, was I mistaken. While the VM isn’t even aware of a local variable’s name, just its index, the VM performs dynamic name resolution to resolve each global variable. This is a key part of Python’s dynamism. Once again, this post will discuss the mechanics of Memphis, my Python interpreter built in Rust. While it doesn’t match CPython 100%, the model is simple and maps closely to how most Python runtimes behave under the hood. Bytecode Compilation Consider the following Python program. # A global variable which we'll later reference inside our function y = 11 # A function which adds an unknown number (via global var) to the input # parameter x, returning the result. def add_useless(x): return x + y Here is the bytecode for our function, add_useless: LOAD_FAST 0 (x) LOAD_GLOBAL 0 (y) ADD RETURN_VALUE Our CodeObject looks like the one below. Again, pardon my pseudo-Rust syntax! CodeObject { name: "add_useless", varnames: ["x"], // names of each local accessed by the function names: ["y"], // names of each global accessed by the function } We don’t see a constant of 11 here because this code object is specific to our add_useless function. The function will have to look up that value at runtime via the global store, since it isn’t embedded in the code object itself. If you haven’t read the previous post, I’d encourage you to pause here and give it a read. It walks through the evaluation stack step-by-step and shows how the ADD operation gets its two operands off the stack. VM Execution What we’re interested in today is what, exactly, LOAD_GLOBAL is doing. First, let’s define a runtime concept I blew past earlier. Global Store: a mapping from a variable name to an object reference. The object being referenced lives on the heap. This is roughly what you get when you call globals() in Python or CPython. Let’s see what happens when the VM begins executing the bytecode. Like last time, we’ll ignore how the bytecode actually enters the function. LOAD_FAST 0 means: read from slot 0 on the frame’s execution stack. LOAD_GLOBAL 0 means: look up the identifier in names at index 0. It finds y, then goes to the global store to look for the key y, where it finds an object reference to the value of 11. The remaining instructions, ADD and RETURN_VALUE, work just as they did in the previous post: the VM pops two operands off the stack, computes the result, and returns it. You may have noticed: global variables don’t need to be defined when the function is defined, only when it is called. If y doesn’t exist at function call time, a NameError will be thrown. This differs from local variables, which must exist at the time they are first referenced. Otherwise, the bytecode compiler will assume you are referring to a global with that same name. See how there is an extra level of indirection when resolving the global compared to the local? This is key to Python’s dynamism. Here’s an example to illustrate this. y = 11 def add_useless(x): return x + y print(add_useless(9)) # prints 20 y = 1 print(add_useless(9)) # prints 10 This example highlights the perils of impure functions, but otherwise might not seem that surprising at first. Before we continue, it’s worth mentioning that the global store is specific to the module, while the heap is shared across modules. This means that multiple modules can have a global named y, which matches what we expect as users. Imagine having to know if every other module in your project, including those from third-party libraries, had previously used a global identifier? That would be a nightmare! Global Store Mutations To further illustrate how globals can be modified, let’s consider two more examples. These below move away from my Memphis implementation and focus on how dynamic global behavior shows up in CPython. a = "First" globals()['a'] = "Second" print(a) # prints "Second" This one accomplishes something similar to the previous example (reassigning a global), but by mutating the global store directly via globals(). We see here that a doesn’t point to a fixed memory location, but is resolved dynamically by name at runtime. To take it one step further, consider the same idea applied across modules. We’ll also reassign a function rather than an integer. # main.py import other def monkey_patch_foo(): print("Got ya!") other.foo = monkey_patch_foo other.bar() # prints "Got ya!" # other.py def foo(): print("I am foo.") def bar(): foo() Our main module modifies the behavior of a function defined in another modul

Think globally, act locally. Who knew this oft-touted phrase was referring to Python bytecode?
Last time we acted on learning how local variables work, today let’s think about how global variables might work.
I came into my own bytecode journey assuming that a “global” was no different than a “local,” it was just at the outermost scope of a module. And boy, was I mistaken.
While the VM isn’t even aware of a local variable’s name, just its index, the VM performs dynamic name resolution to resolve each global variable. This is a key part of Python’s dynamism.
Once again, this post will discuss the mechanics of Memphis, my Python interpreter built in Rust. While it doesn’t match CPython 100%, the model is simple and maps closely to how most Python runtimes behave under the hood.
Bytecode Compilation
Consider the following Python program.
# A global variable which we'll later reference inside our function
y = 11
# A function which adds an unknown number (via global var) to the input
# parameter x, returning the result.
def add_useless(x):
return x + y
Here is the bytecode for our function, add_useless
:
LOAD_FAST 0 (x)
LOAD_GLOBAL 0 (y)
ADD
RETURN_VALUE
Our CodeObject
looks like the one below. Again, pardon my pseudo-Rust syntax!
CodeObject {
name: "add_useless",
varnames: ["x"], // names of each local accessed by the function
names: ["y"], // names of each global accessed by the function
}
We don’t see a constant of 11 here because this code object is specific to our add_useless
function. The function will have to look up that value at runtime via the global store, since it isn’t embedded in the code object itself.
If you haven’t read the previous post, I’d encourage you to pause here and give it a read. It walks through the evaluation stack step-by-step and shows how the ADD
operation gets its two operands off the stack.
VM Execution
What we’re interested in today is what, exactly, LOAD_GLOBAL
is doing. First, let’s define a runtime concept I blew past earlier.
Global Store: a mapping from a variable name to an object reference. The object being referenced lives on the heap. This is roughly what you get when you call globals()
in Python or CPython.
Let’s see what happens when the VM begins executing the bytecode. Like last time, we’ll ignore how the bytecode actually enters the function.
-
LOAD_FAST 0
means: read from slot 0 on the frame’s execution stack. -
LOAD_GLOBAL 0
means: look up the identifier innames
at index 0. It findsy
, then goes to the global store to look for the keyy
, where it finds an object reference to the value of 11. - The remaining instructions,
ADD
andRETURN_VALUE
, work just as they did in the previous post: the VM pops two operands off the stack, computes the result, and returns it.
You may have noticed: global variables don’t need to be defined when the function is defined, only when it is called. If y
doesn’t exist at function call time, a NameError
will be thrown. This differs from local variables, which must exist at the time they are first referenced. Otherwise, the bytecode compiler will assume you are referring to a global with that same name.
See how there is an extra level of indirection when resolving the global compared to the local? This is key to Python’s dynamism. Here’s an example to illustrate this.
y = 11
def add_useless(x):
return x + y
print(add_useless(9)) # prints 20
y = 1
print(add_useless(9)) # prints 10
This example highlights the perils of impure functions, but otherwise might not seem that surprising at first.
Before we continue, it’s worth mentioning that the global store is specific to the module, while the heap is shared across modules. This means that multiple modules can have a global named y
, which matches what we expect as users. Imagine having to know if every other module in your project, including those from third-party libraries, had previously used a global identifier? That would be a nightmare!
Global Store Mutations
To further illustrate how globals can be modified, let’s consider two more examples. These below move away from my Memphis implementation and focus on how dynamic global behavior shows up in CPython.
a = "First"
globals()['a'] = "Second"
print(a) # prints "Second"
This one accomplishes something similar to the previous example (reassigning a global), but by mutating the global store directly via globals()
. We see here that a
doesn’t point to a fixed memory location, but is resolved dynamically by name at runtime.
To take it one step further, consider the same idea applied across modules. We’ll also reassign a function rather than an integer.
# main.py
import other
def monkey_patch_foo():
print("Got ya!")
other.foo = monkey_patch_foo
other.bar() # prints "Got ya!"
# other.py
def foo():
print("I am foo.")
def bar():
foo()
Our main
module modifies the behavior of a function defined in another
module, and that change affects the other
module itself. Said another way: you can change the behavior of code from the outside, and the function itself doesn’t know.
This is referred to as monkey patching and is one of the most mind-bending things about Python. Monkey patching is often used to inject patch fixes or replace functionality at runtime. And it’s only possible because of Python’s dynamic name resolution for global variables.
To be transparent, Memphis doesn’t fully support this yet! I'm still working through object mutability and shared references, and how these vary across my treewalk implementation and bytecode VM. Monkey patching: WIP.
You might wonder why Python treats locals and globals so differently in bytecode.
The reason is to balance performance and flexibility. Functions are called frequently during the lifetime of a program, its locals are accessed often, so Python optimizes for speed using slot-based indexing. At the module level, the dynamic name resolution introduces a performance hit, but the language is more dynamic as a result.
The End
I didn’t fully appreciate this design until I implemented it myself. You really start to see how much of Python’s feel comes from how its variables are resolved in bytecode.
If you’re curious to explore more, I recommend trying out the dis module to inspect your own functions! It’ll be confusing at first, but you may begin to pick up one new tidbit each time.
Now that we’ve tackled locals and globals, we’re free to think about free variables next! I hope you’ll stay tuned.
Lastly, I’m continuing to experiment with open office hours this week. If you’re stuck in Python or Rust and want to talk through it live, here’s the booking link. The slots are pay-what-you-want, with zero pressure to tip. I’d love to meet you!
Subscribe & Save [on nothing]
Want a software career that actually feels meaningful? I wrote a free 5-day email course on honing your craft, aligning your work with your values, and building for yourself. Or just not hating your job! Get it here.
Build [With Me]
I mentor software engineers to navigate technical challenges and career growth in a supportive, sometimes silly environment. If you’re interested, you can explore my mentorship programs.
Elsewhere [From Scratch]
I also write essays and fiction about neurodivergence, meaningful work, and building a life that fits. My novella Lake-Effect Coffee is a workplace satire about burnout, friendship, and a coffee van. Read the first chapter or grab the ebook.