Object Identity and Interning

Every Python object has three immutable-for-life properties: a type, a value, and an identity. Identity is the one most people never think about until it bites them. The built-in id(x) returns an integer that is “guaranteed to be unique and constant for this object during its lifetime” (data model), and the is operator compares two objects by identitya is b is true exactly when id(a) == id(b). In CPython specifically, id(x) is the object’s address in memory; this is an implementation detail, not a language guarantee, and other implementations are free to do something else. The reason identity is surprising is that CPython aggressively caches and shares certain small immutable objects — the integers -5 through 256, interned strings, and the singletons None/True/False — so that a is b comes out True for values you never deliberately shared, while == (value equality) is the operator you almost always actually want. This note traces those caches in the CPython 3.14 source.

The headline rule, which the rest of this note justifies: use == to compare values; reserve is for identity checks against singletons (x is None, x is True). Using is to compare numbers or strings works by accident for cached values and fails silently for everything else.


Mental Model: Identity vs. Value

Think of every object as living at some address with a nameplate (its value). == reads the nameplates and asks “do these say the same thing?”; is asks “are these the same nameplate — literally the same object?”. Two distinct objects can carry equal values ([1,2] == [1,2] but the two lists are different objects), and — because of caching — two names can point at the same object even when you wrote them as separate literals.

flowchart LR
    subgraph names["Python names (variables)"]
        a["a"]
        b["b"]
        c["c"]
        d["d"]
    end
    subgraph heap["objects on the heap"]
        I256["int 256<br/>(cached singleton)"]
        I257a["int 257<br/>(fresh)"]
        I257b["int 257<br/>(fresh, separate)"]
    end
    a --> I256
    b --> I256
    c --> I257a
    d --> I257b
    classDef cached fill:#d5f5d5,stroke:#2a2;
    class I256 cached;

Figure: a = 256; b = 256 make a and b point at the one cached 256 object, so a is b is True. But c = 257; d = 257 create two separate 257 objects (257 is outside the cache), so c is d is False even though c == d. The insight: identity reflects CPython’s internal sharing decisions, which value equality is blind to.

id() is the bridge between the two: it exposes the identity as a number. In CPython that number is the address, so id(a) == id(b) precisely when a and b are the same allocation. Because addresses are reused after an object is freed, the docs are careful to say the value is unique only during the object’s lifetime — a freshly created object can legitimately receive the id of a recently deceased one (id() docs).


id() in CPython: It Is the Address (and Why That Matters)

The language reference states the abstract contract — identity is unique and constant for an object’s lifetime — and then adds the CPython-specific footnote: “CPython implementation detail: This is the address of the object in memory.” id(x) is computed as essentially (Py_uintptr_t)x cast to a Python int. Three consequences follow, and getting them right is the whole point of cross-linking Python Language vs CPython Implementation:

  1. It is not portable. PyPy, for instance, has a moving/compacting collector and cannot simply return an address; it synthesizes a stable identity number on demand. Code that assumes id() is an address (e.g. to do pointer arithmetic) is broken outside CPython.
  2. It is reused. Once an object is reclaimed by Reference Counting, its memory — and therefore its id() — can be handed to the next allocation. The classic gotcha: id([]) == id([]) can print True because the first throwaway list is freed before the second is created, so they land at the same address. They were never alive simultaneously.
  3. It says nothing about value. Equal values may have different ids; identical ids (over time) may have held different values.

Because is ultimately compares the underlying pointers (CPython’s do_richcompare/Py_Is for the is operator is a raw pointer comparison), a is b is exactly id(a) == id(b) — but the pointer comparison is faster and is what the is and is not operators compile to (expressions reference).


The Small-Integer Cache (-5 to 256)

CPython pre-allocates a fixed array of the most-used integers at interpreter startup and hands back references to those shared objects instead of allocating fresh ones. The documented range, from the PyLong_FromLong C-API page: “CPython keeps an array of integer objects for all integers between -5 and 256. When you create an int in that range you actually just get back a reference to the existing object.”

The mechanism in the 3.14 source. The bounds are two macros in pycore_runtime_structs.h:

#define _PY_NSMALLPOSINTS           257   /* 0 .. 256 inclusive */
#define _PY_NSMALLNEGINTS           5     /* -1 .. -5 inclusive */

The cache itself is a flat C array of PyLongObjects embedded in the interpreter’s static state:

struct {
    /* Small integers are preallocated in this array so that they
     * can be shared.
     * The integers that are preallocated are those in the range
     * -_PY_NSMALLNEGINTS (inclusive) to _PY_NSMALLPOSINTS (exclusive).
     */
    PyLongObject small_ints[_PY_NSMALLNEGINTS + _PY_NSMALLPOSINTS];   /* 262 entries */
    ...
}

That is 5 + 257 = 262 slots covering -5 through 256. Lookup is pure array indexing — from Objects/longobject.c:

static PyObject *
get_small_int(sdigit ival)
{
    assert(IS_SMALL_INT(ival));
    return (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + ival];
}

The + _PY_NSMALLNEGINTS offset shifts the signed value into a non-negative array index: -5 maps to index 0, 0 maps to index 5, 256 maps to index 261. Every integer-producing path — PyLong_FromLong, addition, parsing — funnels through this check and returns the shared object when the value lands in range. So:

a = 256
b = 256
a is b      # True  — both names bound to small_ints[261]
c = 257
d = 257
c is d      # False — 257 is outside the cache; two fresh PyLongObjects
-5 is -5    # True
-6 is -6    # CPython detail: may be False (just outside the cache)

Why -5 to 256?

The bounds are a tuned heuristic: these are the integers that dominate real programs — loop counters, small offsets, byte values (0255), and a few common negatives. Caching them avoids millions of tiny allocations and the corresponding Reference Counting churn. The asymmetry (-5 low, 256 high) reflects that small positives vastly outnumber small negatives in practice. The exact range is not a language guarantee — never write code that depends on 257 is 257.

The cached small ints are immortal (see below): the pycore_long.h accessors call them “the immortal zero singleton” and “the immortal one singleton,” and they are part of _PyRuntimeState.global_objects, which PEP 683 immortalizes. They are never deallocated and their refcounts are never modified.

The negative bound is exactly -5: pycore_long.h @ v3.14.5 defines _PY_NSMALLNEGINTS 5 and _PY_NSMALLPOSINTS 257, with the cache spanning -_PY_NSMALLNEGINTS (inclusive) to _PY_NSMALLPOSINTS (exclusive) — i.e. -5..256. Bypassing constant folding to probe the runtime cache boundary, on CPython 3.14.5 int("-5") is int("-5") is True while int("-6") is int("-6") is False, confirming -5 as the low edge.


String Interning

Interning means keeping a single canonical copy of a string so that two equal strings can become the same object. Per sys.intern: “Enter string in the table of ‘interned’ strings and return the interned string — which is string itself or a copy. Interning strings is useful to gain a little performance on dictionary lookup — if the keys in a dictionary are interned, and the lookup key is interned, the key comparisons (after hashing) can be done by a pointer compare instead of a string compare. Normally, the names used in Python programs are automatically interned, and the dictionaries used to hold module, class or instance attributes have interned keys.”

That last sentence is the practical payoff. Attribute access (obj.attr) and name lookup are dict lookups keyed on strings; because identifiers are interned, the comparison after the hash is a single pointer compare instead of a character-by-character strcmp. Across the millions of attribute lookups a program performs, this is a real speedup — and it is invisible because the interpreter does it for you. See CPython Dict Internals for how the lookup uses identity as a fast pre-check before falling back to value comparison.

Three flavors of interned string (CPython 3.14)

The 3.14 implementation in Objects/unicodeobject.c tracks an interned state byte with several values:

  • SSTATE_NOT_INTERNED — an ordinary string.
  • SSTATE_INTERNED_MORTAL — interned into the per-interpreter interned dict, but still reference-counted; it dies when no references remain.
  • SSTATE_INTERNED_IMMORTAL — interned and immortal (refcount frozen, never collected).
  • SSTATE_INTERNED_IMMORTAL_STATIC — a statically-allocated, compile-time-baked interned string (e.g. the _Py_ID(...) identifiers the interpreter itself uses).

The core routine intern_common does a setdefault on the interpreter’s interned dict: if an equal string is already present it discards the new one and returns the canonical existing object; otherwise it inserts the new string and marks it SSTATE_INTERNED_MORTAL. Single-character Latin-1 strings short-circuit to a pre-built singleton. This is why intern(x) is intern(y) is guaranteed when x == y.

sys.intern() produces a mortal interned string

A subtle 3.14-era fact, confirmed in Python/sysmodule.c: sys.intern() calls _PyUnicode_InternMortal, not the immortal variant:

sys_intern_impl(PyObject *module, PyObject *s)
{
    if (PyUnicode_CheckExact(s)) {
        PyInterpreterState *interp = _PyInterpreterState_GET();
        Py_INCREF(s);
        _PyUnicode_InternMortal(interp, &s);
        return s;
    }
    ...
}

Hence the explicit warning in the docs: “Interned strings are not immortal; you must keep a reference to the return value of intern() around to benefit from it.” If you let your reference go, the interned string can be reclaimed and a later equal string will be a fresh object again. (In the free-threaded build, intern_common forces immortalize = 1 for all interned strings — a build-specific divergence, because mortal interning is unsafe without the GIL coordinating the shared dict.)

Automatic compile-time interning of literals

Beyond names, CPython interns many string literals that look like identifiers (containing only letters, digits, and underscores) at compile time, so that:

a = "hello"
b = "hello"
a is b          # typically True — identifier-like literal, interned at compile time
 
x = "hello world!"
y = "hello world!"
x is y          # typically False — contains space/punctuation, not auto-interned
 
m = "".join(["h", "e", "l", "l", "o"])
m is a          # False — computed at runtime, never interned

This is why "hello" is "hello" looks like it “works” but "hello world!" is "hello world!" does not, and why runtime-built strings are never shared even when equal. To force sharing for arbitrary strings, call sys.intern() explicitly.

The exact rule and code location were verified for CPython 3.14.5. The old all_name_chars heuristic that lived in compile.c was renamed and relocated during the compiler refactor: it is now should_intern_string in Objects/codeobject.c @ v3.14.5, applied when a code object is constructed. In the default GIL build, should_intern_string returns true exactly when the string is ASCII and every byte matches [a-zA-Z0-9_] (the comment in the source literally reads “compute if s matches [a-zA-Z0-9_]”). The construction path intern_code_constants then calls intern_constants(co_consts), which interns each qualifying string constant via _PyUnicode_InternMortal (mortal — so "hello" is "hello" works but the string stays collectible), while intern_strings interns the name tuples (co_names, localsplusnames) via _PyUnicode_InternImmortal. So the “only identifier-characters get interned” boundary is exact for string constants, and names are always interned regardless of content.

Resolved (2026-06-01)

Rule and location verified against Objects/codeobject.c at the v3.14.5 tag (should_intern_string, intern_constants, intern_strings, intern_code_constants). Build-sensitive: under Py_GIL_DISABLED, should_intern_string returns 1 unconditionally and most constants are interned and immortalized.


The Singletons: Identity-Unique by Construction

None, True, and False are different from the caches above: there is exactly one of each, created once at interpreter startup, and the language guarantees their uniqueness. There is no way to construct a second Nonetype(None)() returns the same object, and None.__new__ always returns the canonical instance. This is precisely why the idiomatic test is x is None, never x == None: identity is guaranteed, the comparison is a single pointer check, and it cannot be fooled by a class that overrides __eq__. The same holds for x is True / x is False and for Ellipsis (...) and NotImplemented. See None True False and the Singletons for their construction, and Object Creation new init and alloc for how a __new__ that always returns the one instance enforces the singleton property.

All of these singletons are immortal under PEP 683 (CPython 3.12+): an object is immortal when its reference count is pinned to a special sentinel value (_Py_IMMORTAL_REFCNT, using high-order bits of the refcount field) that Py_INCREF/Py_DECREF recognize and leave untouched. The PEP lists exactly what gets immortalized: “singletons (None, True, False, Ellipsis, NotImplemented), all static types … all static objects in _PyRuntimeState.global_objects (e.g. identifiers, small ints).” Immortality reinforces identity stability — these objects can never be collected and reborn at a different address, so their id() is fixed for the life of the interpreter. See Immortal Objects for the refcount mechanics and why immortality also matters for free-threading.


Failure Modes and Common Misunderstandings

  • Using is to compare numbers/strings. x is 256 may be True and x is 257 False for the same logical intent. This is the single most common identity bug. Modern CPython even emits a SyntaxWarning: "is" with 'int' literal. Did you mean "=="? for x is 257-style code (the expressions reference shows this exact warning), precisely because it almost always indicates a value comparison written wrong. Always use == for values.
  • id([]) == id([]) printing True. Two throwaway objects that are never alive at the same time can reuse one address (point 2 above). The result tells you nothing — there is no shared object, just a recycled id.
  • Assuming id() is a stable global handle. It is unique only for the object’s lifetime; after the object dies the number is fair game for reuse. Do not use id() as a persistent key in a dict that outlives the objects (use the objects themselves, or weakref).
  • Expecting sys.intern() to persist without a reference. Because sys.intern is mortal in 3.14, dropping your reference lets the canonical copy be collected; a later equal string is then a different object again. Hold the returned reference.
  • Porting identity assumptions across implementations. id() being the address, the small-int range, and literal auto-interning are all CPython specifics. On PyPy/Jython/GraalPy the numbers and boundaries differ; only the singleton identity guarantees (None is None) are portable. See Python Language vs CPython Implementation and CPython vs PyPy and Alternative Implementations.
  • Over-interning. Calling sys.intern() on huge numbers of unique, short-lived strings wastes the interned dict and memory for no benefit — interning pays off only for strings compared repeatedly as dict keys.

See Also