Right, let’s pull back the curtain on what actually happens when your Python function is running. You’ve written a function, you’ve called it—but in the CPython engine room, the moment you cross that function’s threshold, a whole new world of state gets created to manage your code’s little universe. This is the frame object.

Think of a frame as the ultimate to-do list and scratchpad for a single function call. It knows where you are (f_lineno), what your local variables are (f_locals), what code object you’re executing (f_code), and where to go when you’re done (f_back points to the frame of the function that called this one). It’s the execution context, and it’s stored on a stack because, well, functions call other functions. This is the famous call stack you’ve heard about, and in CPython, it’s literally a stack of these frame objects.

What’s in the Frame Box?

Let’s crack one open. The best way to see one is to create one. Or, more accurately, to ask the interpreter to show you one. We’ll use the inspect module, which is basically our official backstage pass.

import inspect

def simple_function(x, y):
    z = x + y
    frame = inspect.currentframe()
    print(f"Frame locals: {frame.f_locals}")
    print(f"Code object name: {frame.f_code.co_name}")
    print(f"Calling frame's function: {frame.f_back.f_code.co_name if frame.f_back else 'None'}")
    return z

def caller():
    result = simple_function(5, 3)

caller()

Running this will output something like:

Frame locals: {'x': 5, 'y': 3, 'z': 8, 'frame': <frame at 0x...>}
Code object name: simple_function
Calling frame's function: caller

See? The frame’s locals contain the function’s arguments (x, y) and its local variable (z). It even knows we created a variable called frame pointing to itself—it’s a bit meta, but it proves the point. The f_code attribute holds the code object (the compiled blueprint of the function), and f_back points to the frame of caller(). This is how the interpreter knows where to return to when we hit that return z statement.

The Bytecode Connection

This is where it gets really cool. The frame object is the central hub for the bytecode interpreter. The core execution loop inside CPython essentially looks like this (conceptually):

  1. Grab the next bytecode instruction from the sequence in f_code.co_code.
  2. Look at the instruction’s argument (if it has one).
  3. Manipulate the data stack—which is also stored within the frame object—to push/pop values.
  4. Update the frame’s f_lasti (last instruction) to point to the next instruction.
  5. Repeat.

The frame holds the state, and the bytecode is the list of commands that change that state. You can’t have one without the other. Let’s see this bytecode in action.

import dis

def add_and_multiply(a, b):
    result = a + b
    result *= 2
    return result

dis.dis(add_and_multiply)

The dis module disassembles the function, showing us the human-readable representation of the bytecode:

  4           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 STORE_FAST               2 (result)

  5           8 LOAD_FAST                2 (result)
             10 LOAD_CONST               1 (2)
             12 INPLACE_MULTIPLY
             14 STORE_FAST               2 (result)

  6          16 LOAD_FAST                2 (result)
             18 RETURN_VALUE

Every one of those LOAD_FAST, STORE_FAST, and BINARY_ADD operations is happening in the context of our frame. LOAD_FAST 0 (a) means “grab the value from the 0th slot in the frame’s local variables array and push it onto the data stack.” The frame isn’t just a dictionary; for speed, it uses arrays for locals and the stack. STORE_FAST pops a value off the stack and shoves it into the local array. This is why local variable access is blisteringly fast in Python.

Common Pitfalls and The f_locals Gotcha

Here’s the first thing that will bite you if you start playing with frames: the f_locals attribute is a bit of a liar. Well, not a liar, but it’s a dynamic proxy. Reading from it usually gives you an accurate view. But writing to it? That’s a different story.

def modify_locals():
    x = 10
    frame = inspect.currentframe()
    print(f"Before: x = {x}")
    frame.f_locals['x'] = 20
    print(f"After f_locals assignment: x = {x}") # Spoiler: it's still 10

    # The correct, nuclear option:
    import ctypes
    ctypes.pythonapi.PyFrame_LocalsToFast(ctypes.py_object(frame), ctypes.c_int(0))
    print(f"After LocalsToFast: x = {x}") # Now it's 20

modify_locals()

Why this absurdity? Performance. The interpreter uses those fast local arrays I mentioned. f_locals is constructed on-demand when you ask for it. Changing the dictionary doesn’t automatically update the underlying array that the bytecode is actually using. This is a classic case of the designers (rightly) optimizing for the 99.999% case—executing bytecode—over the 0.001% case—dynamically messing with a function’s locals from inside itself. The ctypes hack is your only way out, and it’s a clear sign you’re doing something you probably shouldn’t be doing in production code.

The best practice here is simple: treat frame objects as read-only snapshots for introspection and debugging. You’re peeking into the interpreter’s private notes. You wouldn’t usually grab a surgeon’s scalpel mid-operation to “make a quick adjustment,” and you shouldn’t try to alter a frame’s state mid-execution either. Understand it, learn from it, but let the interpreter do its job.