Types, identity, and mutability
is against ==, and why a list and a tuple behave so differently once you pass them around.
After this lesson you can
- Explain the difference between is and ==
- Say which built-in types are mutable and which are not
- Predict what happens when a mutable default argument is reused
== asks whether two values are equal. is asks whether two names
point at the same object in memory. They usually agree by accident
for small integers and interned strings, which is exactly what makes
is dangerous to reach for out of habit.
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — same contents
a is b # False — two different list objects
x = 5
y = 5
x is y # True — small ints are cached, but this is an implementation detail
is has exactly one correct everyday use: comparing against None.
value is None, never value == None — None is a singleton, so is
is both correct and cannot be fooled by a class's own __eq__, and it
is the style PEP 8 asks for.
Mutable and immutable
str, int, float, bool, and tuple are immutable — nothing can
change them in place; every "modification" makes a new object.
list, dict, and set are mutable — the same object can change
shape without a new one being created.
s = "hello"
s.upper() # returns "HELLO", a new string
s # still "hello" — s itself never changed
nums = [1, 2, 3]
nums.append(4) # mutates nums in place
nums # [1, 2, 3, 4] — the same list object, changed
Try it
def run(): original = [1, 2, 3] alias = original copy = original[:] alias.append(4) copy.append(99) return {"original": original, "alias": alias, "copy": copy}The mutable default argument trap
A default argument is evaluated once, when the def runs — not on
every call.
def add_item(item, into=[]):
into.append(item)
return into
add_item("a") # ['a']
add_item("b") # ['a', 'b'] — the same list, remembered from last time
Every call that does not pass into shares and mutates the exact same
list, because that list was created once, at definition time, not fresh
per call. The fix is the standard sentinel:
def add_item(item, into=None):
if into is None:
into = []
into.append(item)
return into
This is the single most common source of a "how does this function
already have data in it the first time I call it" bug in Python, and it
reaches beyond lists: every default expression is evaluated once, so a
default {} used as a cache is shared by every call, and a default
datetime.now() is frozen at the moment the def ran.
def add_with_or(item, into=None): into = into or [] into.append(item) return into def add_with_is_none(item, into=None): if into is None: into = [] into.append(item) return into def run(): mine_or = [] add_with_or("x", mine_or) mine_none = [] add_with_is_none("x", mine_none) fresh = [add_with_is_none("a"), add_with_is_none("b")] return {"with_or": mine_or, "with_is_none": mine_none, "fresh": fresh}Common mistakes
- Writing
into = into or []instead ofif into is None. An empty list is falsy, so a caller who passes their own empty list gets a brand-new one back and their list never sees the item (with_orabove stays[]). - Using
isto compare numbers or strings. It happens to work for small integers, which CPython caches, and fails for larger ones built at runtime —big_isbelow isFalsealthough the values are equal. - Assuming a tuple freezes what it holds. The tuple's slots cannot be reassigned, but a list inside it can still grow, and a tuple holding a list cannot be hashed.
def run(): small_a, small_b = int("7"), int("7") big_a, big_b = int("1000"), int("1000") frozen = ([1, 2], "label") frozen[0].append(3) try: hash(frozen) hashable = "yes" except TypeError as e: hashable = f"TypeError: {e}" return { "small_is": small_a is small_b, "big_eq": big_a == big_b, "big_is": big_a is big_b, "tuple_after_append": frozen, "tuple_hash": hashable, }What interviewers ask
- What is the difference between
isand==? Identity against equality;isis forNoneand other singletons or sentinels, never for comparing values. - Why is
def f(x=[])a bug, and how do you fix it? The default is built once, whendefruns, and shared by every call; default toNoneand create the list inside. - Is a tuple that contains a list immutable? Its own references are fixed, but the list inside can change, which is also why such a tuple is unhashable.
Try it yourself
3 visible tests · 2 hidden testsThe starter code below has the mutable-default bug from this lesson.
Fix add_score(name, score, board=None) so it appends
{"name": name, "score": score} to board and returns board:
- when the caller passes a board, the entry goes into that very list — even when it is empty — and that list is returned;
- when no board is passed, it starts a fresh, empty list on every
call. Two calls that both omit
boardmust never share one.
entryPoint(calls) is written for you. It keeps one list of its own,
mine, starting empty. Each call is [name, score, pass_mine]: when
pass_mine is true it calls add_score(name, score, mine), otherwise
add_score(name, score). It returns {"returned": [...], "mine": mine}, the value each call returned and the final state of mine.
entryPoint([["Nino",90,false]])entryPoint([["Ana",75,true],["Luka",60,true]])entryPoint([["Mari",88,false],["Giorgi",40,false]])
Sign up to run the tests, check the hidden ones and save your progress. Sign up