compile() sounds like an action, not a factory. It is tempting to imagine that it turns source into a finished function, or perhaps into the opaque bytes saved in __pycache__. Neither model is quite right.
In its ordinary form, compile() produces a code object. A code object is an immutable description of executable Python code: instructions plus constants, names, source positions, flags, and other metadata. It is not running. It has no current argument values, global namespace, instruction pointer, or exception state. Those arrive later, when evaluation creates a frame.
There is also an alternate result. With an AST compiler flag, compile() stops earlier and returns an abstract syntax tree. Following both paths gives us a useful pipeline:
source -> tokens -> AST -> symbol analysis -> instructions/CFG -> code object
|
namespaces + call state -> frame -> execution
The first half describes compilation; the second describes execution. We will inspect the boundary using only standard-library tools.
Version note. The public
compile(),ast, code-object, and frame interfaces discussed here are Python interfaces. Opcode names, bytecode layout, optimizations, inline caches, and CPython's internal compiler stages are implementation and version details. Experiments were run on CPython 3.14.7.
Experiment 1: three modes, two possible products
The mode argument says what grammatical unit the source represents. It does not select a different result type.
import ast
cases = [
("exec", "answer = 6 * 7"),
("eval", "6 * 7"),
("single", "6 * 7"),
]
for mode, source in cases:
tree = compile(source, "<demo>", mode, ast.PyCF_ONLY_AST)
code = compile(source, "<demo>", mode)
print("[result] mode, AST, code, code name:", mode, type(tree).__name__, type(code).__name__, code.co_name)
On Python 3.14 this prints:
exec Module code <module>
eval Expression code <module>
single Interactive code <module>
exec accepts a suite of statements. eval accepts one expression and produces code whose evaluated value can be returned. single implements an interactive statement: a non-None expression result is sent through the display hook, which is why a REPL prints values without an explicit print().
The AST root reflects that grammar, while the normal result remains types.CodeType. ast.parse() is the convenient spelling for the AST path. Internally it calls compile() with PyCF_ONLY_AST.
Python guarantee.
compile()accepts string, bytes, or AST input and returns a code object, except when an AST-returning flag is requested.exec()andeval()accept code objects. The exact representation of executable instructions is not a language guarantee.
The AST has structure, not runtime values
The parser has already discarded punctuation that does not affect meaning and organized the program into nodes. Names also carry context: loading price is different from storing into price.
import ast
source = "subtotal = price * quantity"
tree = ast.parse(source)
print("[state] AST:", ast.dump(tree, indent=2, include_attributes=False).replace("\n", "\n[state] "))
The important portion is an Assign whose target is Name(..., ctx=Store()); its value is a BinOp containing two Name(..., ctx=Load()) nodes. There is no multiplication result because price and quantity do not have runtime bindings yet.
ASTs are useful for linters, source transforms, and code generation because they retain language-level structure. They are not a stable serialized interchange format: Python's abstract grammar can change between releases. In 3.14, for example, template strings added TemplateStr and Interpolation, old literal node classes such as Num and Str were removed, and AST repr() began including field values.
If you modify a tree, every required field and source location must be valid before compilation. ast.NodeTransformer plus ast.fix_missing_locations() is usually safer than constructing a large tree by hand.
Experiment 2: inspect the optimized AST
Python 3.13 added ast.PyCF_OPTIMIZED_AST, allowing tools to request the AST after compiler optimization appropriate to optimize=.
import ast
source = """\
if __debug__:
result = 40 + 2
"""
flags = ast.PyCF_ONLY_AST | ast.PyCF_OPTIMIZED_AST
for level in (0, 1):
tree = compile(source, "<opt>", "exec", flags, optimize=level)
print("[result] optimization level and AST:", level, ast.dump(tree, include_attributes=False))
On 3.14, __debug__ is represented as Constant(value=True) at level 0 and Constant(value=False) at level 1. This is an excellent inspection surface, but not a promise that every possible constant expression will be folded. Optimizer choices can change without changing Python semantics.
The ordinary AST and optimized AST answer different tooling questions. A refactoring tool generally wants the programmer's structure. A compiler explorer may want to know what survives optimization.
A code object is a recipe, not a function
Code objects expose read-only co_* attributes. Some central ones are:
co_code: CPython bytecode bytes;co_consts: literals and nested code objects;co_names: names resolved outside fast local slots;co_varnames: parameters and local variable names;co_freevarsandco_cellvars: closure bookkeeping;co_filename,co_name, andco_qualname: diagnostic identity;co_flags: properties such as generator or coroutine code;co_stacksize: required evaluation-stack depth;co_lines()andco_positions(): mappings back to source.
The attributes are descriptive, not a supported bytecode-editing API. code.replace() can copy a code object with selected fields changed, but replacing instruction bytes requires exact knowledge of that CPython release's bytecode, cache layout, exception table, and invariants.
Experiment 3: nested definitions are nested recipes
Compiling a def at module level does not immediately produce the final function object. The module code contains another code object as a constant. Executing the module code creates the function and associates that nested code with globals, defaults, annotations, and possibly closure cells.
import types
source = """\
factor = 3
def scale(value):
return value * factor
"""
module_code = compile(source, "pricing.py", "exec")
children = [
value for value in module_code.co_consts
if isinstance(value, types.CodeType)
]
function_code = children[0]
print("[state] module names and local variables:", module_code.co_names, module_code.co_varnames)
print("[state] function name and local variables:", function_code.co_name, function_code.co_varnames)
print("[state] function global names:", function_code.co_names)
print("[state] function source location:", function_code.co_filename, function_code.co_firstlineno)
Output on our run:
('factor', 'scale') ()
scale ('value',)
('factor',)
pricing.py 2
At module scope, assignments use the module namespace rather than function fast-local slots, so co_varnames is empty. Inside scale, value is local and factor is looked up as a global. A truly nested function capturing an enclosing function's local would list that name in co_freevars instead.
This distinction matters when dynamically constructing functions. types.FunctionType(code, globals) supplies a globals dictionary, but code with free variables also needs correctly ordered closure cells. A code object alone is deliberately incomplete.
Experiment 4: disassemble without parsing text output
dis.dis() is excellent at the terminal. Programs should prefer dis.get_instructions(), which yields structured Instruction records.
import dis
def total(items):
result = 0
for item in items:
result += item
return result
for instruction in dis.get_instructions(total):
print(f"{instruction.opname:34} {instruction.argval!r}")
CPython 3.14 emits operations including RESUME, GET_ITER, FOR_ITER, BINARY_OP, a backward jump, and RETURN_VALUE. It may combine adjacent local loads and may use LOAD_FAST_BORROW, both 3.14-era implementation choices. Do not write application logic that requires this exact sequence.
The durable lesson is stack-machine shaped: instructions load references, perform operations, store results, and branch. dis.stack_effect() can inspect how an opcode changes the evaluation stack. The compiler computes co_stacksize from these paths so the runtime can provision frame storage.
Since 3.11, CPython can specialize instructions as code runs and stores inline cache data beside instructions. In 3.14, dis can show caches, adaptive instructions, offsets, and source positions. The default disassembly is the best starting point; specialized output answers performance questions, not language-semantics questions.
Experiment 5: execution supplies namespaces
The same code object can run repeatedly against different mappings.
code = compile("result = rate * hours", "invoice.py", "exec")
first = {"rate": 80, "hours": 2}
second = {"rate": 125, "hours": 3}
exec(code, first)
exec(code, second)
print("[result] namespace-specific results:", first["result"], second["result"])
print("[check] name recorded but value unbound:", "rate" in code.co_names, not hasattr(code, "rate"))
This prints 160 375, then True True. co_names records that generated instructions use the spelling rate; it does not bind rate to 80 or 125.
If the globals dictionary lacks __builtins__, exec() inserts one. Passing separate globals and locals has class-body-like name-resolution behavior, which often surprises code trying to use exec() as an ordinary function scope. Pass explicit mappings and test the exact lookup behavior you need.
Security decision. Replacing
__builtins__is not a sandbox.compile(),eval(), andexec()must not process untrusted code in your application process. Parsing to an AST also needs resource limits: sufficiently deep or complex input can exhaust interpreter resources.
Experiment 6: watch a frame appear
A frame is one execution of code. It joins a code object with globals, builtins, locals, an instruction position, and tracing/exception state.
import sys
code = compile("seen = token + 1", "worker.py", "exec")
events = []
def trace(frame, event, arg):
if frame.f_code is code and event in {"call", "return"}:
events.append((event, frame.f_code.co_filename, frame.f_locals.get("seen")))
return trace
namespace = {"token": 41}
sys.settrace(trace)
try:
exec(code, namespace)
finally:
sys.settrace(None)
print(events)
The call event sees no seen binding; the return event sees 42. Both frame events point to the exact same code object. A later exec(code, other_namespace) creates a different frame around that same recipe.
sys.settrace() and frame attributes are intended for debuggers, profilers, and coverage tools. A tracing function changes execution performance and can keep frames and everything reachable from their locals alive. Clear retained tracebacks and frames in long-running diagnostic systems.
Experiment 7: filenames become operational metadata
The filename argument is not required to name a real file, but it should identify the source meaningfully.
import traceback
code = compile("1 / 0", "generated/rules.py", "exec")
try:
exec(code, {})
except ZeroDivisionError:
print("[error] generated source location:", traceback.format_exc().splitlines()[-3])
The traceback line names generated/rules.py. Debuggers, tracebacks, coverage tools, and source-position consumers use this metadata. Prefer a stable virtual name such as "<template:welcome>" over the generic "<string>" when generated code has an identity users may need to diagnose.
Code objects carry position tables rather than source text. linecache is why tracebacks can often display source lines. For generated code, registering source in linecache.cache can make diagnostics substantially better.
Experiment 8: optimization changes the recipe
optimize=-1 inherits the interpreter setting. Explicit levels are 0, 1, and 2: level 1 removes assertions and sets __debug__ false; level 2 also removes docstrings.
import dis
source = '"module docs"\nassert ready\nvalue = 42\n'
for level in (0, 1, 2):
code = compile(source, "checks.py", "exec", optimize=level)
opnames = [item.opname for item in dis.get_instructions(code)]
print(level, code.co_consts, "RAISE_VARARGS" in opnames)
On 3.14, level 0 retains the docstring and assertion path, level 1 retains the docstring but removes the assertion, and level 2 removes both. Exact constants and opcodes are implementation details; the documented semantic effects are what you may rely on.
Never use assert for input validation, authorization, or required invariants at system boundaries. Optimization can intentionally erase it.
What .pyc adds, and what it does not
Import machinery can cache marshalled code objects in .pyc files. A cache header lets the loader decide whether the cache matches the interpreter and source state. This avoids repeated parsing and compilation; it does not turn Python into native machine code, and importing still executes the module code to populate a module namespace.
The marshal module can serialize code objects, but its documentation is unusually direct: code-object formats are not compatible across Python versions, loading one under the wrong version has undefined behavior, and untrusted marshal data is unsafe. Use normal import machinery for caches. Use a specified data format, not code objects, for durable application artifacts.
Practical decisions
- Use
ast.parse()and AST visitors when you need language structure. - Use
compile()once and reuse the code object when repeatedly evaluating trusted expressions; measure before assuming compilation dominates. - Use
dis.get_instructions()for CPython diagnostics, never as a cross-version contract. - Give generated code a meaningful filename and preserve its source when diagnostics matter.
- Pass explicit namespaces to
eval()andexec()so data flow is reviewable. - Do not mutate raw bytecode or persist code objects across interpreter versions.
- Treat dynamic code execution as arbitrary code execution, not as configurable data evaluation.
Exercises
- Compile a list comprehension and locate its nested code object in
co_consts. Compare its local names with the enclosing code. - Compile
lambda x: x + offset, inspectco_names, then place the lambda inside another function and inspectco_freevars. - Use
dis.get_instructions()and each instruction'spositionsfield to map operations back to columns in a multi-line expression. - Transform integer constants with
ast.NodeTransformer, callast.fix_missing_locations(), compile the tree, and execute it. - Run the optimization experiment on another Python release. Record semantic changes separately from opcode changes.
Keep this model
Source is not executed directly, and a code object is not a suspended execution. CPython parses source into an AST, analyzes scope, lowers it through instructions and a control-flow graph, optimizes it, and assembles an immutable code object. Nested definitions contribute nested code objects.
Execution then supplies what compilation could not know: namespaces, arguments, closure cells, current instruction state, and exception context. That combination is a frame. A function is another layer again: it packages a code object with globals, defaults, annotations, and closure state so calls can create frames repeatedly.
When tooling behaves strangely, ask which layer it is inspecting. AST, code object, function, frame, and .pyc are related, but they are not interchangeable names for "compiled Python."