Python Tutorial Series: The Nature of Variables Through id()

Python
Programming Languages
A simple built-in function that reveals how Python variables really work
Author

Anthony

Published

April 14, 2026

At first glance, id() looks like a tiny utility: it just returns an integer. But once you start using it to inspect real code, you quickly realize something deeper: many bugs and “weird” behaviors come from misunderstanding what a Python variable actually is.

This article uses runnable examples to explain one core idea: in Python, variables are names, and objects are the real entities. Along the way, we will cover small integer caching, constant pooling, string interning, is vs ==, and a few classic pitfalls that appear in production code.

A Counter-Intuitive Start

Let us begin with a famous example:

a = 256
b = 256
print(a is b)  # Expected: True

x = 257
y = 257
print(x is y)  # Expected: may be True or False
True
False

Many developers see this and think Python is inconsistent. It is not. The behavior is usually consistent within a given implementation and execution context, but the result can vary across contexts (script vs REPL) and across implementations.

To understand why, we first need to be precise about what id() means.

What id() Actually Means

According to the official Python docs, id(object) returns an integer that is unique and constant during the object’s lifetime.

That statement has two important parts:

  1. As long as an object is alive, its id() does not change.
  2. Two different live objects cannot share the same id().

In CPython (the most widely used implementation), this value is often the memory address of the object.

s = "python"
print(id(s))             # Expected: an integer
print(id(s) == id(s))    # Expected: True
4368988176
True
Warning

In CPython, id() is often the memory address, but this is an implementation detail, not a language-level guarantee. PyPy, Jython, and other implementations may use different strategies.

So id() is useful for debugging and learning internals, but you should not rely on CPython-specific behavior as business logic.

Lifetime-Unique, Not Globally Unique

A common misunderstanding is: “If id() is unique, it should never repeat.” That is incorrect.

The docs say unique during the object’s lifetime. After an object is destroyed, its old id() may be reused.

print(id(object()))
print(id(object()))
print(id(object()))  # May repeat previous values
4500198144
4500198400
4500198144

These temporary objects are created and discarded immediately, so CPython may reuse the same memory slot.

If you want to compare identities safely, keep references alive:

a = object()
b = object()
print(id(a), id(b))  # Expected: two different integers
print(a is b)        # Expected: False
4500198496 4500198144
False

Small Integer Cache

Now back to 256 vs 257.

CPython pre-allocates a range of small integer objects and reuses them. In many tutorials you will see -5..256 as the typical range. In newer CPython branches, related constants are internal and version-dependent, so avoid treating one exact range as a language guarantee.

a = 256
b = 256
print(a is b)  # Expected: usually True in CPython

c = -5
d = -5
print(c is d)  # Expected: usually True in CPython
True
True

For values outside the commonly cached range:

x = 257
y = 257
print(x is y)  # Expected: may be True or False depending on context
False

The key message is not the exact boundary. The key message is that identity behavior for literals can be affected by implementation-level reuse.

Constant Pooling and Compilation Context

If 257 is not always from the small-int cache, why is x is y sometimes True anyway?

Because equal literals in the same code object may be loaded from the same constant slot.

import dis

def f():
    a = 257
    b = 257
    return a is b

dis.dis(f)
  3           0 RESUME                   0

  4           2 LOAD_CONST               1 (257)
              4 STORE_FAST               0 (a)

  5           6 LOAD_CONST               1 (257)
              8 STORE_FAST               1 (b)

  6          10 LOAD_FAST                0 (a)
             12 LOAD_FAST                1 (b)
             14 IS_OP                    0
             16 RETURN_VALUE

You may see both assignments load the same constant index (for example, LOAD_CONST 1). That means both names refer to the same constant object in that compiled unit.

This is also why script files and REPL input can differ:

  • In a .py file, both lines are often compiled together.
  • In REPL, each input is usually a separate compile unit.

So “same text” does not always mean “same compiled context.”

Note

When is changes across contexts, it often reflects constant reuse strategy, not value semantics. For value comparison, use ==.

String Interning

Strings can also be reused through string interning.

A simple way to think about it: if two strings are identical, Python may keep one shared copy in memory.

a = "hello"
b = "hello"
print(a is b)  # Expected: often True
True

For strings with spaces or symbols, identity is less predictable:

s1 = "hello world"
s2 = "hello world"
print(s1 is s2)  # Expected: may be True or False
False

You can force interning with sys.intern():

import sys

raw1 = "user_id"
raw2 = "user_" + "id"

i1 = sys.intern(raw1)
i2 = sys.intern(raw2)

print(i1 is i2)  # Expected: True
True

Practical use case: repeated short strings (CSV field names, status labels, protocol tokens) can consume less memory when interned carefully.

import sys

rows = ["OK", "OK", "FAIL", "OK", "FAIL"]
rows = [sys.intern(x) for x in rows]

print(rows[0] is rows[1])  # Expected: True
print(rows[2] is rows[4])  # Expected: True
True
True

is vs ==: Practical Rules

The difference is fundamental:

  • is compares identity (same object)
  • == compares value (via __eq__)
a = [1, 2]
b = [1, 2]

print(a == b)  # Expected: True
print(a is b)  # Expected: False
True
False

One strong rule in real projects: use is None, not == None.

x = None
print(x is None)  # Expected: True
print(x == None)  # Expected: True, but not recommended
True
True

Why is is None better?

  1. None is a singleton, so identity is the correct semantic check.
  2. == calls __eq__, which may have custom behavior.

For example, with NumPy arrays:

import numpy as np

arr = np.array([1, 2, 3])
print(arr == None)  # Expected: array([False, False, False])
print(arr is None)  # Expected: False
[False False False]
False

is None is both safer and clearer.

Mutable Objects and Identity Changes

id() is also a great tool to understand in-place mutation vs rebinding.

append mutates a list in place; + creates a new list:

lst = [1, 2]
old_id = id(lst)

lst.append(3)
print(id(lst) == old_id)  # Expected: True

lst = lst + [4]
print(id(lst) == old_id)  # Expected: False
True
False

+= has type-specific behavior:

# list: usually in-place
l = [1, 2]
l_id = id(l)
l += [3]
print(id(l) == l_id)  # Expected: True

# tuple: creates a new tuple and rebinds
t = (1, 2)
t_id = id(t)
t += (3,)
print(id(t) == t_id)  # Expected: False
True
False

Classic trap:

t = ([1, 2], [3, 4])

try:
    t[0] += [5]
except TypeError as e:
    print(type(e).__name__)  # Expected: TypeError

print(t)  # Expected: ([1, 2, 5], [3, 4])
TypeError
([1, 2, 5], [3, 4])

Why does it mutate even though it errors?

  1. t[0] is a list, so in-place addition happens first.
  2. Python then attempts to assign the result back.
  3. Tuple item assignment is illegal, so a TypeError is raised.

The mutation already happened before the exception.

Functions, Classes, and Modules Also Have id()

Everything in Python is an object, including functions, classes, and modules.

import sys

def foo():
    pass

class Bar:
    pass

print(id(foo))
print(id(Bar))
print(id(sys))
4501764864
36446986256
4368951200

Modules often behave like singletons due to import caching in sys.modules:

import math
import math as m2

print(math is m2)  # Expected: True
True

Practical Use and Real Pitfalls

Understanding identity is useful, but misuse is dangerous.

A bad pattern is using id() as a long-term tracking key:

obj = object()
tracking = {id(obj): "alive"}

del obj
# Later, a new object may reuse the same id

This can create false matches in long-running programs.

A better approach is weakref:

import weakref

class Node:
    pass

n = Node()
meta = weakref.WeakKeyDictionary()
meta[n] = "alive"

print(len(meta))  # Expected: 1

del n
# After garbage collection, key-value can disappear automatically
1

This tracks object relationships safely without relying on reusable integer identities.

Conclusion

id() helps you see Python’s object model in action: variables are names, objects hold state and behavior.

Once you internalize that model, many confusing behaviors become predictable: is vs ==, mutable vs immutable operations, interning, constant pooling, and identity reuse.

At the same time, keep this boundary clear: many examples here are CPython implementation details. They are excellent for debugging and performance tuning, but they should not become hard assumptions in application logic.

In the next article, we will go deeper into mutable vs immutable objects, especially how they affect function arguments, default values, and side effects.

References

Back to top