CPython can execute the same function differently after it has observed the function running. The language has not changed, and your code object still describes ordinary Python operations. Inside the interpreter, frequently executed instructions can become narrower operations guarded by facts such as "both operands are exact integers" or "this name is still in builtins."

This is the specializing adaptive interpreter introduced in Python 3.11 and substantially evolved since then. It is not a promise that dynamic Python becomes static, and it is not a reason to hand-optimize every local variable. It is a useful model for understanding why stable, ordinary code often gets faster without source changes.

Exact test target. Every output in this tutorial was verified with CPython 3.14.7, arm64, macOS 26.5.2, traditional GIL enabled, optimization level 0, non-debug build, Clang 21.0.0. Bytecode and specialization are CPython implementation details and change between feature releases. The Python language guarantees results and observable semantics, not opcode names, warmup thresholds, cache layouts, or specialization choices.

The mechanism in one pass

The compiler emits general bytecode such as BINARY_OP, LOAD_ATTR, LOAD_GLOBAL, and CALL. CPython allocates inline cache space next to specializable instructions. As an instruction executes, a counter eventually triggers an attempt to specialize it for the values seen at that location.

A successful specialization replaces the interpreter's executable form with a guarded variant. BINARY_OP_ADD_INT, for example, can take a direct path when both inputs are exact integers. If a guard fails, CPython performs correct generic behavior and updates adaptive state. Repeated evidence can cause another specialization or a return to the generic form.

This happens per instruction location, not per function name and not through whole-program type inference. Two a + b expressions in one function can specialize differently because they see different values.

PEP 659 describes the original architecture, but it is historical. In particular, old tutorials often show visible names ending in _ADAPTIVE. Do not expect that output on 3.14. Ask the running interpreter.

Experiment 1: compare cold and hot arithmetic

Run this in a fresh process:

import dis

def add(a, b):
    return a + b

print("cold, compiler-facing view")
dis.dis(add)

print("cold, executable view")
dis.dis(add, adaptive=True, show_caches=True)

for _ in range(10_000):
    add(1, 2)

print("hot, executable view")
dis.dis(add, adaptive=True, show_caches=True)

The ordinary 3.14 disassembly was:

RESUME                   0
LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
BINARY_OP                0 (+)
RETURN_VALUE

After integer warmup, the adaptive view included:

RESUME_CHECK             0
LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
BINARY_OP_ADD_INT        0 (+)
CACHE                    0 (counter: 832)
CACHE                    0 (descr: 0)
CACHE
CACHE
CACHE
RETURN_VALUE

BINARY_OP_ADD_INT is still Python integer addition. It must preserve arbitrary-precision integers, exceptions, and all observable semantics. The faster path removes repeated generic dispatch and type-slot discovery when its exact-type guards hold; it does not turn Python integers into unchecked machine integers.

The fused LOAD_FAST_BORROW_LOAD_FAST_BORROW and borrowed-reference forms are also 3.14 details. Specialization is part of a broader interpreter optimization pipeline, including combined instructions and cheaper reference handling.

Experiment 2: the default view remains stable

After warming add, compare both views again:

import dis

def add(a, b):
    return a + b

for _ in range(10_000):
    add(1, 2)

print("standard")
dis.dis(add)
print("specialized")
dis.dis(add, adaptive=True)
print("stored bytes:", list(add.__code__.co_code))

The standard view still printed BINARY_OP; only adaptive=True printed BINARY_OP_ADD_INT. This distinction is deliberate. Use ordinary disassembly to reason about compiler output and portable control flow within one CPython release. Use the adaptive view to inspect current runtime execution state.

Do not patch co_code, decode caches by hand, or persist adaptive output. The dis documentation explicitly says bytecode may change across Python releases and implementations. Even within 3.14, cache values such as version tags are process-local evidence, not public API.

3.14 API note. python -m dis -S file.py requests specialized bytecode from the CLI. -C shows caches, -O offsets, and the new 3.14 -P flag source positions. Calling dis.dis(function, adaptive=True, show_caches=True) is usually easier when you need to warm the function first.

Experiment 3: inspect several optimization families

One short function can exercise global lookup, a built-in call, instance attributes, subscripting, and arithmetic:

import dis

class Point:
    def __init__(self, x):
        self.x = x

point = Point(3)
values = [4]

def work(point, values):
    return len(values) + point.x + values[0]

for _ in range(10_000):
    work(point, values)

dis.dis(work, adaptive=True, show_caches=True)

The important hot instructions in our run were:

LOAD_GLOBAL_BUILTIN       (len + NULL)
CALL_LEN
LOAD_ATTR_INSTANCE_VALUE  (x)
BINARY_OP_ADD_INT         (+)
BINARY_OP_SUBSCR_LIST_INT ([])
BINARY_OP_ADD_INT         (+)

Each name states a guarded hypothesis:

  • LOAD_GLOBAL_BUILTIN caches where len was found and versions associated with relevant namespaces.
  • CALL_LEN recognizes the particular built-in call shape.
  • LOAD_ATTR_INSTANCE_VALUE uses the observed instance/type layout rather than starting full attribute resolution from scratch.
  • BINARY_OP_SUBSCR_LIST_INT handles an exact list with an exact integer index and checks bounds.
  • BINARY_OP_ADD_INT handles exact integer operands.

Notice the 3.14 subscript spelling. Python 3.14 folded binary subscripting into BINARY_OP with operation argument NB_SUBSCR; the old BINARY_SUBSCR examples are stale. CPython's Python/bytecodes.c lists list, tuple, string, dictionary, and Python __getitem__ variants in the current BINARY_OP family.

Experiment 4: use Instruction, not parsed text

When a tool needs structured data, use dis.get_instructions():

import dis

for instruction in dis.get_instructions(work, adaptive=True):
    if instruction.opname != instruction.baseopname:
        print(
            instruction.opname,
            "base=", instruction.baseopname,
            "cache fields=",
            [name for name, size, data in instruction.cache_info or ()],
        )

Our warmed function reported pairs including:

LOAD_GLOBAL_BUILTIN base= LOAD_GLOBAL cache fields= ['counter', 'index', 'module_keys_version', 'builtin_keys_version']
CALL_LEN base= CALL cache fields= ['counter', 'func_version']
LOAD_ATTR_INSTANCE_VALUE base= LOAD_ATTR cache fields= ['counter', 'version', 'keys_version', 'descr']
BINARY_OP_SUBSCR_LIST_INT base= BINARY_OP cache fields= ['counter', 'descr']

Since Python 3.13, get_instructions() no longer emits separate CACHE instruction objects; show_caches there is deprecated and has no effect. Cache metadata lives in Instruction.cache_info. In contrast, formatted dis.dis(..., show_caches=True) still displays CACHE lines. Mixing these two APIs is a common source of broken inspection scripts.

baseopname is especially useful: it lets a diagnostic group changing specialized names under the original operation. Still pin such tooling to a CPython version. These fields document CPython bytecode, not a cross-implementation Python interface.

Experiment 5: watch adaptation follow the workload

The same addition site can change its preferred specialization:

import dis

def add(a, b):
    return a + b

def opnames():
    return [i.opname for i in dis.get_instructions(add, adaptive=True)]

for _ in range(10_000):
    add(1, 2)
print("integers:", opnames())

for _ in range(100_000):
    add("a", "b")
print("strings: ", opnames())

On our build, the relevant name changed from BINARY_OP_ADD_INT to BINARY_OP_ADD_UNICODE. No source code or code object was replaced by the application. The interpreter adapted one executable instruction location to later evidence.

Do not infer a stable warmup count from 10_000. It is intentionally excessive for demonstration. Counters and backoff policies are private implementation choices, and tracing, instrumentation, build mode, or future maintenance can affect them.

The observation also does not justify forcing one type solely for interpreter friendliness. Types express program semantics. Stable types often help specialization, but converting values can cost more than a specialized operation saves and may change correctness.

Experiment 6: misses preserve semantics

Alternate values after warming for strings:

import dis

def add(a, b):
    return a + b

for _ in range(10_000):
    add("a", "b")

for i in range(100_000):
    if i % 2:
        assert add(1, 2) == 3
    else:
        assert add("a", "b") == "ab"

for instruction in dis.get_instructions(add, adaptive=True):
    print(instruction.opname)

Our final snapshot still showed BINARY_OP_ADD_UNICODE. That does not mean integer calls used string concatenation or returned a wrong result. A specialized instruction contains guards; a guard miss takes a correct fallback path and updates adaptive state. The final opcode is one moment in a feedback process, not a complete profile of all calls.

PEP 659's high-level model remains useful: specialization should be cheap to apply, cheap to miss, and able to change. Current counter details differ from the PEP's illustrative design. Read Python/specialize.c and Python/bytecodes.c for the exact 3.14 behavior rather than treating the PEP as current source code.

Highly polymorphic sites may gain less because no one guarded shape dominates. That is a reason to measure the real application, not to replace sound object-oriented design with giant type switches.

Experiment 7: specialization has deliberate limits

Custom operator dispatch remains generic in this simple case:

import dis

class Number:
    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        return Number(self.value + other.value)

def add(a, b):
    return a + b

value = Number(1)
for _ in range(10_000):
    add(value, value)

print([i.opname for i in dis.get_instructions(add, adaptive=True)])

Our output retained plain BINARY_OP. CPython 3.14 has specialized variants for selected high-value shapes, not every type and protocol combination. Its source lists direct variants for integer and float arithmetic, Unicode addition, common subscriptions, and an extension mechanism, but generic semantics remain essential.

Likewise, custom __getattribute__, descriptors, metaclasses, mutable class dictionaries, tracing hooks, and unusual call signatures can require checks or block a particular specialization. "Did not specialize" does not mean "bad Python." It may mean the generic protocol is exactly what the code requires, or that implementing and maintaining a safe fast path has not paid for itself.

Never depend on specialization for correctness. Any function must remain correct on another Python implementation, on a cold first call, while being traced, and after runtime state invalidates a cache.

Experiment 8: loops specialize more than arithmetic

Inspect a realistic reduction rather than one expression:

import dis

def total(values):
    result = 0
    for value in values:
        result += value
    return result

for _ in range(10_000):
    total(range(10))

dis.dis(total, adaptive=True)

Our hot loop included:

FOR_ITER_RANGE
STORE_FAST_LOAD_FAST
LOAD_FAST_BORROW
BINARY_OP_ADD_INT
STORE_FAST
JUMP_BACKWARD_NO_JIT

There are several distinct optimizations here: range iteration is recognized, adjacent local operations are combined, local loads can borrow references, integer addition specializes, and the backward edge has a 3.14-specific form. Looking only for BINARY_OP_ADD_INT understates what the interpreter is doing.

It also shows why folklore such as "always cache a built-in in a local variable" ages badly. LOAD_GLOBAL_BUILTIN already optimizes stable built-in lookup, while manually adding locals can hurt clarity and may not improve the complete loop. Measure source-level alternatives on supported versions. Let bytecode explain a result; do not make private bytecode the API your source is written against.

Inline caches are guarded shortcuts

An inline cache stores information near the instruction that consumes it. For a global load, that may include a dictionary index and namespace version information. For an attribute, it may include type/layout versions and an offset or descriptor. The optimized instruction checks assumptions before using cached information.

The checks are why normal Python dynamism remains legal:

def size(value):
    return len(value)

for _ in range(10_000):
    size([])

original = len
try:
    globals()["len"] = lambda value: 99
    assert size([]) == 99
finally:
    globals()["len"] = original

Even if size had specialized a built-in lookup and call, adding a global len must change name resolution immediately. Version checks and invalidation protect that language behavior. Cache contents accelerate the common stable case; they do not freeze namespaces.

This experiment is also a warning about benchmarks. Mutating globals, monkey-patching classes, attaching tracers, or alternating unrelated types can alter interpreter state. Run variants in isolated processes when those effects could cross-contaminate results.

What this means for production code

The specializing interpreter rewards patterns good code often has already:

  • hot operation sites usually see a small number of runtime shapes;
  • builtins and module globals are not constantly rebound;
  • ordinary instance attributes use conventional layouts;
  • common built-in containers and numeric types stay common;
  • work happens in loops long enough for startup costs to amortize.

These are observations, not style mandates. The practical order remains:

  1. Choose the right algorithm and data structure.
  2. Remove unnecessary work and crossings into Python code.
  3. Keep code straightforward enough for humans and the runtime to understand.
  4. Profile a representative application to find hot paths.
  5. Benchmark alternatives on every supported interpreter family and version.
  6. Use adaptive disassembly to explain CPython-specific results.

A specialized bytecode is evidence that CPython recognized a case, not evidence that the operation is free. Integer allocation, cache guards, memory access, function boundaries, and surrounding application work still exist. Conversely, a generic opcode can be fast enough that changing architecture to remove it is a net loss.

Version and implementation boundaries

Python guarantees that a + b invokes the language's binary operation semantics and that obj.x, len(value), and items[index] behave according to their protocols. It does not guarantee CPython bytecode exists.

CPython 3.14 specifically supplies the dis output shown here. CPython 3.11, 3.12, and 3.13 use related but different instruction sets and APIs. PyPy may trace and JIT larger regions. A free-threaded CPython 3.14 build uses thread-local bytecode by default; the documented -X tlbc=0 option disables thread-local bytecode and also disables the specializing interpreter in that build. That option does not apply to this traditional-GIL test build.

Pin diagnostic assertions to exact versions, and avoid tests that demand a particular specialization. Changes to warmup policy or a new, better opcode should not break an application's correctness suite.

Exercises

  1. Warm one function with integers, floats, and strings in separate fresh processes. Use baseopname to group the resulting specializations.
  2. Compare attribute loading for a normal instance, a slotted instance, a property, and a class with custom __getattribute__. Explain outcomes without labeling unspecialized cases as defects.
  3. Warm list, tuple, dictionary, and string subscriptions. Record the exact 3.14 names and identify the guard each name implies.
  4. Rebind a global used by a warmed function and verify correctness before and after. Inspect adaptive output, but do not assume when it must change.
  5. Run the experiments on CPython 3.13 or PyPy. List which observations are Python semantics and which were CPython 3.14 artifacts.

Keep this model

CPython observes individual hot instruction locations, chooses narrow guarded implementations for common shapes, stores supporting data in inline caches, and falls back correctly when assumptions fail. The process is local, speculative, and adaptive.

Write clear Python with stable semantics, measure realistic workloads, and inspect the runtime only when it helps explain evidence. CPython is already doing many of the tiny lookup and dispatch optimizations that old performance advice asks you to imitate by hand.

Primary sources