Alright, let’s pull back the curtain on the three musketeers of a CPython code object: co_code, co_consts, and co_varnames. These are the attributes you’ll be living in when you want to understand what your code actually does after it’s been chewed up by the Python parser. Think of a code object as the compiled, ready-to-run blueprint for a function, module, or class body. It’s the “what,” not the “how” of execution—that’s the frame’s job. We’re looking at the architect’s plans.

co_code: The Bytecode String

This is the star of the show, the pre-compiled sequence of instructions the Python virtual machine (VM) will execute. It’s stored as a string of bytes (hence ‘bytecode’), which is a memory-efficient nightmare to read. Each instruction is either one or three bytes long. The first byte is the opcode, and the next two (if needed) are its argument.

Let’s see it in action. We’ll use the dis module to make sense of the gibberish.

import dis

def example_func(a):
    result = a + 42
    return result

# Let's get the raw bytes
code_obj = example_func.__code__
print(f"Raw co_code bytes: {code_obj.co_code}")
# Outputs something like: b'd\x01\x00}\x01\x00|\x00\x00d\x02\x00\x17}\x01\x00|\x01\x00S'
# Yeah, good luck with that.

# This is why we have the dis module:
print("Human-readable disassembly:")
dis.dis(example_func)

The output from dis.dis will look like this:

  2           0 LOAD_CONST               1 (42)
              2 STORE_FAST               1 (result)

  3           4 LOAD_FAST                0 (a)
              6 LOAD_CONST               1 (42)
              8 BINARY_ADD
             10 STORE_FAST               1 (result)

  4          12 LOAD_FAST                1 (result)
             14 RETURN_VALUE

See those numbers like 1 after LOAD_CONST? That’s the argument. The VM uses that number as an index into another member of the code object… which brings us to our next guest.

co_consts: The Tuple of Constants

This is a tuple of all the literals the code needs. When you see LOAD_CONST 1 in the bytecode, it means “go grab the object at index 1 in co_consts.” It’s the VM’s little stash of pre-made, immutable values.

What’s in it? Numbers, strings, tuples, maybe even None. Basically, anything you wrote literally in your code that doesn’t have its own name. Let’s inspect it.

print(f"co_consts: {code_obj.co_consts}")
print(f"Type of co_consts: {type(code_obj.co_consts)}")

# Output will look like:
# (None, 42)

Index 0 is almost always None. Why? Because every function that doesn’t explicitly return a value implicitly returns None, and the VM needs something to LOAD for that RETURN_VALUE opcode. The designers just made it a permanent resident at address zero. Index 1 is our literal 42.

Here’s a pro tip and a common pitfall: the compiler is smart, but not that smart. It won’t combine constants.

def constant_madness():
    a = "hello"
    b = "hello"
    return a is b  # This will likely be True due to string interning, but...

print(constant_madness.__code__.co_consts)
# Output: (None, 'hello', 'hello')

Wait, two identical 'hello' strings? Yep. The compiler isn’t doing deep analysis to de-duplicate every constant in your function. It just stuffs them in as it finds them. The VM might intern them later, but co_consts will still have the duplicates. It’s a bit wasteful, but it keeps the compiler fast and simple.

co_varnames: The Tuple of Local Variable Names

This is the official register of all local variable names for this code block. It’s a tuple of strings. When the bytecode says STORE_FAST 1, it means “take the value on the stack and store it in the local variable slot numbered 1.” The name of that slot is found by looking at co_varnames[1].

print(f"co_varnames: {code_obj.co_varnames}")
# Output: ('a', 'result')

So in our example_func, slot 0 is 'a' (the argument) and slot 1 is 'result'.

The critical thing to understand here is the difference between FAST and NAME operations in the bytecode.

  • STORE_FAST/LOAD_FAST are for locals (names in co_varnames). These are blazingly fast because the VM accesses them by a simple integer index in an array.
  • STORE_NAME/LOAD_NAME are for global/outer scope lookups. These are slower because they involve a dictionary lookup (func.__globals__) or a chain of lookups.

This is a huge reason why local variables are faster than global ones. The VM isn’t doing a hash lookup for a; it’s going straight to array index 0. The designers got this very right.

A common pitfall? The indices are assigned statically at compile time. The name 'a' will always be slot 0 in that function’s frame. This is why you can’t add or remove local variables dynamically—the entire layout is fixed before the function even runs. Trying to do it with exec inside the function is a one-way ticket to confusing and broken behavior. Just don’t.