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:
- Reads the
.pyfile - Compiles it into bytecode
- 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.