CPython Optimizations: Interning and Constant Folding

Inspect CPython’s reuse and optimization mechanisms without making application correctness depend on cached identities or particular bytecode.

Two equal strings sometimes have the same identity. Two equal integers sometimes do too. An expression written as multiplication can compile into a function that simply loads the result. These observations are interesting, but they answer implementation questions. They do not change how application code should compare values.

I keep three mechanisms separate: reuse of selected integer objects, interning of strings, and reuse or precomputation of constants during compilation. They can all reduce repeated work, but they happen for different reasons and have different boundaries. Treating every shared identity as “interning” makes experiments harder to interpret.

This article follows Python Memory Management and Variables and Memory in Python in Python for Backend Developers. The introductory article is Basic of Python. The executable observations below use the standard, non-free-threaded CPython 3.14.4 build with the GIL enabled. Free-threaded behavior is documented separately. Automatic object sharing and bytecode details are implementation-dependent, even when the value semantics are portable Python.

Start with the application’s value contract

left == right asks for equality. left is right asks whether both references identify the same object. A parser can produce an equal value in a distinct object. A compiler or runtime may reuse an immutable object where creating another indistinguishable instance is unnecessary. Neither outcome should decide whether an incoming status means “queued.”

The data model allows implementation-dependent reuse of immutable values. It does not grant application code permission to infer value equality from a few observed identities. Use identity when sameness itself is the contract, such as checking None or a private sentinel.

This distinction is especially useful when an identity-based test passes in a short script but fails for data loaded over the network. The short script may have created a compiler constant, while the service constructs a value at runtime. The service did not break equality. The test asked a different question from the one the application needed answered.

Small-integer reuse is a CPython mechanism

CPython’s documented small-integer array covers values from -5 through 256. Returning an integer in that range can reuse an existing object. The integer C API documentation describes this as a current implementation detail, not a Python language guarantee.

small_left = int("42")
small_right = int("42")
large_left = int("1000")
large_right = int("1000")

assert small_left == small_right
assert large_left == large_right
print("Small integers share identity:", small_left is small_right)
print("Larger integers share identity:", large_left is large_right)

On the tested build, the small-integer identity observation is True and the larger-integer observation is False. Both equality assertions pass. Constructing the integers from strings makes the example easier to interpret than repeating numeric literals in one compiled code object.

It would still be wrong to turn the larger-number observation into a universal rule. A different expression can load a shared constant, and another implementation can choose different reuse policies. “Above 256 always means a new identity” is as misleading as “equal numbers are always identical.”

The practical lesson is modest: immutable values can be shared safely when their identity is irrelevant to the computation. A counter, timeout, database identifier, or response code should use numeric comparisons. Designing a branch around where CPython currently caches integers creates a dependency that the data contract never required.

Literal sharing and runtime construction are different experiments

A literal appears in source that the compiler can inspect. A runtime string can be assembled from values that become available only during execution. Repeating the same literal in one compilation context does not simulate two independent values arriving from a parser.

literal_left = "job_status"
literal_right = "job_status"
runtime_left = "".join(["job", "_", "status"])
runtime_right = "".join(["job", "_", "status"])

assert literal_left == literal_right == runtime_left == runtime_right
print("Literal identity:", literal_left is literal_right)
print("Runtime identity:", runtime_left is runtime_right)

The literal pair shares identity on CPython 3.14.4 in this example. The two joined strings do not. All four strings compare equal. The example labels the identity results as observations rather than asserting that future Python releases must produce the same sharing pattern.

There are several possible explanations for shared string identity: automatic interning of selected strings, compiler constant reuse, or an operation that returns an existing string unchanged. An identity test by itself does not identify which mechanism was responsible. For example, wrapping an existing string in str() is not a reliable way to force a new object for an experiment.

Compilation boundaries also matter. Code typed in separate interactive inputs need not be compiled together as it would be in one module. A notebook, a debugger, and a standalone script can therefore expose different identities without disagreeing about string equality. Record how the values were constructed before drawing a conclusion.

sys.intern returns a canonical string reference

String interning maintains a table that can provide a shared representative for equal strings. sys.intern(value) returns the interned string, which may be the input object or another existing object. Retain the return value. Calling the function does not rewrite every name or container that already refers to the input.

import sys

left = "".join(["job", "_", "status"])
right = "".join(["job", "_", "status"])
assert left == right

left = sys.intern(left)
right = sys.intern(right)
assert left is right

handlers = {left: "handle_status"}
incoming_key = sys.intern("".join(["job", "_", "status"]))
assert handlers[incoming_key] == "handle_status"
assert incoming_key == "job_status"
Equal runtime strings share one representative after explicit interningRead before then after. Two runtime-constructed strings are equal but distinct in the tested example. Assigning the results of sys.intern back to both names makes them refer to a single canonical string while the returned references are retained.Retain the references returned by sys.internBefore: equal runtime stringsAfter explicit interningleft"job_status"right"job_status"leftright"job_status"One representativeleft == rightleft is right
Equal runtime strings share one representative after explicit interningRead before then after. Two runtime-constructed strings are equal but distinct in the tested example. Assigning the results of sys.intern back to both names makes them refer to a single canonical string while the returned references are retained.Explicit string interningBefore: equal, distinct objectsleft"job_status"right"job_status"After: retain sys.intern resultsleftright"job_status"One canonical representativeEqual values before; shared identity after.
The diagram illustrates the tested runtime construction. Automatic sharing of other strings is implementation-dependent.

After the returned references are assigned back to left and right, the identity assertion is intentional. It verifies the explicit interning experiment while both references remain live. The dictionary lookup still uses ordinary string equality semantics; application correctness does not require a caller to remember to intern its key.

The sys.intern documentation explains that interning can help dictionary lookup when both sides are interned, and that callers should keep returned references alive. It also notes that names used in Python programs are normally interned automatically. That does not imply that every string built from request data is automatically interned.

Interning and immortality describe different properties. Interning concerns a canonical representative for a value. Immortality concerns an object’s lifetime and reference-count treatment. On the standard GIL-enabled build, interning alone does not promise immortality. In CPython 3.14’s free-threaded build, however, interned strings are immortal and survive until interpreter shutdown. The free-threading documentation explains this difference. Interning an unbounded stream of unique values can therefore have a lasting memory cost in that build. Keep returned references and use value comparisons for business logic in either build.

Intern only when the workload gives it a purpose

A parser handling a small, repeated vocabulary of field names is a plausible candidate for an interning experiment. A stream of unique request identifiers is a different workload: it offers little repeated vocabulary and may retain large numbers of distinct values elsewhere in the application.

Interning does not remove the cost of building or reading the original string. The program may already have allocated a temporary string before it can ask the intern table for a representative. There is also work involved in the interning request itself. Measuring only a later dictionary lookup leaves those costs outside the result.

I would first ask whether the workload is spending meaningful time or memory on repeated string keys. If it is, compare the complete parse-and-dispatch path with and without interning. Keep the vocabulary distribution, retention policy, interpreter version, and concurrency representative. A microbenchmark can isolate a mechanism, but it cannot decide the architecture on its own.

Automatic interning is also not a substitute for normalization. Case, whitespace, Unicode normalization, and domain aliases define different value-level questions. Two differently spelled keys do not become semantically equivalent because both were interned. Establish the input contract before attempting storage reuse.

A code object can retain constants

A function’s code object exposes constants through __code__.co_consts. This is useful for investigation: it lets us see values the compiled function can load rather than construct from scratch on every call. The code object documentation describes this introspection interface.

def allowed_states():
    return ("queued", "running", "done")

def new_states():
    return ["queued", "running", "done"]

first_tuple = allowed_states()
second_tuple = allowed_states()
first_list = new_states()
second_list = new_states()

assert first_tuple == second_tuple
assert first_list == second_list
assert first_list is not second_list
print("Tuple constant reused:", first_tuple is second_tuple)
print("Code constants:", allowed_states.__code__.co_consts)

On the tested build, allowed_states returns the same tuple object on successive calls. The tuple of strings appears in its code constants. This is constant reuse, and it is more precise to call it that than to claim that arbitrary tuples are globally interned.

The list-returning function creates independent mutable lists. Appending to one result must not alter another result created by a separate call. Reusing immutable ingredients is compatible with that requirement, but reusing the whole mutable list would change the function’s behavior.

The design implication is useful even without inspecting bytecode: immutable shared data can represent a fixed vocabulary, while callers that need independent mutable state should construct or copy it intentionally. Do not infer a general identity promise from co_consts. It explains the current compiled artifact, not every artifact another interpreter might produce.

Peephole optimization is historical terminology here

A peephole optimizer examines a small local window of instructions and replaces patterns with equivalent work. Older Python material often groups constant folding under that heading because of where CPython previously implemented it. In Python 3.7, constant folding moved from the peephole optimizer to an AST optimizer. The Python 3.7 optimization notes and implementation issue record that change.

An abstract syntax tree, or AST, represents the parsed structure of source expressions. An optimization there can reason about constant expressions before emitting the final bytecode. Modern compilation involves multiple transformations, so “the peephole optimizer did it” is too imprecise as a blanket explanation for every optimized expression.

For an application engineer, the valuable questions are when the work happens, what result is preserved, and what evidence supports the explanation. We can answer those for a small example without treating a historical source-file layout as a permanent API.

Observe compile-time constant folding

Constant folding replaces a suitable expression made from constants with its computed result during compilation. In the next example, 24 * 60 has all its inputs in the source. The parameterized function receives an input only when called.

import dis

def minutes_per_day():
    return 24 * 60

def minutes_for(hours):
    return hours * 60

assert minutes_per_day() == minutes_for(24) == 1440
print("Folded constants:", minutes_per_day.__code__.co_consts)
print("Runtime constants:", minutes_for.__code__.co_consts)
for function in (minutes_per_day, minutes_for):
    print(function.__name__)
    for instruction in dis.get_instructions(function):
        print(instruction.opname, instruction.arg, instruction.argrepr)
Constant folding contrasted with runtime computationRead the known-literal path and then the parameterized path. In the tested CPython 3.14.4 build, compilation precomputes 24 times 60 as 1440. The function using hours still performs multiplication at runtime because the argument arrives only when called.Compile-time folding and runtime arithmeticKnown literalsArgument supplied at call timereturn 24 * 60return hours * 60Load constant 1440Computed during compilationMultiply at runtimeResult depends on hoursObserved on CPython 3.14.4. Values are the application contract.
Constant folding contrasted with runtime computationRead the known-literal path and then the parameterized path. In the tested CPython 3.14.4 build, compilation precomputes 24 times 60 as 1440. The function using hours still performs multiplication at runtime because the argument arrives only when called.When does the work happen?Known literalsreturn 24 * 60Load constant 1440Computed during compilationArgument arrives at call timereturn hours * 60Multiply at runtimeResult depends on hoursObservation: CPython 3.14.4
Compilation can fold this literal expression. It cannot precompute the parameterized result for every possible argument.

On CPython 3.14.4, the first function’s constants include 1440. Its disassembly loads that result and returns it. The parameterized function instead loads hours, uses LOAD_SMALL_INT 60, and performs multiplication at runtime. The sample prints the raw instruction operand as well as its readable representation so that 60 remains visible.

LOAD_SMALL_INT was added in Python 3.14 and carries the small integer directly as an instruction operand. Inspect instructions alongside co_consts: the constant table alone does not tell you which values are loaded or whether arithmetic still happens. The checks verify this build’s observed folding without fixing offsets or requiring a complete instruction listing.

The semantic assertion is stronger and more portable than any particular disassembly: both functions return 1440 for the supplied call. The dis documentation warns that CPython bytecode is an implementation detail that can change between releases. Keep observations tied to the interpreter that produced them.

This does not mean every expression that looks constant to a reader will be folded. A named module value can be rebound. A function call can have side effects. Some computations would be too costly or produce excessively large constants during compilation. Prefer readable expressions and inspect a concrete case when optimization actually matters.

Runtime operands preserve dynamic behavior

The expression value * 60 is not inherently integer arithmetic. Its meaning depends on the object passed as value. The same function can multiply an integer, repeat a string, or invoke an object’s multiplication method.

class Multiplier:
    def __mul__(self, factor):
        return f"custom multiplication by {factor}"

def scale(value):
    return value * 60

assert scale(2) == 120
assert scale("x") == "x" * 60
assert scale(Multiplier()) == "custom multiplication by 60"

Moving this operation to compilation would require knowing which object and behavior the caller will supply. The function’s syntax alone does not provide that information. This is one reason to avoid simplistic claims that the compiler will precompute anything that looks mathematically straightforward.

Annotations would document an intended input type, but they do not normally replace Python’s runtime dispatch with enforced integer-only execution. When investigating an optimization, distinguish what a type checker assumes from what the executing interpreter is permitted to observe.

Runtime specialization happens after execution provides evidence

Compile-time folding uses information available while creating the code. Adaptive specialization uses evidence from execution to choose more specific runtime paths. The adaptive=True option in dis can expose specialized instructions on CPython versions that support it.

import dis

def increment(value):
    return value + 1

before = [item.opname for item in dis.get_instructions(increment, adaptive=True)]
for number in range(20_000):
    increment(number)
after = [item.opname for item in dis.get_instructions(increment, adaptive=True)]

assert increment(4) == 5
assert increment(2.5) == 3.5
print("Before warm-up:", before)
print("After warm-up:", after)

The printed instruction names can change after the repeated integer calls on the tested build. The script records what it observes rather than requiring a particular specialized opcode or warm-up threshold. The final calls still exercise the function’s result for both an integer and a float.

Specialization is not the same as constant folding: increment still depends on the value passed to each call. A more efficient runtime path does not make the answer a compile-time constant. Adaptive decisions can also depend on execution history and build details, which makes exact instruction names a poor application-level invariant.

Measure the work you intend to change

Here is a small experiment that keeps three measurements separate: lookup with an ordinary equal string, lookup with an already interned string, and lookup including an interning call. It uses the standard-library timeit module. The numbers produced on a particular machine are local observations, not a promised speedup.

import timeit

setup = """
import sys
table = {sys.intern('job_status'): 1}
key = ''.join(['job', '_', 'status'])
interned_key = sys.intern(key)
"""
plain = timeit.repeat("table[key]", setup=setup, repeat=5, number=100_000)
interned = timeit.repeat("table[interned_key]", setup=setup, repeat=5, number=100_000)
including_conversion = timeit.repeat(
    "table[sys.intern(key)]", setup=setup, repeat=5, number=100_000
)
assert len(plain) == len(interned) == len(including_conversion) == 5
print("Lookup only, plain:", min(plain))
print("Lookup only, already interned:", min(interned))
print("Lookup plus interning:", min(including_conversion))

The setup is outside the timed statement. That is appropriate for isolating repeated lookups, but it excludes parsing, string construction, dictionary construction, and most application work. The third statement includes the interning request so that cost is visible. No assertion claims that one timing must beat another.

For a service decision, repeat the comparison around the actual boundary you intend to optimize. Include allocation and retention if those motivate the change. Keep correctness tests about values and ownership, and keep interpreter-specific observations in a separate diagnostic experiment.

A runtime upgrade is a reason to repeat performance observations, not to rewrite correct value comparisons. If a benchmark changes after the upgrade, preserve its inputs and compare the complete workload before attributing the difference to interning or a single instruction. Several runtime mechanisms can affect the same timing result.

CPython can reuse selected integers, canonicalize strings, retain constants, fold suitable expressions, and specialize runtime execution. Knowing which mechanism you are observing helps explain performance. Keeping application behavior independent of those mechanisms makes the code easier to maintain when the runtime changes.

The Python foundations phase is in progress. Return to Python for Backend Developers for the full reading order, or revisit Python Memory Management for object-lifetime and resource-cleanup examples.

Share
Prabhat Kashyap

Prabhat Kashyap

Senior Technical Architect at HCLTech · working with Leonteq Security AG

I have 10+ years of experience building distributed systems and fintech platforms. I write about practical, non-obvious engineering details that official documentation often skips.

Newsletter

Get new engineering deep dives

Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.

Join the newsletter