Skip to main content
Bytes & Beyond

Python Under the Hood

How Python turns a .py file into module objects, global namespaces, function and class objects, and bytecode executed by the PVM

Introduction

Most Python developers write code without thinking much about what actually happens when Python runs it. But understanding Python’s internal execution model can meaningfully improve your ability to debug complex issues, avoid circular imports, reason about async behavior, design cleaner architectures, and write more authoritative technical content.

In this article, we’ll trace the full journey of a .py file — from source text on disk to objects, namespaces, and bytecode running in memory.


1. Python Files Are Modules

Every Python file is treated as a module. Consider this simple project:

project/
├── main.py
└── mymodule.py

mymodule.py:

x = 42

def global_func():
    return x

class MyClass:
    class_var = "class-level"

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

    def func1(self):
        return f"{self.name} - func1"

    def func2(self):
        return f"{self.name} - func2"

    async def async_func(self):
        return f"{self.name} - async_func"

When Python loads mymodule.py, it creates a module object in memory that represents the entire file.


2. Python Compiles Code to Bytecode

Python doesn’t execute raw source text directly. Instead, it follows three steps:

  1. Reads the .py file
  2. Compiles it into bytecode
  3. Executes that bytecode using the Python Virtual Machine (PVM)

The compiled bytecode is cached in:

__pycache__/mymodule.cpython-XY.pyc

You can inspect it directly using the dis module:

import dis
import mymodule

dis.dis(mymodule.global_func)

Which might output something like:

LOAD_GLOBAL 0 (x)
RETURN_VALUE

These low-level instructions are what the Python interpreter actually executes.


3. Module Objects

After compilation, Python wraps everything in a module object:

import mymodule

print(type(mymodule))
# <class 'module'>

This object acts as the top-level container for everything defined in the file — variables, functions, classes, and metadata.


4. The Global Namespace

Every module has a global namespace: a dictionary that maps names to objects.

print(mymodule.__dict__)

Conceptually, it looks like this:

{
    'x': 42,
    'global_func': <function>,
    'MyClass': <class>,
    '__name__': 'mymodule',
    '__file__': 'mymodule.py',
    ...
}

When Python encounters a bare name like x, it searches this dictionary to resolve it.


5. Function Objects and Code Objects

Every function is an object — a first-class value that can be passed around, inspected, and called.

print(mymodule.global_func)
# <function global_func at 0x...>

Each function object contains a code object, which holds the actual bytecode along with metadata:

print(mymodule.global_func.__code__)
# <code object global_func at 0x..., file "mymodule.py", line 3>

A code object stores:

  • The compiled bytecode
  • Variable and argument names
  • Constants used in the function
  • Argument count and other metadata
Function Object
    └── Code Object (bytecode + metadata)

6. Class Objects

Classes are objects too — instances of type.

print(mymodule.MyClass)
# <class 'mymodule.MyClass'>

Each class has its own namespace, which holds all its methods and class-level attributes:

print(list(mymodule.MyClass.__dict__.keys()))
# ['__init__', 'func1', 'func2', 'async_func', 'class_var', ...]

Every method is a function object stored inside the class namespace:

MyClass (Class Object)
├── __init__ → Function Object
├── func1    → Function Object
├── func2    → Function Object
└── async_func → Function Object

7. Instances and Bound Methods

Calling a class creates a new instance object in memory:

obj1 = mymodule.MyClass("Alice")
obj2 = mymodule.MyClass("Bob")

When you access a method through an instance, Python creates a bound method — a wrapper that ties the function to the specific instance:

print(obj1.func1)
# <bound method MyClass.func1 of <MyClass object at 0x...>>

A bound method pairs the instance (self) with the underlying function object from the class:

Bound Method
├── instance (self = obj1)
└── function object (MyClass.func1)

Importantly, all instances share the same underlying function objects — only the binding differs.


8. Async Functions

Async methods are defined and stored just like regular functions:

async def async_func(self):
    ...

The difference appears at call time. Calling an async function doesn’t execute it — it returns a coroutine object:

coro = obj1.async_func()
print(coro)
# <coroutine object MyClass.async_func at 0x...>

The body only runs when the coroutine is awaited:

await coro

This is how Python can suspend and resume execution without blocking — the coroutine object preserves the function’s execution state between suspensions.


9. Type Hints and Forward References

Sometimes you need to reference a type before it’s been defined or imported. Python supports string-based forward references for this:

def process_file(tasks: "BackgroundTasks"):
    pass

Wrapping the type hint in quotes tells Python to treat it as a string rather than evaluating it immediately. This prevents NameError exceptions and is a common pattern for breaking circular import dependencies.


10. Circular Imports

Circular imports happen when two modules import each other:

a.py → imports b
b.py → imports a

Python loads modules top-to-bottom and tracks partially loaded modules in sys.modules. If module a imports b, and b tries to import something from a before a has finished loading, the import will succeed — but the requested name may not exist yet, causing an AttributeError or ImportError.

Common solutions:

  • Use string forward references for type hints
  • Import inside functions to defer the import until it’s needed
  • Refactor modules to eliminate the circular dependency entirely

11. The Full Picture

Here’s how everything fits together:

mymodule.py (Source File)

├── Compiled to Bytecode (.pyc)

└── Module Object

     └── Global Namespace (dict)

          ├── x → 42

          ├── global_func → Function Object
          │                    └── Code Object (bytecode)

          └── MyClass → Class Object

                 ├── class_var → "class-level"
                 ├── __init__  → Function Object → Code Object
                 ├── func1     → Function Object → Code Object
                 ├── func2     → Function Object → Code Object
                 └── async_func → Function Object → Code Object

Instances and bound methods:

obj1 = MyClass("Alice")    →  Instance Object (name="Alice")
obj2 = MyClass("Bob")      →  Instance Object (name="Bob")

obj1.func1  →  Bound Method (obj1 + MyClass.func1)
obj2.func1  →  Bound Method (obj2 + MyClass.func1)

Final Thoughts

At its core, Python is built on three foundational concepts: objects, namespaces, and bytecode. Every .py file becomes a module object; that module holds a global namespace of names pointing to other objects; and those objects — functions, classes, instances — ultimately bottom out in code objects containing bytecode that the Python Virtual Machine executes.

Once this model clicks, a lot of Python’s seemingly mysterious behavior becomes obvious. Circular imports are a namespace timing problem. Async functions are objects that produce coroutines. Forward references are just strings that defer name resolution. Dynamic introspection works because everything is a dict.

This is the knowledge that separates developers who use frameworks from those who build them.