Build a Python Internals Probe

Disassemble bytecode, probe memory, and trigger Python gotchas.

Introduction

30 Second Summary

You use Python every day, but do you know what happens between typing python script.py and seeing output? Most developers treat the interpreter as a black box. That gap between "using Python" and "understanding Python" is exactly what interviewers probe.

In this project, you will build a Python Internals Probe: a single-file CLI tool that uses Python's own standard library to investigate how CPython actually works under the hood.

What You'll Build

When you run your finished probe, it produces rich terminal output showing you disassembled bytecode, live reference counts, triggered gotchas with side-by-side fixes, and working examples of every major Pythonic pattern.

By the end of this project, you'll have:

  • A bytecode inspector that disassembles your own functions into VM opcodes, letting you see that Python compiles before interpreting and compare loop vs comprehension at the instruction level.
  • A memory and object model probe where you watch reference counts change in real time as you create and delete variables, and see the true size of Python objects on the heap.
  • A live gotcha gallery that triggers Python's most infamous bugs (mutable defaults, late-binding closures, UnboundLocalError) with broken and fixed versions running side by side.
  • Secret Mission: Add a GIL probe that empirically proves why threading fails for CPU-bound work but succeeds for IO-bound work.

Do I need to know Python already?

You should be comfortable writing basic Python (functions, loops, lists). This project does not teach Python syntax. It teaches what happens beneath the syntax at the interpreter level.

Before We Start

Before We Start...

Before we start building, let's commit to what you're creating and why. You're about to build a Python Internals Probe that uses Python to investigate CPython itself, because understanding how the interpreter actually works is what separates a Python user from a Python engineer.

Set Up Your Project

Your probe tool needs a home. Before you can investigate how CPython works under the hood, you need a project folder, a Python file, and a confirmed working environment.

In this step, you will create the project structure and run a starter script that proves your Python 3.13 interpreter is ready. You will also verify that the dis module (your bytecode disassembler) is available.

In this step, get ready to:
  • Create the python-internals-probe project folder and probe.py file.
  • Run your starter script and confirm Python 3.13 prints its version.
  • Verify the dis module is available for bytecode inspection.
Create project folder and file

You need a dedicated folder for your probe tool. Placing it on your Desktop makes it easy to find later.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir python-internals-probe
cd python-internals-probe
  • Confirm the folder exists by running:
ls

You should see an empty directory (no output). That is correct because you have not created any files yet.

  • Now open VS Code (or Cursor) by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Cursor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the python-internals-probe folder, and click Open.
  • Open a terminal inside VS Code by clicking Terminal in the top menu bar, then New Terminal.

Why use the integrated terminal?

The integrated terminal in VS Code automatically opens inside your project folder. This means you can edit code and run it without switching windows.

  • Create a new file by clicking File in the top menu, then New File.
  • Save it immediately with Cmd+S (macOS) or Ctrl+S (Windows) and name it probe.py.
  • Add the following starter code to probe.py:
import sys

if __name__ == "__main__":
    print("=" * 60)
    print("  PYTHON INTERNALS PROBE v1.0")
    print(f"  Python {sys.version}")
    print(f"  Platform: {sys.platform}")
    print("=" * 60)

What does this code do?

  • import sys gives you access to Python's system-level information (version, platform, memory tools).
  • if __name__ == "__main__": ensures this code only runs when you execute the file directly, not when it is imported.
  • sys.version and sys.platform report your exact Python build and operating system.

Before you run this, what do you think the output will look like? Consider what sys.version and sys.platform might print for your machine.

  • Save probe.py with Cmd+S (macOS) or Ctrl+S (Windows).
  • Run the probe by entering this command in your terminal:
python probe.py

You should see your Python version (3.13.5 or similar) and your platform printed between lines of equals signs.

Seeing 'command not found' or no output?

  • If you see command not found, try python3 probe.py instead. Some systems use python3 as the command name.
  • Make sure your terminal is open inside the python-internals-probe folder. The terminal prompt should show the folder name.
  • Check that you saved the file as probe.py (not probe.py.txt or similar).

Help me debug why python probe.py is not working

Confirm the dis module is available

The dis module is your bytecode disassembler. It is built into CPython's standard library, so no pip install is needed.

  • In probe.py, find this line at the very top of the file:
import sys
  • Add import dis on a new line ABOVE it, so the top of your file now looks like this:
import dis
import sys
  • Save probe.py.
  • Run the probe again to confirm no import errors:
python probe.py

You should see the exact same version output as before. No errors means dis imported successfully and is ready for the next step.

Seeing an ImportError?

  • Check for typos. The module name is dis (three letters, all lowercase).
  • Make sure import dis is on its own line, not combined with another import statement.

Help me fix my import dis error

✔️ Awesome, I've got everything!

Great. Double check you have saved probe.py and that running it prints your Python version with no errors.

ⓧ I'd like to double check the full code

Here is the complete probe.py at this point:

import dis
import sys

if __name__ == "__main__":
    print("=" * 60)
    print("  PYTHON INTERNALS PROBE v1.0")
    print(f"  Python {sys.version}")
    print(f"  Platform: {sys.platform}")
    print("=" * 60)

Your environment is confirmed and your probe file is ready. Next up, you will disassemble your own Python functions into bytecode opcodes and see the stack-based VM that actually runs your code.

Disassemble Your Own Code

Your environment is confirmed and ready to go. Now it's time to investigate something that surprises most Python developers: Python compiles your code before running it.

Many developers assume Python is "just interpreted." In reality, CPython compiles your source code to bytecode, then a stack-based virtual machine interprets that bytecode. In this step, you'll use the dis module to see this compilation happen with your own functions.

In this step, get ready to:
  • Disassemble a simple function and read its bytecode opcodes.
  • Compare how a for-loop and a list comprehension compile differently.
  • Inspect the code object to see where Python stores function metadata.
Write and disassemble a simple function

The dis module is Python's built-in disassembler. It takes a function and shows you the exact bytecode opcodes that CPython compiled from your source code.

  • In probe.py, add the following probe_bytecode() function above your if __name__ == "__main__" block:
def probe_bytecode():
    """Disassemble Python functions to see how CPython compiles them."""
    print("\n" + "=" * 60)
    print("PROBE 1: BYTECODE INSPECTOR")
    print("=" * 60)

    def add(a, b):
        return a + b

    print("\n--- Disassembly of add(a, b) ---")
    dis.dis(add)
  • Now scroll down to the bottom of your if __name__ == "__main__" block and add a call to probe_bytecode() after the header prints:
    probe_bytecode()

What does this code do?

You defined a function called probe_bytecode() that acts as a container for your first investigation. Inside it, you defined a tiny add(a, b) function and immediately passed it to dis.dis().

The dis.dis() function prints the bytecode instructions that CPython generated when it compiled add(). Each line of output is one opcode the virtual machine will execute.

  • Save probe.py.
  • Run your probe by entering this command in the terminal:
python probe.py

You should see the version header from before, followed by a new "PROBE 1: BYTECODE INSPECTOR" section showing opcodes like RESUME, LOAD_FAST, BINARY_OP, and RETURN_VALUE.

What are those opcodes?

  • RESUME signals the start of the function to the VM.
  • LOAD_FAST pushes a local variable onto the evaluation stack.
  • BINARY_OP pops two values, adds them, and pushes the result.
  • RETURN_VALUE pops the top of the stack and returns it to the caller.

This is proof that Python compiled your source into a sequence of low-level instructions. It is not reading your text character by character at runtime.

Not seeing any bytecode output?

  • Make sure probe_bytecode() is defined above the if __name__ block, not inside it.
  • Confirm you added the probe_bytecode() call inside the if __name__ == "__main__" block after the header prints.
  • Check that import dis is still at the top of your file.

Why isn't my probe_bytecode function printing anything?

Compare loop vs comprehension bytecode

Python gives you two ways to build a list of squares: a for-loop (familiar from C++ or Java) and a list comprehension (Pythonic). They produce the same result, but the bytecode is different.

  • In probe.py, add the following code inside probe_bytecode() directly below the dis.dis(add) line:
    def loop_squares(n):
        result = []
        for i in range(n):
            result.append(i * i)
        return result

    def comp_squares(n):
        return [i * i for i in range(n)]

    print("\n--- Loop version (Java/C++ style) ---")
    dis.dis(loop_squares)

    print("\n--- Comprehension version (Pythonic) ---")
    dis.dis(comp_squares)

What does this code do?

  • loop_squares(n) builds a list of squares using an explicit for-loop. This mirrors how you would write it in C++ or Java.
  • comp_squares(n) does the same thing in a single list comprehension expression.
  • Disassembling both lets you compare the raw instruction count. The comprehension compiles to fewer opcodes because Python handles the iteration internally.
  • Save probe.py and run it again:
python probe.py

You should now see three disassembly blocks in your output. The loop version has noticeably more opcodes (including GET_ITER and FOR_ITER) compared to the compact comprehension version.

Seeing an IndentationError?

  • All the code you just added must be indented one level inside probe_bytecode(). Each line starts with 4 spaces.
  • The def loop_squares(n): line should be at the same indentation level as def add(a, b): above it.

Help me fix my IndentationError in probe.py

Inspect code object internals

Every Python function carries a hidden __code__ attribute. This is the actual code object that the VM uses at runtime, containing the variable names, constants, and stack size it needs.

  • Still inside probe_bytecode(), add the following code below the dis.dis(comp_squares) line:
    print("\n--- Code object internals for add() ---")
    code = add.__code__
    print(f"  co_varnames: {code.co_varnames}")
    print(f"  co_consts:   {code.co_consts}")
    print(f"  co_stacksize: {code.co_stacksize}")

    print("\n[INSIGHT] Python compiles source to bytecode (.pyc), then a")
    print("stack-based VM interprets it. Unlike C++ (native machine code)")
    print("or Java (JVM bytecode + JIT), CPython's default is pure interpretation.")
    print("Python 3.13 adds an experimental JIT compiler (PEP 744).")

What do these code object attributes mean?

  • co_varnames lists the local variable names. For add(a, b), this is ('a', 'b').
  • co_consts stores compile-time constants. You'll see None (the implicit return value) listed here.
  • co_stacksize tells the VM how many slots to allocate on the evaluation stack for this function.

These are the data structures the interpreter pre-computes so it can run your function efficiently at call time.

  • Save probe.py.

Before you run this, what do you expect to see for co_varnames on the add(a, b) function? Think about what local names exist in that function.

  • Run the probe to see the full output:
python probe.py

At the bottom of the output you should see co_varnames: ('a', 'b'), confirming that Python stored the parameter names as metadata alongside the compiled bytecode.

Seeing a NameError for add?

  • The code = add.__code__ line must come after the def add(a, b): definition inside the same function.
  • Make sure you placed the new code below dis.dis(comp_squares), not outside probe_bytecode().

Help me fix a NameError when accessing add.__code__

✔️ Awesome, I've got everything!

Great. Double check that you've saved probe.py and your output shows all three disassemblies plus the code object internals.

ⓧ I'd like to double check the full code

Scroll up and confirm your probe.py contains all of the following in order:

  • The import dis and import sys lines at the top.
  • The def probe_bytecode(): function containing: the header prints, add() definition and disassembly, loop_squares() and comp_squares() definitions and disassemblies, code object internals for add(), and the insight print statements.
  • The if __name__ == "__main__": block with the header prints followed by probe_bytecode().

You've just proven that Python is not "just interpreted." Every function you write gets compiled to bytecode opcodes first. Next up, you'll probe the object model to see how Python handles memory and identity differently from C++ and Java.

Probe the Object Model

In the previous step, you saw how Python compiles your functions into bytecode opcodes. Now you need to understand what those opcodes actually operate on.

In C++, an int is 4 bytes sitting directly on the stack. In Python, even int(0) is a heap-allocated PyObject. In this step, you will see that difference firsthand by probing object identity, reference counts, and memory sizes.

In this step, get ready to:
  • Demonstrate that Python's == checks value while is checks object identity.
  • Watch reference counts change live as you create and delete variables.
  • Measure the memory overhead of Python's "everything is an object" design.
Demonstrate object identity vs equality

In Java and C++, comparing two variables checks their value. Python has two comparison operators: == checks value, and is checks whether two names point to the exact same object in memory.

  • In probe.py, add the following probe_memory() function below probe_bytecode() (after its closing line, before the if __name__ block):
def probe_memory():
    """Explore Python's object model, reference counting, and identity."""
    print("\n" + "=" * 60)
    print("PROBE 2: OBJECT MODEL & MEMORY")
    print("=" * 60)

    print("\n--- Everything is an object ---")
    print(f"  type(42):        {type(42)}")
    print(f"  type('hello'):   {type('hello')}")
    print(f"  type([1,2,3]):   {type([1, 2, 3])}")
    print(f"  type(type):      {type(type)}")
    print(f"  type(None):      {type(None)}")
    print(f"  type(print):     {type(print)}")

    print("\n--- Identity vs Equality (is vs ==) ---")
    a = [1, 2, 3]
    b = [1, 2, 3]
    c = a
    print(f"  a = [1, 2, 3], b = [1, 2, 3], c = a")
    print(f"  a == b: {a == b}  (same value)")
    print(f"  a is b: {a is b}  (different objects!)")
    print(f"  a is c: {a is c}  (same object)")
    print(f"  id(a): {id(a)}, id(b): {id(b)}, id(c): {id(c)}")

What does this code do?

This first section proves that everything in Python is an object, including int, str, and even type itself.

  • a == b returns True because both lists hold the same values.
  • a is b returns False because Python created two separate list objects on the heap.
  • a is c returns True because c = a makes c point to the same object (not a copy).
  • Now scroll down to your if __name__ == "__main__" block.
  • Find this line:
    probe_bytecode()
  • Add probe_memory() on the next line, so your __main__ block now calls both probes:
    probe_bytecode()
    probe_memory()
  • Save probe.py.
  • Run your probe to see the new output by running this command:
python probe.py

After the bytecode output, you should now see type information for each object, followed by identity comparisons showing a == b is True but a is b is False.

Not seeing the object model output?

  • Make sure probe_memory() is defined at the top level (same indentation as probe_bytecode()), not nested inside another function.
  • Check that you added probe_memory() inside the if __name__ == "__main__" block, below probe_bytecode().

Help me debug my probe_memory() function

CPython also caches small integers as an optimization. Integers from -5 to 256 are pre-allocated as singleton objects, so any variable holding 256 points to the exact same object in memory.

  • Add this code below the identity section inside probe_memory(), right after the id() print line:
    print("\n--- Small integer caching (CPython optimization) ---")
    x = 256
    y = 256
    print(f"  x = 256, y = 256")
    print(f"  x is y: {x is y}  (cached: same object)")
    x2 = int("257")
    y2 = int("257")
    print(f"  x2 = int('257'), y2 = int('257')")
    print(f"  x2 is y2: {x2 is y2}  (not cached: different objects)")

Why int("257") instead of just 257?

Using int("257") forces Python to create the integer at runtime, bypassing compiler constant folding. If you wrote x = 257 and y = 257 on separate lines in the same function, the compiler might reuse the same constant object. The int() call guarantees two separate allocations so you can reliably see is return False.

  • Save probe.py and run it again:
python probe.py

You should see x is y: True for 256 (cached) and x2 is y2: False for 257 (not cached).

Observe reference counting in action

CPython uses reference counting as its primary memory management strategy. Every object has a counter tracking how many names point to it. When the count reaches zero, the object is immediately freed.

  • Add this code below the small integer caching section inside probe_memory():
    print("\n--- Reference counting ---")
    obj = [1, 2, 3]
    print(f"  obj = [1, 2, 3]")
    print(f"  sys.getrefcount(obj): {sys.getrefcount(obj)}")
    print(f"  (Always +1 because getrefcount() itself holds a reference)")
    ref2 = obj
    print(f"  ref2 = obj")
    print(f"  sys.getrefcount(obj): {sys.getrefcount(obj)}")
    del ref2
    print(f"  del ref2")
    print(f"  sys.getrefcount(obj): {sys.getrefcount(obj)}")

What does this code do?

  • sys.getrefcount(obj) returns the reference count, but the count is always one higher than expected because the function call itself temporarily holds a reference to the object.
  • Creating ref2 = obj adds a second name pointing to the same list, bumping the count.
  • del ref2 removes that name, dropping the count back down. This is how CPython knows when to free memory.
  • Save probe.py and run it:
python probe.py

In the reference counting section, you should see the count start at 2, increase to 3 after ref2 = obj, then drop back to 2 after del ref2.

Seeing different numbers?

The exact starting count depends on your Python version and interpreter internals. The important thing is the relative change: the count goes up by 1 when you add a reference and down by 1 when you delete it. If you see no output at all, make sure this code is indented inside probe_memory().

Why are my reference counts different from expected?

Add insight commentary

The final piece of this probe shows the raw memory cost of Python's object model. You will print the byte size of common types to see the overhead that "everything is an object" adds.

  • Add this code at the end of probe_memory(), below the reference counting section:
    print("\n--- Object sizes (sys.getsizeof) ---")
    print(f"  int(0):       {sys.getsizeof(0)} bytes")
    print(f"  int(1):       {sys.getsizeof(1)} bytes")
    print(f"  float(1.0):   {sys.getsizeof(1.0)} bytes")
    print(f"  str(''):      {sys.getsizeof('')} bytes")
    print(f"  str('hello'): {sys.getsizeof('hello')} bytes")
    print(f"  list([]):     {sys.getsizeof([])} bytes")
    print(f"  dict({{{}}}):     {sys.getsizeof({})} bytes")

    print("\n[INSIGHT] In C++, int is 4 bytes on the stack. In Python, even")
    print("int(0) is a heap-allocated PyObject (~28 bytes). This is the cost")
    print("of 'everything is an object'. Python trades memory for flexibility.")

What does sys.getsizeof() measure?

sys.getsizeof() returns the memory consumption directly attributed to the object in bytes. It does not count the memory of objects a container refers to. An empty list still takes dozens of bytes because every Python object carries a type pointer, reference count, and other internal bookkeeping.

  • Save probe.py.

Before you run this, what do you think int(0) will cost in bytes? More or less than the 4 bytes a C++ int uses?

  • Run the full probe to see the answer:
python probe.py

You should see int(0) costs 28 bytes. That is 7x the size of a C++ int, all because Python wraps every value in a full object with type information, reference count, and value storage.

Seeing a syntax error on the dict line?

  • The dict line uses double curly braces {{}} inside the f-string. In Python f-strings, {{ prints a literal { and }} prints a literal }. Make sure you have exactly dict({{}}) in your f-string.
  • Check that all your f-string print statements have matching quotes and parentheses.

Help me fix a syntax error in my probe_memory() function

✔️ Awesome, I've got everything!

Great. Double check you have saved probe.py with all four sections: types, identity, integer caching, reference counting, object sizes, and the insight commentary.

ⓧ I'd like to double check the full code

Your probe.py is now longer than 30 lines. Scroll up through this step and confirm each of these chunks is present in your file, in this order:

  • The probe_bytecode() function (unchanged from the previous step).
  • The probe_memory() function definition with the docstring and header prints.
  • The "Everything is an object" section printing types.
  • The "Identity vs Equality" section with a, b, c comparisons.
  • The "Small integer caching" section with 256 and 257 comparisons.
  • The "Reference counting" section with obj, ref2, and del.
  • The "Object sizes" section with sys.getsizeof() calls and the [INSIGHT] print.
  • The if __name__ block calling both probe_bytecode() and probe_memory().

You now have concrete proof that Python's object model trades memory for flexibility. Every value lives on the heap, every variable is a name pointing to a reference-counted object. Next up, you will trigger Python's most famous gotchas and see how these design choices create bugs that trip up developers from C++ and Java backgrounds.

Trigger Python's Famous Gotchas

You've seen how Python compiles to bytecode and manages memory as heap-allocated objects. Now you need to confront the places where Python's design actively contradicts your instincts from other languages.

In C++, Java, and JavaScript, default arguments get fresh values each call. Variables inside closures capture their value when bound. Assignment creates a copy. Python does none of these things. This step triggers each gotcha live so you see the behavior before you memorize the rule.

In this step, get ready to:
  • Demonstrate the mutable default argument bug and its sentinel fix.
  • Trigger late-binding closures and LEGB scoping errors.
  • Expose the shallow copy trap with nested lists.
Demonstrate the mutable default argument bug

In C++ or Java, if a function has a default parameter of an empty list, you'd expect a fresh list every time you call it. Python does something different.

  • In probe.py, add the following probe_gotchas() function below your probe_memory() function:
def probe_gotchas():
    """Demonstrate Python behaviors that trip up C++/Java/JS developers."""
    print("\n" + "=" * 60)
    print("PROBE 3: PYTHON GOTCHAS")
    print("=" * 60)

    # Gotcha 1: Mutable default arguments
    print("\n--- Gotcha 1: Mutable Default Arguments ---")

    def append_to_broken(item, lst=[]):
        lst.append(item)
        return lst

    print("  def append_to_broken(item, lst=[]):")
    print(f"    Call 1: {append_to_broken(1)}")
    print(f"    Call 2: {append_to_broken(2)}")
    print(f"    Call 3: {append_to_broken(3)}")
    print("  BUG! The list persists between calls.")
    print("  Python evaluates defaults ONCE at definition time.")

    def append_to_fixed(item, lst=None):
        if lst is None:
            lst = []
        lst.append(item)
        return lst

    print(f"\n  Fixed version (sentinel pattern):")
    print(f"    Call 1: {append_to_fixed(1)}")
    print(f"    Call 2: {append_to_fixed(2)}")
    print(f"    Call 3: {append_to_fixed(3)}")

What does this code do?

The append_to_broken function defines lst=[] as a default argument. Python evaluates that empty list ONCE when the function is defined. Every call shares the same list object.

The append_to_fixed function uses the sentinel pattern: set the default to None, then create a fresh list inside the function body. This guarantees a new list every call.

Now you need to call this function from your main block so it actually runs.

  • Scroll down to your if __name__ == "__main__" block at the bottom of the file.
  • Find this line:
    probe_memory()
  • Add probe_gotchas() on the next line, so the bottom of your file now looks like this:
    probe_bytecode()
    probe_memory()
    probe_gotchas()

Before you run this, think about what you'd expect. If you call append_to_broken(1), then append_to_broken(2), then append_to_broken(3), what should each call return?

  • Save probe.py and run it with this command:
python probe.py

Scroll down to the PROBE 3 section. You'll see [1], [1, 2], [1, 2, 3] for the broken version. The list accumulates across calls because Python only creates the default list once.

Not seeing the PROBE 3 output?

  • Make sure probe_gotchas() is inside the if __name__ == "__main__" block, indented at the same level as probe_memory().
  • Check that the function definition def probe_gotchas(): is at the top level (no indentation), not nested inside another function.
  • If you see a SyntaxError, check that the f-string lines use matching quotes and the parentheses are balanced.

Why isn't my probe_gotchas() function producing output?

Trigger late-binding closures and LEGB scoping

The next gotcha is even more surprising. When you create lambdas inside a loop, you'd expect each one to capture its own value of the loop variable. Python captures the variable itself.

  • In probe.py, add the following code at the end of your probe_gotchas() function, below the last append_to_fixed print statement:
    # Gotcha 2: Late-binding closures
    print("\n--- Gotcha 2: Late-Binding Closures ---")
    funcs_broken = [lambda x: x + i for i in range(5)]
    print("  funcs = [lambda x: x + i for i in range(5)]")
    print(f"  funcs[0](0) = {funcs_broken[0](0)}  (expected 0, got 4!)")
    print(f"  funcs[1](0) = {funcs_broken[1](0)}  (expected 1, got 4!)")
    print("  All closures capture the VARIABLE i, not its value.")

    funcs_fixed = [lambda x, i=i: x + i for i in range(5)]
    print(f"\n  Fixed (default arg captures value):")
    print(f"  funcs[0](0) = {funcs_fixed[0](0)}")
    print(f"  funcs[1](0) = {funcs_fixed[1](0)}")

Why does every lambda return 4?

Each lambda captures the variable i, not the value i had at the time the lambda was created. After the loop finishes, i equals 4. All five lambdas read that same final value.

The fix lambda x, i=i: x + i uses a default argument to snapshot the value at each iteration. Default arguments are evaluated at definition time, which is exactly the behavior you want here.

  • Save probe.py and run it:
python probe.py

In the Gotcha 2 section, you'll see funcs[0](0) = 4 where you'd expect 0. The fixed version correctly returns 0 and 1.

Now add the third gotcha, which demonstrates how Python's scoping rules can cause an error even when a variable appears to exist.

  • Add this code below the closures section, still inside probe_gotchas():
    # Gotcha 3: LEGB scoping and UnboundLocalError
    print("\n--- Gotcha 3: LEGB Scoping ---")
    counter = 0

    def increment_broken():
        try:
            counter += 1
            return counter
        except UnboundLocalError as e:
            return f"UnboundLocalError: {e}"

    print(f"  counter = 0")
    print(f"  def increment_broken(): counter += 1")
    print(f"  Result: {increment_broken()}")
    print("  Python sees 'counter =' and marks it LOCAL for entire function.")
    print("  But += reads before writing: UnboundLocalError.")
    print("  Fix: use 'nonlocal counter' or 'global counter'.")

What is LEGB scoping?

LEGB stands for Local, Enclosing, Global, Built-in. Python resolves variable names in this order. The trap here is that counter += 1 contains an assignment, so Python marks counter as local for the entire function body.

The += operator reads the variable before writing, but since Python already decided it's local (and no local value exists yet), you get an UnboundLocalError.

  • Save probe.py and run it:
python probe.py

In the Gotcha 3 section, you'll see the UnboundLocalError printed. The variable counter exists in the enclosing scope, but Python's assignment detection overrides that.

Seeing an IndentationError?

  • All code inside probe_gotchas() should be indented by 4 spaces. The nested increment_broken() function needs 8 spaces of indentation.
  • Make sure you haven't mixed tabs and spaces. VS Code shows this as a squiggly underline on the affected line.

I'm getting an IndentationError in my probe_gotchas function. Help me fix my indentation.

Expose the shallow copy trap

The final gotcha catches developers who think slicing a list creates an independent copy. It does create a new outer list, but inner objects are still shared.

  • Add this code at the end of your probe_gotchas() function, below the LEGB scoping section:
    # Gotcha 4: Shallow vs deep copy
    print("\n--- Gotcha 4: Assignment is Not Copying ---")
    original = [[1, 2], [3, 4]]
    shallow = original[:]
    shallow[0].append(99)
    print(f"  original = [[1, 2], [3, 4]]")
    print(f"  shallow = original[:]")
    print(f"  shallow[0].append(99)")
    print(f"  original: {original}  (mutated!)")
    print("  Slice creates new outer list but inner lists are shared.")

    print("\n[INSIGHT] These gotchas exist because Python's design choices")
    print("(late binding, reference semantics, one-time default evaluation)")
    print("optimize for the common case. Understanding them is interview gold.")

What does this code do?

The slice original[:] creates a new list object, but it copies references to the inner lists rather than duplicating them. Both original[0] and shallow[0] point to the same inner list.

This is why mutating shallow[0] also mutates original[0]. For a fully independent copy, you'd need copy.deepcopy().

Time for the final run. Before you execute, predict: what will original look like after you append 99 to shallow[0]? Will the original be unchanged, or will it show [[1, 2, 99], [3, 4]]?

  • Save probe.py and run it:
python probe.py

The original IS mutated. You'll see [[1, 2, 99], [3, 4]] because the slice only created a shallow copy. The inner lists are shared references.

Seeing a NameError for UnboundLocalError?

  • UnboundLocalError is a built-in exception. You do not need to import it. If you see a NameError, check that you spelled it exactly as UnboundLocalError (capital U, L, E).
  • If your output shows different numbers for the closures section, check that the comprehension uses range(5) and the lambda uses x + i.

My gotchas probe is producing unexpected results or errors. Help me debug.

✔️ Awesome, I've got everything!

Great. Double check you've saved probe.py and confirm all four gotchas produce output when you run the probe.

ⓧ I'd like to double check the full code

Here is the complete probe_gotchas() function. Confirm yours matches:

def probe_gotchas():
    """Demonstrate Python behaviors that trip up C++/Java/JS developers."""
    print("\n" + "=" * 60)
    print("PROBE 3: PYTHON GOTCHAS")
    print("=" * 60)

    print("\n--- Gotcha 1: Mutable Default Arguments ---")

    def append_to_broken(item, lst=[]):
        lst.append(item)
        return lst

    print("  def append_to_broken(item, lst=[]):")
    print(f"    Call 1: {append_to_broken(1)}")
    print(f"    Call 2: {append_to_broken(2)}")
    print(f"    Call 3: {append_to_broken(3)}")
    print("  BUG! The list persists between calls.")
    print("  Python evaluates defaults ONCE at definition time.")
    def append_to_fixed(item, lst=None):
        if lst is None:
            lst = []
        lst.append(item)
        return lst

    print(f"\n  Fixed version (sentinel pattern):")
    print(f"    Call 1: {append_to_fixed(1)}")
    print(f"    Call 2: {append_to_fixed(2)}")
    print(f"    Call 3: {append_to_fixed(3)}")

    print("\n--- Gotcha 2: Late-Binding Closures ---")
    funcs_broken = [lambda x: x + i for i in range(5)]
    print("  funcs = [lambda x: x + i for i in range(5)]")
    print(f"  funcs[0](0) = {funcs_broken[0](0)}  (expected 0, got 4!)")
    print(f"  funcs[1](0) = {funcs_broken[1](0)}  (expected 1, got 4!)")
    print("  All closures capture the VARIABLE i, not its value.")
    funcs_fixed = [lambda x, i=i: x + i for i in range(5)]
    print(f"\n  Fixed (default arg captures value):")
    print(f"  funcs[0](0) = {funcs_fixed[0](0)}")
    print(f"  funcs[1](0) = {funcs_fixed[1](0)}")

    print("\n--- Gotcha 3: LEGB Scoping ---")
    counter = 0

    def increment_broken():
        try:
            counter += 1
            return counter
        except UnboundLocalError as e:
            return f"UnboundLocalError: {e}"

    print(f"  counter = 0")
    print(f"  def increment_broken(): counter += 1")
    print(f"  Result: {increment_broken()}")
    print("  Python sees 'counter =' and marks it LOCAL for entire function.")
    print("  But += reads before writing: UnboundLocalError.")
    print("  Fix: use 'nonlocal counter' or 'global counter'.")
    print("\n--- Gotcha 4: Assignment is Not Copying ---")
    original = [[1, 2], [3, 4]]
    shallow = original[:]
    shallow[0].append(99)
    print(f"  original = [[1, 2], [3, 4]]")
    print(f"  shallow = original[:]")
    print(f"  shallow[0].append(99)")
    print(f"  original: {original}  (mutated!)")
    print("  Slice creates new outer list but inner lists are shared.")

    print("\n[INSIGHT] These gotchas exist because Python's design choices")
    print("(late binding, reference semantics, one-time default evaluation)")
    print("optimize for the common case. Understanding them is interview gold.")

And confirm your __main__ block calls all three probe functions:

if __name__ == "__main__":
    print("=" * 60)
    print("  PYTHON INTERNALS PROBE v1.0")
    print(f"  Python {sys.version}")
    print(f"  Platform: {sys.platform}")
    print("=" * 60)

    probe_bytecode()
    probe_memory()
    probe_gotchas()

You've now seen four behaviors that trip up experienced developers from other languages. These aren't bugs. They're consequences of Python's design choices around late binding and reference semantics. Next up, you'll build the Pythonic patterns that make Python worth choosing despite these quirks.

Build Pythonic Patterns

Your probe has exposed how CPython compiles code, manages memory, and breaks your C++/Java/JS intuitions. Now you need to see the patterns that make those trade-offs worth it.

Decorators, generators, and context managers are Python's answer to problems that require verbose boilerplate in other languages. In this step, you will build each pattern from scratch and watch it work.

In this step, get ready to:
  • Build a simple and parameterized decorator that measures timing and retries on failure.
  • Define an infinite Fibonacci generator and compare its memory usage against a list.
  • Create a context manager class with guaranteed cleanup and demonstrate concise comprehensions.
Add the new imports

The patterns in this step use timing, function metadata preservation, and type hints. You need three new imports to support them.

  • In probe.py, find the imports at the top of your file:
import dis
import sys
  • Replace those two lines with the following expanded import block:
import dis
import sys
import time
import functools
from collections.abc import Generator

What do these imports do?

time gives you perf_counter() for high-resolution timing. functools provides @wraps which preserves a decorated function's name and docstring.

Generator from collections.abc is the modern type hint for generator functions (preferred over typing.Generator since Python 3.9).

Define the timer decorator

A decorator is a function that wraps another function to add behavior without modifying the original code. Your first decorator will measure and print execution time.

  • Add the following function below your probe_gotchas() function:
def timer(func):
    """A decorator that measures execution time."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"    {func.__name__} took {elapsed:.6f}s")
        return result
    return wrapper

What does this code do?

timer() takes a function as input and returns a new wrapper function. The wrapper records the time before and after calling the original function, then prints the difference.

@functools.wraps(func) copies the original function's __name__ and __doc__ onto the wrapper. Without it, every decorated function would show up as "wrapper" in stack traces.

  • Save probe.py and run it to confirm no import errors:
python probe.py

You should see the same three probes as before (bytecode, object model, gotchas) with no errors. The new function exists but nothing calls it yet.

Seeing an ImportError?

Check that from collections.abc import Generator is on its own line. Make sure you typed collections.abc (not collections alone).

help me fix my import error in probe.py

Define the retry decorator and wire up probe_patterns()

A parameterized decorator adds another layer of nesting. The outer function accepts configuration (like max attempts), and returns the actual decorator that wraps the target function.

  • Add the retry() function directly below your timer() function:
def retry(max_attempts=3):
    """A decorator with arguments that retries on failure."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"    Attempt {attempt} failed: {e}. Retrying...")
        return wrapper
    return decorator

What does this code do?

retry(max_attempts=3) is three functions deep: retry() takes the config, returns decorator() which takes the function, returns wrapper() which runs the actual retry logic.

If the wrapped function raises an exception, the wrapper tries again. On the final attempt, it re-raises instead of swallowing the error.

Now you need a probe_patterns() function that uses both decorators and produces visible output.

  • Add the following probe_patterns() function below retry():
def probe_patterns():
    """Demonstrate Pythonic patterns that showcase why Python exists."""
    print("\n" + "=" * 60)
    print("PROBE 4: PYTHONIC PATTERNS")
    print("=" * 60)

    # Decorators
    print("\n--- Decorators (adding behavior without modifying code) ---")

    @timer
    def slow_sum(n):
        return sum(range(n))

    result = slow_sum(1_000_000)
    print(f"    Result: {result}")
    print(f"  slow_sum.__name__: '{slow_sum.__name__}' (preserved by @wraps)")

    # Decorator with arguments
    print("\n--- Decorator with arguments ---")
    attempt_count = 0

    @retry(max_attempts=3)
    def unreliable():
        nonlocal attempt_count
        attempt_count += 1
        if attempt_count < 3:
            raise ConnectionError("Server unavailable")
        return "Success!"

    print(f"    Result: {unreliable()}")

What does this code do?

@timer wraps slow_sum() to print how long it takes to sum a million numbers. Then __name__ proves that @wraps preserved the original function's identity.

unreliable() deliberately fails twice before succeeding. The @retry decorator catches each failure and retries, so you see the retry messages in your output.

  • In your if __name__ == "__main__" block at the bottom of the file, find the line probe_gotchas() and add a call to probe_patterns() directly below it:
    probe_gotchas()
    probe_patterns()
  • Save probe.py and run it:
python probe.py

At the end of the output, you should see a new PROBE 4 section. The @timer decorator prints an execution time for slow_sum, and the retry decorator prints "Attempt 1 failed" and "Attempt 2 failed" before printing "Success!".

Seeing a NameError for timer or retry?

Make sure timer() and retry() are defined at the top level (no indentation), ABOVE probe_patterns(). Python reads the file top to bottom. If the decorators are below the function that uses them, Python hasn't seen them yet.

help me fix my NameError in probe_patterns

Add generators and memory comparison

A generator produces values lazily using yield. Instead of building an entire list in memory, it computes one value at a time. This is how Python handles infinite sequences with constant memory.

  • Add the fibonacci() generator function below retry() and above probe_patterns():
def fibonacci() -> Generator[int, None, None]:
    """An infinite generator - produces values lazily."""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

What does this code do?

yield pauses the function and hands a value to the caller. The next time someone calls next() on this generator, it resumes right where it left off. The while True loop means it can produce Fibonacci numbers forever without using more than O(1) memory.

The type hint Generator[int, None, None] means: yields int, accepts nothing via send(), returns nothing.

  • Now add the generators section inside probe_patterns(). Find the last line of the function (print(f" Result: {unreliable()}")) and add the following below it:
    # Generators
    print("\n--- Generators (lazy infinite sequences) ---")
    fib = fibonacci()
    first_10 = [next(fib) for _ in range(10)]
    print(f"  First 10 Fibonacci: {first_10}")
    print("  Generator uses O(1) memory regardless of sequence length.")

    # Generator expression vs list comprehension memory
    print("\n--- Generator vs List (memory comparison) ---")
    list_comp = [x * x for x in range(10000)]
    gen_expr = (x * x for x in range(10000))
    print(f"  [x*x for x in range(10000)]: {sys.getsizeof(list_comp)} bytes")
    print(f"  (x*x for x in range(10000)): {sys.getsizeof(gen_expr)} bytes")

What does this code do?

The first section calls next(fib) ten times to pull the first 10 Fibonacci numbers from the infinite generator. The generator only computes values as you ask for them.

The memory comparison uses sys.getsizeof() to show that a list comprehension stores all 10,000 results in memory at once, while a generator expression stores only the computation state (a handful of bytes).

  • Save probe.py and run it:
python probe.py

You should now see the Fibonacci sequence [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] printed. The memory comparison shows the list using tens of thousands of bytes while the generator expression uses only around 200 bytes.

Seeing an indentation error?

The generator section code must be indented one level (4 spaces) inside probe_patterns(). Check that it aligns with the print(f" Result: {unreliable()}") line above it.

help me fix my indentation error

Add context managers and comprehensions

A context manager guarantees cleanup runs no matter what. Even if your code crashes inside a with block, the __exit__ method always fires.

  • Add the ManagedResource class below fibonacci() and above probe_patterns():
class ManagedResource:
    """A context manager demonstrating __enter__/__exit__."""

    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"    Acquiring: {self.name}")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"    Releasing: {self.name}")
        return False

What does this code do?

__enter__ runs when you enter the with block and returns the object you work with. __exit__ runs when the block ends, even if an exception was raised. Returning False means exceptions propagate normally after cleanup.

  • Add the context managers and comprehensions section at the end of probe_patterns(), below the generator memory comparison code:
    # Context managers
    print("\n--- Context Managers (guaranteed cleanup) ---")
    with ManagedResource("database connection") as res:
        print(f"    Using: {res.name}")
    print("  __exit__ always runs, even if an exception occurs.")

    # Comprehensions
    print("\n--- Comprehensions (concise transformations) ---")
    words = ["hello", "world", "python", "probe"]
    upper = {w: w.upper() for w in words if len(w) > 4}
    print(f"  Dict comp: {upper}")
    flat = [c for word in words for c in word if c in "aeiou"]
    print(f"  Nested comp (vowels): {flat}")

    print("\n[INSIGHT] These patterns are why Python exists: expressive,")
    print("readable code that does more with less. Decorators replace AOP,")
    print("generators replace iterator boilerplate, comprehensions replace loops.")

What does this code do?

The with ManagedResource(...) block prints "Acquiring" on entry and "Releasing" on exit, proving guaranteed cleanup. In production, this pattern manages database connections, file handles, and locks.

The dict comprehension filters words longer than 4 characters and maps them to uppercase. The nested list comprehension extracts all vowels from all words in a single expression.

Finally, update the __main__ block to print a final summary after all probes complete.

  • Find the probe_patterns() call in your __main__ block and add the following directly below it:
    print("\n" + "=" * 60)
    print("  PROBE COMPLETE")
    print("=" * 60)
    print("\n  Key takeaways for interviews:")
    print("  1. Python compiles to bytecode, then interprets (not 'just interpreted')")
    print("  2. Everything is a heap-allocated object (even int)")
    print("  3. Reference counting + cyclic GC (not just GC like Java)")
    print("  4. Mutable defaults, late-binding closures, LEGB: know them cold")
    print("  5. Decorators, generators, comprehensions: the Pythonic toolkit")

What does this code do?

This prints a tidy wrap-up of the five key interview takeaways your probe has demonstrated. It serves as a quick-reference cheat sheet every time you run the tool.

Before you run, what do you think you will see printed when the with ManagedResource("database connection") block executes?

  • Save probe.py and run the full probe:
python probe.py

You should see "Acquiring: database connection", then "Using: database connection", then "Releasing: database connection" printed in sequence. The output ends with the five key takeaways for interviews.

Seeing a NameError for ManagedResource?

Make sure the ManagedResource class is defined at the top level (not indented inside another function) and appears ABOVE probe_patterns() in the file. Check that the class name matches exactly in both the definition and the with statement.

help me fix my ManagedResource error

✔️ Awesome, I've got everything!

Great. Double check you have saved probe.py and that your output shows all four probes followed by the "PROBE COMPLETE" summary.

ⓧ I'd like to double check the full code

Your probe.py is now over 200 lines. Scroll through and confirm the following sections are present in this order:

  • Imports: dis, sys, time, functools, Generator from collections.abc.
  • probe_bytecode() function (from Step 2).
  • probe_memory() function (from Step 3).
  • probe_gotchas() function (from Step 4).
  • timer() decorator function.
  • retry() parameterized decorator function.
  • fibonacci() generator function.
  • ManagedResource class with __enter__ and __exit__.
  • probe_patterns() function covering decorators, generators, memory comparison, context managers, and comprehensions.
  • if __name__ == "__main__" block calling all four probe functions, ending with the PROBE COMPLETE summary.

Your probe now demonstrates the full Pythonic toolkit. Decorators add behavior without touching original code. Generators handle infinite data in constant memory. Context managers guarantee cleanup. Next up, the Secret Mission challenges you to prove the GIL's effect on CPU-bound vs IO-bound threading.

Secret mission

The GIL Probe

The Global Interpreter Lock is the single most-asked Python interview question for experienced developers. Can you prove its effect empirically by running CPU-bound and IO-bound work in threads and measuring the difference?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Python's standard library. There are no ongoing costs, no cloud resources, and no paid services to worry about.

Decide whether to keep your project files or delete them.

Resources you used:

  • The python-internals-probe folder on your Desktop containing probe.py.

Keep everything running

No action needed. Choose this if you want to keep your probe as a reference tool for interview prep or future Python exploration.

  • Your probe.py file stays on your Desktop and takes up minimal disk space.
  • Run python probe.py anytime to refresh your knowledge before an interview.

Pause - I'll come back to this later

There are no running processes or services to pause. Your project is just a file on disk.

  • The python-internals-probe folder will stay on your Desktop until you decide to delete it.
  • No background processes are running. Nothing to stop or shut down.

Delete - I don't want to use this again

Remove the project folder entirely to free up disk space.

  • Delete the project folder by running this command in your terminal.

macOS

rm -rf ~/Desktop/python-internals-probe

Windows

rmdir /s /q %USERPROFILE%\Desktop\python-internals-probe
  • Confirm the folder is gone by checking your Desktop. You should no longer see python-internals-probe listed.

That's a Wrap!

That's a Wrap!

Nice work. You just built a Python Internals Probe that uses Python to investigate itself at the implementation level.

You've learned how to:

  • Built a bytecode inspector that disassembles Python functions into VM opcodes, proving that CPython compiles source to bytecode before interpreting it.
  • Probed the object model to observe reference counting, object identity, and the memory cost of "everything is an object" compared to C++ and Java.
  • Triggered Python's famous gotchas (mutable defaults, late-binding closures, LEGB scoping, shallow copies) and implemented Pythonic patterns like decorators, generators, and context managers.
  • Secret Mission: Empirically proved the GIL's effect on CPU-bound vs IO-bound threading, showing ~1x speedup for CPU work and ~2x speedup for IO work.

Ready to quiz yourself?