Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~36 min· topic 1 of 9

Topic 13.1

Objects, Identity and References

In one line

Every value in Python is an object with an identity, a type and a value, and a variable is just a name pointing at an object. Knowing the difference between == (same value) and is (same object) explains aliasing, argument passing and the mutable default argument trap.

Think of it like this

A school locker. The locker (the object) holds your books. Your name on a sticky label (the name) tells people which locker is yours. Two friends can stick two labels on the same locker, so a change made through one label shows up through the other. A different locker can hold exactly the same books and still be a different locker. Python names and objects work the same way: names are labels, objects are lockers.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Object
A thing in memory with an identity, a type and a value. Everything in Python is an object.
Name (variable)
A label that points at an object. It is not a box that holds the value.
Identity
Who an object is; in CPython its memory address, shown by id(). It never changes during the object's life.
Alias
A second name for the same object.
Mutable / immutable
A mutable object can be changed in place (list, dict). An immutable one cannot (int, str, tuple).
Rebinding
Making a name point at a different object. The old object is untouched.
Interning
Reusing one object for equal values, so two names share it. CPython does it for small ints and some strings.
Call by object reference
How Python passes arguments: the parameter becomes another name for the caller's object.

Step by step

01Two names, one object

b = a does not copy the list. It puts a second label on it. Mutating through either label changes the one object both labels point to.

The comparison a == c is True because the contents match; a is c is False because they are two different objects that happen to look alike.

main.pywhole filepython
a = [1, 2, 3]
b = a            # a second label on the SAME list
c = [1, 2, 3]    # a different list, equal contents

print(a == c, a is c, a is b)
b.append(4)
print(a, c)
Two names, one objectdiagram
Rendering diagram…

02Every value is an object

Ask any value for its type and you get a class. Ask that class for its type and you get type. Functions and classes are objects too, which is why you can pass them around, store them in lists and give them attributes.

main.pywhole filepython
for value in (42, "hi", [1], len, int):
    print(type(value).__name__, "->", type(type(value)).__name__)

03Equality versus identity

== is customisable: a class decides what equal means by defining __eq__ (Topic 8.7). is is not customisable. It compares the identities, full stop. That is why x is None is safe even when x is an object whose __eq__ does something odd.

04The small-integer and string caches

CPython builds the integers -5 to 256 once and reuses them. A bigger integer made at run time is a new object each time. We use int("...") so the compiler cannot merge two equal constants for us.

Strings made at run time are separate objects until you call sys.intern. The lesson is not 'memorise the ranges': it is 'never use is to compare numbers or strings'. Another Python (PyPy, a newer CPython) may cache differently.

main.pywhole filepython
import sys

print(int("200") is int("200"))        # True: cached small ints
print(int("100000") is int("100000"))  # False: two objects

s1 = "".join(["hel", "lo"])
s2 = "".join(["hel", "lo"])
print(s1 is s2, sys.intern(s1) is sys.intern(s2))

05Rebinding versus mutating inside a function

Inside rebind, x = x + [99] builds a new list and points the local name x at it. The caller's list is untouched. Inside mutate, x.append(99) changes the same list the caller holds.

+= on a list is a trap: b += [3] calls list.__iadd__, which extends the list in place, so every alias sees the change. For an int or a tuple, += has no in-place version, so it rebinds. The same line of code does two different things depending on the type.

main.pywhole filepython
def rebind(x):
    x = x + [99]
    return x

def mutate(x):
    x.append(99)

nums = [1, 2]
rebind(nums); print(nums)
mutate(nums); print(nums)

06The mutable default argument

Python runs def once. The default list is built at that moment and stored in add_item.__defaults__. Every call that omits bucket receives that same list, so it grows across calls.

Use None as the default and create the list inside the function. This is the single most reported Python gotcha, and linters (Ruff rule B006) flag it.

main.pywhole filepython
def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add_item("a"))
print(add_item("b"))
print(add_item.__defaults__)

07Shallow and deep copies

list(a), a[:] and a.copy() make a shallow copy: a new outer list whose items are the same objects. For a list of numbers that is enough. For a list of lists, the inner lists are still shared, and copy.deepcopy copies the whole tree (recall Topic 4.9).

Rule of thumb: copy when you are about to change something a caller might still be holding. Better still, build new objects and avoid changing shared ones.

Try it yourself

  1. 1

    Predict, then run

    Before pressing Run on the first example, write down the answers to the three comparisons. Then change b = a to b = a.copy() and predict again. Which comparisons change, and why does a stay unchanged after b.append(4)?

  2. 2

    Find the numbers that are cached

    In the second example, try int("256") and int("257") in place of the 200 and 100000. Where does the cache stop? Then explain in one sentence why you must never write if n is 256.

  3. 3

    Fix the default argument

    Change bucket=[] to bucket=None, add if bucket is None: bucket = [] as the first line of the function, and run it again. The two calls now print ['a'] and ['b'].

Code & diagrams

Names, objects, equality and identity New tab

Add `d = a[:]` and print `d is a` and `d == a`. A slice is a new list.

Sign in to run this example in your browser.

Expected output

a == c: True
a is c: False
a is b: True
a after b.append(4): [1, 2, 3, 4]
c unchanged: [1, 2, 3]
int -> type
str -> type
list -> type
builtin_function_or_method -> type
type -> type
True NoneType
Caches, argument passing and the default-argument trap New tab

The cache results are CPython details. Do not rely on them in real code.

Sign in to run this example in your browser.

Expected output

small ints share one object: True
big ints share one object:   False
but they are equal: True
built strings same object: False
after sys.intern: True
after rebind: [1, 2]
after mutate: [1, 2, 99]
['a']
['a', 'b']
defaults live on the function: (['a', 'b'],)
Names and objects in memorydiagram
Rendering diagram…
A shared request template: shallow copy against deepcopy New tab

A realistic aliasing bug: `dict(TEMPLATE)` copies only the outer dict, so the inner `messages` list is shared by every request.

Sign in to run this example in your browser.

Expected output

shallow: messages in the second request: 3
shallow: the template itself changed: 3 messages
shallow: shares the list with the template: True
deepcopy: messages in the third request: 2
deepcopy: the template is untouched: 1 message

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Compare a string with `is`

Use is to compare a string built at run time with a literal.

main.pywhole filepython
word = "".join(["hel", "lo"])
if word is "hello":
    print("same object")
else:
    print("different objects")
terminal
$ python main.py
── what you'll see ──
main.py:2: SyntaxWarning: "is" with 'str' literal. Did you mean "=="?
if word is "hello":
different objects

Myth vs fact

Myth

Python passes arguments by reference.

Fact

It passes the object reference by value. A function can mutate the caller's object, but it cannot make the caller's name point somewhere else by rebinding its own parameter.

Myth

is is a faster ==.

Fact

They answer different questions. is is about identity, == is about value. Using is for values gives results that depend on caching and can change between Python versions.

Myth

Assigning a list to another variable copies it.

Fact

Assignment never copies anything. It only adds a label. Use .copy(), a slice or copy.deepcopy to copy.

Myth

Integers are stored directly in the variable, like in C.

Fact

A name holds a reference to an int object. Even 5 is an object with a type, a size and a reference count, which is part of why Python loops are slower than C.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    In CPython, id(x) is the address of the object, and x is y compiles to one cheap pointer comparison (IS_OP). Other implementations differ: PyPy and Jython give id as an unrelated number. Never treat id as an address in portable code, and remember that an id can be reused: once an object is freed, a new object may get the same id, so never store an id as a long-lived key.

  • ▸

    Since Python 3.12 (PEP 683) the small integers, None, True, False and some interned strings are immortal: their reference count is a fixed marker value that is never changed, so they cost no count updates and can be shared safely between threads and sub-interpreters. This changes only how the count behaves. It does not change what is means, and which values are cached is still an implementation detail.

  • ▸

    The same reasoning explains x is y surprises in tests: assert result is 5 passes or fails depending on the interpreter. Since 3.8 CPython warns about is with a literal (a SyntaxWarning), and linters flag it (Ruff F632). The legitimate uses of is are None, True/False, your own sentinel objects (_MISSING = object()), and checking whether two names share a mutable object.

  • ▸

    Because names are labels, a closure or a default value keeps an object alive. A long-lived default argument or global list that you keep appending to is one of the common memory leaks in Python services (Topic 13.2).

  • ▸

    Treat shared mutable state as a design smell: prefer returning new values, using tuples and frozen dataclasses (Topic 8.8), and copying at module boundaries. The same discipline is why NumPy views (Topic 13.9) need care: a slice shares memory with its parent.

  • ▸

    In AI code, the same aliasing causes subtle bugs: a prompt template dictionary reused and mutated between requests, or a list of messages appended to inside a helper. When a function needs its own copy of a message list, copy it at the start.

Remember this

  1. 1

    Every object has three things: an identity (who it is; it never changes while the object lives), a type (what it is; it decides what you can do with it) and a value (what it holds). In CPython the identity is the object's address in memory, and id(obj) returns it. Integers, strings, lists, functions, classes and even None are all objects: there are no bare 'primitive' values in Python the way there are in Java or C.

  2. 2

    == asks whether two objects have equal values (it calls __eq__). is asks whether two names point at the same object (it compares identities, and nobody can change that). Two lists with the same contents are == but not is. Use is for None, True and False (there is only one object of each, a singleton), and == for everything else.

  3. 3

    Assignment never copies. b = a gives the one object a second label. If the object is mutable (list, dict, set, most class instances), a change through b is visible through a, because there is only one object. If the object is immutable (int, str, tuple, frozenset), nobody can change it, so sharing is safe. (Careful: a tuple is immutable but can hold a mutable item, such as a list, and that inner list can still change.) Rebinding a name (a = something_else) moves the label and never changes the old object.

  4. 4

    Arguments are passed by object reference, sometimes called 'call by sharing'. The function's parameter becomes a new label for the same object the caller holds. Mutating the object (x.append(1)) is seen by the caller. Rebinding the parameter (x = x + [1]) only moves the function's own label. This is neither 'by value' nor 'by reference' in the C++ sense, and it is why the question 'does Python pass by value or by reference?' has the answer 'neither: it passes object references by value'.

  5. 5

    CPython caches some objects to save memory and time. Small integers from -5 to 256 are pre-built, so int("7") is int("7") is True, while int("1000") is int("1000") is usually False. Some strings are interned (shared): the compiler interns string constants that look like identifiers, and sys.intern does it on request. Equal constants inside one compiled unit (one function, or one line typed into the REPL) can also be merged into a single object, so a = 1000; b = 1000 on one line may give a is b as True. These caches are implementation details: they are how CPython happens to work, the language does not promise them, and code must never depend on is for numbers or strings.

  6. 6

    The classic trap built on all this is the mutable default argument (recall Topic 5.2). A default value is created once, when the def statement runs, and stored on the function (func.__defaults__). A default [] is therefore one list shared by every call that does not pass its own. The fix is bucket=None and if bucket is None: bucket = [] inside the function.

Explain it without notes

01

What is the difference between == and is, and when is each correct?

02

Is Python pass-by-value or pass-by-reference? Explain with a function that mutates and one that rebinds.

03

Why is def f(x, items=[]) dangerous, and how do you fix it?

04

Why can a is b be True for two small integers but False for two large ones? Should your code rely on this?

05

What does a += b do for a list, and what does it do for an int or a tuple? Why does t = ([1], 2); t[0] += [3] both fail and succeed?

Practice

01

Predict the output: a = [1, 2], b = a, b += [3], c = a, c = c + [4]. Print a, b, c, and whether a is b and a is c.

02

Fix append_to(item, bucket=[]) so each call without a bucket starts with a new empty list. Show that two calls print ['a'] and ['b'], and that a list you pass in is used.

03

Show with three pairs (two int("1000"), two int("7"), two [1] lists) that equal does not mean same object.

04

Run t[0] += [3] on t = ([1], 2) inside try/except TypeError. Print the error type name and then t. Explain in a comment why the tuple still changed.

Trade-offs

  • ↔

    Sharing is cheap, copying is safe. Because assignment only adds a label, passing big lists around costs nothing. The price is aliasing bugs, so mutate shared data on purpose or not at all.

  • ↔

    Object everywhere is uniform but costs memory. A Python int is a full object (a reference count, a type pointer and the digits). That uniformity gives you one simple model, and it is why numeric work moves to NumPy arrays (Topic 13.9).

Done when you can

  • Done when you can draw names as arrows pointing to objects for a small program.

  • Done when you can say when == and is give different answers and which to use.

  • Done when you can predict whether a function's change to its argument is visible to the caller.

  • Done when you can explain the mutable default argument bug and fix it.

  • Done when you can explain why is must not be used to compare numbers or strings.