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.

Pyodide / WebAssembly
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() and eval() 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.

Pyodide / WebAssembly
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=.

Pyodide / WebAssembly
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_freevars and co_cellvars: closure bookkeeping;
  • co_filename, co_name, and co_qualname: diagnostic identity;
  • co_flags: properties such as generator or coroutine code;
  • co_stacksize: required evaluation-stack depth;
  • co_lines() and co_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.

Pyodide / WebAssembly
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.

Pyodide / WebAssembly
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(), and exec() 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.

Pyodide / WebAssembly
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() and exec() 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

  1. Compile a list comprehension and locate its nested code object in co_consts. Compare its local names with the enclosing code.
  2. Compile lambda x: x + offset, inspect co_names, then place the lambda inside another function and inspect co_freevars.
  3. Use dis.get_instructions() and each instruction's positions field to map operations back to columns in a multi-line expression.
  4. Transform integer constants with ast.NodeTransformer, call ast.fix_missing_locations(), compile the tree, and execute it.
  5. 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."

Primary sources