Python Execution Model
Running a Python program means compiling source into bytecode and handing that bytecode to a virtual machine — this topic traces the whole path from .py file to running behavior, including imports, caching, and the REPL.
Understanding what actually happens when you type python file.py clears up a lot of confusion about what "interpreted language" means, why a __pycache__ folder full of .pyc files shows up, and how imports execute code exactly once. This topic walks the full path from source file to running program in CPython, and draws a careful line between compile-time and runtime errors.
The .py Source File
A Python program starts life as one or more plain-text .py files containing source code — human-readable statements and expressions written according to Python's grammar. Nothing about a .py file is special to the operating system; it becomes executable behavior only once handed to a Python interpreter.
From Source to Bytecode: The Interpreter's First Job
When CPython runs a .py file, it does not execute the text directly. It first parses the source into an abstract syntax tree (AST) and compiles that AST into bytecode — a compact, platform-independent set of low-level instructions specific to the CPython virtual machine. This compilation step happens automatically, every time, as an internal part of running your program; there is no separate command you invoke to "compile" a Python file in ordinary use.
CPython implementation detail: the exact bytecode instruction set (the opcodes you'd see via the dis module) is specific to CPython and can change between versions; it is not part of the Python language specification, and other implementations (PyPy, Jython) use their own internal representations.
Bytecode and the .pyc Cache
Once a module is compiled to bytecode, CPython caches that bytecode on disk as a .pyc file, inside a __pycache__ directory next to the source, named with the Python version that produced it (e.g. module.cpython-312.pyc). The next time that module is imported, CPython checks the source file's modification time (and size) against the cached bytecode; if the source hasn't changed, it skips re-compiling and loads the cached bytecode directly, which speeds up subsequent imports.
CPython implementation detail: the .pyc cache and its exact naming/invalidation scheme are entirely a CPython optimization detail, not a language guarantee. This caching applies to imported modules; the file you run directly as the top-level script is compiled fresh each time and is not itself cached to a .pyc.
myproject/
├── main.py
└── __pycache__/
└── helpers.cpython-312.pyc"Interpreted Language" — Explained Carefully
Saying flatly "Python has no compilation" is inaccurate. What's true is: CPython compiles source to bytecode (an intermediate representation), then a virtual machine written in C interprets that bytecode instruction by instruction. This is different from a language like C, which compiles ahead-of-time all the way down to native machine code for a specific CPU architecture, producing a standalone executable — and different again from purely interpreted execution with no intermediate compiled form at all.
The most accurate description: Python source is compiled to bytecode, and that bytecode is interpreted. This happens automatically and transparently every time you run a program — there's no separate developer-facing "build" step in typical usage, which is the behavioral distinction people are usually gesturing at when they call Python "interpreted."
The Import Process and Module Execution
When a module is imported for the first time in a running program, Python: (1) locates the module (searching sys.path), (2) compiles it to bytecode if a valid cached .pyc isn't already available, (3) creates a new module object, and (4) executes the module's top-level code in that module's own namespace, top to bottom, exactly once. Any subsequent import of the same module within the same running program reuses the already-created module object from sys.modules — it does not re-execute the module's code.
Running main.py prints "counter.py is executing" only once, then 0 — the second import statement finds the module already in sys.modules and simply reuses it.
# counter.py
print("counter.py is executing")
count = 0# main.py
import counter
import counter # no-op: counter.py's top-level code does NOT run again
print(counter.count)Running a File Directly: python file.py
python file.py compiles and executes file.py as the top-level script. Its __name__ is set to "__main__" (see the Program Structure topic for the if __name__ == "__main__": idiom). Python inserts the script's own directory at the front of sys.path, so the script can import sibling modules in the same directory.
Running a Module: python -m module
python -m module runs a module by its importable name rather than by file path, using the normal import machinery to locate it on sys.path. This is the standard way to run a module that lives inside a package (where relative imports need the package context to work correctly), and it's how many standard-library and third-party tools are invoked, such as python -m venv or python -m pip.
python script.py # runs script.py directly as a file path
python -m package.module # runs a module found via sys.path, resolved by import nameCommand-Line Arguments
Arguments passed after the script name on the command line are made available to your program through sys.argv, a list of strings, where sys.argv[0] is the script's own path/name and the rest are the arguments that followed it.
For real command-line interfaces with flags, help text, and validation, the standard-library argparse module is the conventional tool, rather than parsing sys.argv by hand.
import sys
print(sys.argv)$ python greet.py Ada Lovelace
['greet.py', 'Ada', 'Lovelace']Compile-Time (Syntax) Errors versus Runtime Errors
A SyntaxError (or IndentationError/TabError, its more specific subclasses) is detected while Python is compiling your source to bytecode, before any of your code's logic runs — it means the code isn't even valid Python. A runtime error (like TypeError, ValueError, ZeroDivisionError, or a user-raised exception) happens while the already-compiled bytecode is executing, meaning the syntax was fine but something went wrong as the program actually ran.
A practical consequence: a SyntaxError anywhere in a module prevents the entire module from running at all (nothing in it executes, not even the lines before the error), whereas a runtime error only halts execution at the point it's raised — code that already ran has already had its effects.
def broken(: # SyntaxError — malformed syntax, caught before execution
return 1def divide(a, b):
return a / b
divide(10, 0) # ZeroDivisionError — valid syntax, fails while runningThe REPL and Interactive Execution
Running python (or python3) with no file argument starts the REPL — Read-Eval-Print Loop — an interactive prompt that compiles and executes one statement or expression at a time, immediately, and prints the result of expressions automatically (without needing an explicit print call). It's a convenient tool for quick experiments, but it is not how real programs are structured or run; production code lives in .py files.
Tools like Jupyter notebooks and IPython build richer interactive experiences on top of this same read-eval-print idea; they are third-party/ecosystem tools, not part of the standard library's basic REPL.
$ python
Python 3.12.4 (main, ...) on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 2 + 2
4
>>> name = "Ada"
>>> print(f"Hello, {name}")
Hello, AdaKey Takeaways
CPython compiles source to bytecode and then interprets that bytecode — it is not accurate to say Python has no compilation step.
The .pyc bytecode cache under __pycache__ is a CPython implementation detail that speeds up repeated imports; it is not a language guarantee.
A module's top-level code executes exactly once per running program, the first time it's imported; later imports reuse the cached module object from sys.modules.
python file.py runs by file path; python -m module runs by import name using sys.path — prefer -m for modules that live inside packages.
SyntaxError is caught at compile time, before any of the module's code executes; other exceptions occur at runtime, once execution reaches the failing line.
The REPL evaluates and prints one statement at a time interactively; it's a tool for experimentation, not where real programs live.
Treating Python as having no compilation step at all is inaccurate; the compile step to bytecode is just automatic and produces an intermediate form, not native machine code.
import mymodule #... import mymodule # expecting the module's top-level prints/side-effects to run again import mymodule # top-level code runs once, on first import; later imports reuse the cached module object
A second import of an already-imported module does not re-run its top-level code — it's fetched from sys.modules, so side effects (like print statements) only happen the first time.
SyntaxError is caught during compilation, before execution begins, so it's fundamentally different from a runtime exception that halts a program partway through.
Trace what happens, step by step, when you run python script.py — from the .py file to your program's behavior.
CPython parses script.py into an AST, compiles that AST into bytecode, sets __name__ to "__main__" for that module, and the bytecode interpreter (the CPython VM) executes the bytecode instructions in order. If the script imports other modules, each is located via sys.path, compiled (or loaded from a valid .pyc cache) and executed once, then made available in sys.modules.
Why do .pyc files and a __pycache__ directory appear after you import modules, but not necessarily after running a script directly?
CPython caches the compiled bytecode of imported modules on disk (as .pyc files under __pycache__) so future imports can skip recompilation if the source is unchanged. The top-level script you run directly is compiled fresh each run and isn't cached the same way. This caching behavior is a CPython implementation detail, not a language guarantee.
What's the practical difference between python script.py and python -m package.module?
python script.py runs a file directly by its filesystem path. python -m package.module runs a module by its dotted import name, resolved through the normal import machinery and sys.path — this correctly handles relative imports inside packages, which running the file directly by path often cannot.
If a module has a print() call at its top level and gets imported three separate times across your program, how many times does that print run?
Once. A module's top-level code executes only the first time it's imported; subsequent import statements for the same module retrieve the already-created module object from sys.modules without re-running its code.
How does a SyntaxError differ from a runtime error like ZeroDivisionError in terms of when it's detected and what has already executed?
A SyntaxError is detected while Python compiles the source to bytecode, before any of the module's code has run — so nothing in that module executes, not even statements before the error. A runtime error like ZeroDivisionError occurs during execution of already-valid bytecode; any code that ran before the error already had its effects.
Is it accurate to describe Python's .pyc bytecode caching as part of 'the Python language'? Why or why not?
No — it's a CPython implementation detail. The Python language specification does not require any particular implementation to cache compiled bytecode on disk; other implementations (PyPy, Jython) handle compilation and caching differently or not at all in the same way.
Given util.py above, what prints when main.py runs import util followed later by import util again?
"loading util" is printed exactly once. The second import finds util already present in sys.modules and reuses the existing module object without re-executing its top-level code.
Write a script that prints the second command-line argument passed to it (assume at least two are always given).
import sys print(sys.argv[2]) sys.argv[0] is the script name, sys.argv[1] is the first argument, and sys.argv[2] is the second argument passed on the command line.
What happens when this file is run, and does 'before f is called' get printed?
Nothing prints. The malformed def f(: is a SyntaxError, caught while CPython compiles the whole module, before any execution begins — so no line in the file runs, including the print statements that appear before the broken function definition.
In one sentence, explain why 'Python is a purely interpreted language with no compilation' is an imprecise claim about CPython.
CPython compiles Python source into bytecode (an intermediate, platform-independent instruction format) before a virtual machine interprets that bytecode — so a compilation step genuinely occurs, it's just automatic and doesn't produce native machine code the way C's compiler does.
# util.py
print("loading util")
VALUE = 100def f(:
return 1
print("before f is called")
print(f())