Disassembly is compelling because it looks authoritative. Source code permits several interpretations in a reader's head; a column of capitalized operations appears to reveal what Python really does. That instinct is useful until it becomes cargo culting: counting opcodes as a performance proof, teaching one release's lowering as language semantics, or testing that a function emits an exact sequence.

Bytecode answers a narrower and better question: how did this CPython release encode this code object? It is excellent evidence for explaining name access, evaluation order, branching, cleanup, and optimizer decisions. It is not Python's specification, and it is not stable machine code. CPython 3.14 can quicken and specialize instructions after execution, includes inline cache storage, and changed several instruction forms from earlier releases.

This tutorial develops a repeatable reading method: start from a semantic question, use structured instruction records, reconstruct stack and control flow, compare source positions, and stop when the evidence answers the question.

Version boundary. Every experiment was run on CPython 3.14.7. Evaluation order and observable expression behavior come from Python's language reference. dis, opcode names, instruction widths, cache entries, specialized forms, and compiler optimizations describe CPython and can change in a maintenance or feature release.

Experiment 1: inspect records, not formatted columns

dis.dis() is designed for people. dis.get_instructions() supplies records that tools can inspect without parsing spacing or arrows.

import dis


def invoice(rate, hours):
    subtotal = rate * hours
    return subtotal + 5


for instruction in dis.get_instructions(invoice):
    print(
        instruction.opname,
        instruction.argval,
        instruction.starts_line,
        instruction.is_jump_target,
    )

The output includes local loads, a multiply represented by BINARY_OP, a store, an addition, and a return. CPython 3.14 may combine adjacent loads or use borrowed-reference local operations. Those details are useful when comparing 3.14 builds, but the stable conclusion is simply that operands are evaluated, multiplication occurs before assignment, and the return expression reads the local.

An Instruction also exposes offset, opcode, arg, argrepr, positions, and cache-related fields. Prefer argval when you need the resolved constant, name, or target. argrepr is presentation text and should not become a parser interface.

Start every investigation by writing the question. "Why is rate considered local?" is answerable. "What does all this bytecode mean?" invites a tour with no stopping condition.

A stack machine is a tracing model

Most operations consume references from an evaluation stack and push results. Locals, constants, and globals are stored elsewhere; load operations place their values on the stack. Binary operations pop operands and push a result. A store removes a value and writes it into a namespace or local slot.

This is not the same as saying every Python value lives in a separate stack allocation. CPython's internal frame storage and borrowed references are implementation concerns. The evaluation stack is the right logical model for instruction effects.

Experiment 2: prove left-to-right evaluation

Function-call arguments are evaluated left to right. Disassembly can connect that guarantee to one implementation.

import dis


def mark(label):
    print(label)
    return label


def consume(*values):
    return values


def run():
    return consume(mark("first"), mark("second"))


print(run())
for item in dis.get_instructions(run):
    if item.opname != "RESUME":
        print(item.opname, item.argrepr)

The prints establish observable behavior: first precedes second. The instruction stream shows one mark call completed before construction of the second argument and the final consume call.

Python guarantee. Expressions are evaluated left to right, including call arguments. CPython 3.14 detail. The exact combination of load, push-null, and call instructions is not guaranteed. Use the language rule in application reasoning and bytecode only to diagnose this interpreter.

This ordering matters when argument expressions mutate shared state, consume iterators, or can fail. The better engineering response is usually to remove surprising side effects, not to depend on an opcode sequence.

Experiment 3: calculate stack effects

dis.stack_effect() reports an opcode's net logical effect. Jumping and non-jumping paths can differ.

import dis


def choose(flag, left, right):
    return left if flag else right


for item in dis.get_instructions(choose):
    if item.arg is None:
        effect = dis.stack_effect(item.opcode)
    else:
        effect = dis.stack_effect(item.opcode, item.arg)
    print(item.opname, effect)

Loads increase depth, conditional tests consume their condition, and return consumes the result. Do not sum every printed effect blindly across a branch: mutually exclusive paths are not one linear execution. To derive maximum depth, follow control-flow edges and merge compatible stack heights. The compiler has already done that work; choose.__code__.co_stacksize records the required capacity.

For jump-sensitive instructions, pass jump=True and jump=False to compare paths. Tooling that models bytecode must understand basic blocks, not just iterate records. If your real goal is source linting, use ast instead; the AST preserves language structure and avoids reconstructing it from a lower-level, unstable form.

Experiment 4: follow targets instead of indentation

Branches are explicit edges. Build a small index by offset and inspect target values.

import dis


def classify(value):
    if value < 0:
        return "negative"
    if value == 0:
        return "zero"
    return "positive"


instructions = list(dis.get_instructions(classify))
offsets = {item.offset for item in instructions}
for item in instructions:
    target = item.argval if item.opcode in dis.hasjump else None
    if target is not None:
        assert target in offsets
    print(item.offset, item.opname, target)

Each conditional edge skips to the next test when false; each successful branch returns. Source indentation has been lowered into edges and terminal operations. Loops similarly contain a backward edge, but opcode names and direction encodings have changed repeatedly.

Coverage tools care about edges because executing both lines of a compact condition does not imply taking both outcomes. Application developers usually should remain at source level. Reach for the graph when diagnosing branch coverage, compiler output, or generated code.

Experiment 5: map instructions back to columns

Since PEP 657, code objects can carry precise expression positions. The positions record connects operations to lines and columns.

import dis


def calculate(price, quantity, discount):
    return (price * quantity) - discount


for item in dis.get_instructions(calculate):
    if item.positions.lineno is not None:
        print(
            item.opname,
            item.positions.lineno,
            item.positions.col_offset,
            item.positions.end_col_offset,
        )

Several instructions can share one source span, and an instruction can have no position. Positions describe association, not a promise of one opcode per token. Python can also run with debug ranges disabled, making column data unavailable. Diagnostic code must tolerate missing positions.

The practical payoff appears in tracebacks that underline a failing subexpression. For generated code, preserve meaningful filenames and source text; precise offsets cannot display source that diagnostic machinery cannot retrieve.

Experiment 6: constants reveal optimization, not a mandate

Compare code objects rather than claiming Python must fold a particular expression.

import dis


def folded():
    return 20 * 2 + 2


def runtime(value):
    return value * 2 + 2


for function in (folded, runtime):
    print(function.__name__, function.__code__.co_consts)
    print([(item.opname, item.argval) for item in dis.get_instructions(function)])

On CPython 3.14, folded loads the already computed integer 42; runtime performs operations. Constant folding is permitted because it preserves observable behavior for these built-in constants. The optimizer has budgets and deliberately avoids some huge constants or transformations.

Never make correctness depend on folding, and do not infer production speed from the source spelling. Measure the full workload. Folding might remove nanoseconds from code dominated by I/O, while changing an algorithm can remove millions of operations.

optimize=1 and optimize=2 have documented semantic effects on assertions, __debug__, and docstrings. Other folding choices are CPython version behavior. Keep those categories separate.

Experiment 7: specialization is runtime evidence

CPython can adapt frequently executed instructions to observed types. Warm a function, then request adaptive disassembly.

import dis


def add_pair(pair):
    return pair[0] + pair[1]


for _ in range(20_000):
    add_pair((20, 22))

plain = [item.opname for item in dis.get_instructions(add_pair)]
adaptive = [
    item.opname
    for item in dis.get_instructions(add_pair, adaptive=True)
]
print(plain)
print(adaptive)
print(add_pair((1, 2)))

The adaptive list can contain specialized or instrumented forms absent from the default view. Exact names and whether specialization occurs depend on the CPython build, runtime history, and 3.14's optimizer. The result 3 is semantics; the adaptive output is a diagnostic snapshot.

Specialization is why copied disassembly from a blog can disagree with your process while both are correct. Warm-up, types, tracing, environment, and flags matter. Record all of them in performance investigations. Do not contort clear code to provoke an opcode unless a representative benchmark proves value and regression tests preserve behavior.

Experiment 8: caches are part of the encoding

Show cache entries explicitly and compare the two views.

import dis


class Account:
    balance = 42


def read(account):
    return account.balance


hidden = list(dis.get_instructions(read, show_caches=False))
shown = list(dis.get_instructions(read, show_caches=True))
print(len(hidden), len(shown))
print([item.opname for item in shown])

The cache-aware view is longer. Inline cache entries support runtime optimization and occupy locations in CPython's instruction representation even though the default display hides them. Consequently, raw co_code indexing and hand-authored jump offsets are particularly fragile.

Do not mutate co_code to patch behavior. A valid 3.14 code object also has exception tables, line and position data, stack invariants, flags, constants, and cache expectations. Source transformation followed by compile() is safer because the compiler rebuilds the complete artifact. If interception is the goal, use decorators, tracing, profiling, audit hooks, or a documented import hook.

A disciplined reading workflow

Use this sequence in reviews and investigations:

  1. State the semantic or performance question.
  2. Reduce it to a small function without changing the behavior under study.
  3. Record sys.implementation, sys.version, flags, and warm-up conditions.
  4. Run the function first; output outranks your reading of disassembly.
  5. Use dis.get_instructions() and source positions.
  6. Draw basic blocks for branching code and track stack state only where needed.
  7. Compare cold/default and adaptive output only for optimization questions.
  8. Finish with a source-level engineering decision.

Bytecode is especially valuable for confirming that a name is local, global, or free; locating implicit cleanup; explaining why a line has several traceable positions; and understanding compiler-generated control flow. It is weak evidence for end-to-end performance and the wrong abstraction for portable semantics.

When sharing a disassembly in an incident or review, include the reduced source beside it. A naked opcode listing loses semantic names, environment, and the reason it was collected. Annotated evidence lets a future Python upgrade replace the listing without erasing the argument.

Practical decisions

  • Test behavior and public introspection results, not exact opcode lists.
  • Pin a CPython feature release when a bytecode tool genuinely requires one, and reject unsupported versions explicitly.
  • Prefer Instruction records over parsing dis.dis() output or indexing co_code.
  • Use ASTs for source analysis and transforms; use bytecode for post-compilation diagnosis.
  • Treat specialization as an observation, never a correctness dependency.
  • Preserve filenames and source positions in generated code.
  • Benchmark representative work before changing source to influence lowering.
  • Recheck bytecode tools on every Python upgrade, including maintenance releases.

Exercises

  1. Disassemble a and b() and a or b(). Draw both control-flow graphs and connect them to short-circuit semantics.
  2. Use dis.stack_effect() on every path through a conditional expression and verify the merge heights agree.
  3. Compare a list comprehension with an equivalent loop. Identify compiler choices without claiming either must be faster.
  4. Warm one attribute-access function with objects of one class and then several classes. Record adaptive output and timings separately.
  5. Run the experiments on another CPython feature release. Make two lists: semantic similarities and encoding differences.
  6. Write a checker that reports unsupported sys.version_info before examining instructions, then explain why that guard is still insufficient for arbitrary bytecode mutation.

Keep this model

CPython bytecode is a versioned executable encoding inside a code object. dis turns that encoding into structured evidence: operations, arguments, targets, cache entries, and source positions. The evaluation stack and control-flow graph let you follow one implementation's work without mistaking that work for the Python language definition.

Read from a question outward. Establish observable behavior, inspect the smallest relevant region, label implementation details, and return to a source-level decision. When an opcode changes, your model should bend without your application breaking.

Primary sources