Mutability & Data Types Interview Questions & Answers

33 questions Updated 2026-06-17

Python interview questions on mutable vs immutable types, the mutable default argument trap, is vs ==, and shallow vs deep copy.

Read the in-depth guidePython Mutability — Mutable vs Immutable Types Explained

Immutable (the value can never change in place — any "change" makes a new object): int, float, complex, bool, str, tuple, frozenset, bytes, and None.

Mutable (can be modified in place): list, dict, set, bytearray, and most custom class instances.

s = "hello"
print(id(s))
s += " world"        # looks like mutation...
print(id(s))         # ...but id() changed — a NEW string was created

nums = [1, 2, 3]
print(id(nums))
nums.append(4)       # genuine in-place mutation
print(id(nums))      # same id — same object

Why it matters: mutability drives how assignment, function arguments, and ==/is behave. Immutable objects are also hashable (usable as dict keys / set members), while mutable ones generally aren't.

A function's default argument is evaluated once, when the def statement runs — not on each call. So a mutable default (like [] or {}) is created a single time and shared across every call, accumulating state between invocations.

def add(item, bucket=[]):   # the same list object on every call
    bucket.append(item)
    return bucket

add(1)   # [1]
add(2)   # [1, 2]  <- surprise! the list persisted
add(3)   # [1, 2, 3]

The fix is the standard None sentinel — use None as the default and create a fresh object inside the body:

def add(item, bucket=None):
    if bucket is None:      # new list per call
        bucket = []
    bucket.append(item)
    return bucket

== tests value equality — "do these represent the same data?" — by calling the object's __eq__. is tests identity — "are these the exact same object in memory?" — comparing id()s. They often agree, but not always.

a = [1, 2, 3]
b = [1, 2, 3]
a == b   # True  — equal contents
a is b   # False — two distinct list objects

c = a
c is a   # True  — same object

Rule: use is only for singletonsNone, True, False (e.g. if x is None:). Don't use is for numbers or strings: small ints and some strings appear to work because CPython caches/interns them (256 is 256 -> True, but 1000 is 1000 can be False), which is an implementation detail you shouldn't rely on.

A shallow copy (copy.copy, list(x), x[:], dict(x)) creates a new outer container but copies references to the nested objects — so the inner objects are still shared. A deep copy (copy.deepcopy) recursively copies everything, producing a fully independent structure.

import copy
original = [[1, 2], [3, 4]]

shallow = copy.copy(original)
shallow[0].append(99)
print(original)   # [[1, 2, 99], [3, 4]]  <- inner list was shared!

deep = copy.deepcopy(original)
deep[0].append(99)
print(original)   # unchanged — fully independent

Use a shallow copy when the elements are immutable (or sharing is fine); reach for deepcopy when you have nested mutable structures and need true isolation (note it's slower and handles cycles).

Yes. A tuple's immutability is shallow: you can't reassign or resize its slots, but each slot is just a reference, and if that reference points to a mutable object, that object can still be changed in place.

t = (1, [2, 3])
t[1].append(4)     # allowed — mutating the list inside
print(t)           # (1, [2, 3, 4])

t[1] = [9]         # TypeError — can't reassign a tuple slot

A consequence interviewers love: a tuple containing a list is not hashable, because hashability requires all contents to be immutable — so hash((1, [2])) raises TypeError, and such a tuple can't be a dict key.

Dictionary keys (and set members) must be hashable. Hashing requires that an object's hash value stays constant for its lifetime, which in practice means it must be immutable. Lists are mutable, so Python deliberately makes them unhashable — they have no __hash__.

d = {}
d[[1, 2]] = 'x'   # TypeError: unhashable type: 'list'
d[(1, 2)] = 'x'   # tuples are immutable -> hashable

The reason is correctness: a dict places a key in a bucket based on its hash. If a key could mutate after insertion, its hash would change and you'd never be able to find it again. Use an immutable equivalent — a tuple instead of a list, a frozenset instead of a set.

id(obj) returns a unique integer identity for an object — in CPython, its memory address. Two names with the same id refer to the same object; is is essentially an id comparison.

a = [1, 2]
b = a
id(a) == id(b)   # True  — same object
a is b           # True
c = [1, 2]
id(a) == id(c)   # False — equal value, different object

id is useful for understanding aliasing and why mutation through one name is visible through another. The actual value is implementation-specific (don't rely on it being an address).

CPython pre-caches small integers from −5 to 256 as singletons, so equal values in that range share one object and is returns True. Outside that range, equal integers are usually distinct objects.

a = 256
b = 256
a is b      # True  — cached

c = 257
d = 257
c is d      # False — separate objects (in a REPL)
c == d      # True  — always compare values with ==

This is a CPython implementation detail, not a language guarantee. Never use is to compare numbers — use ==. is is only for singletons like None.

CPython interns some strings — storing one shared copy — so identical string literals can be the same object. Short, identifier-like strings are interned automatically; others may not be.

a = "hello"
b = "hello"
a is b           # True  — interned literal

c = "hello world!"
d = "hello world!"
c is d           # often False (not auto-interned)

import sys
e = sys.intern("hello world!")  # force interning

Like int caching, this is an optimization detail. Always compare string values with ==, not identity with is.

You get a surprising result: the list is mutated, and a TypeError is raised. t[0] += [3] does t[0] = t[0] + [3] — the += extends the list in place (succeeds), then tries to reassign the tuple slot (fails, since tuples are immutable).

t = ([1, 2], 'x')
t[0] += [3]      # TypeError: 'tuple' object does not support item assignment
print(t)         # ([1, 2, 3], 'x')  — the list WAS extended!

So the mutation happens before the assignment error. Use t[0].extend([3]) if you want to mutate the inner list without the confusing error.

Yes — slicing produces a new (shallow) list containing the same element references. lst[:] is a common idiom for a shallow copy. But the elements themselves are shared, so nested mutables are still linked.

a = [1, 2, 3]
b = a[:]          # shallow copy
b.append(4)
a                 # [1, 2, 3] — unaffected

nested = [[1], [2]]
copy = nested[:]
copy[0].append(9)
nested            # [[1, 9], [2]] — inner list shared

Slicing copies the outer list only; use copy.deepcopy for full independence of nested structures.

Neither exactly — Python is pass-by-object-reference (a.k.a. "pass by assignment"). The function receives a reference to the same object, so it can mutate a mutable argument in place, but rebinding the parameter doesn't affect the caller.

def mutate(lst): lst.append(4)     # caller sees this
def rebind(lst): lst = [0]         # caller does NOT see this

data = [1, 2, 3]
mutate(data); print(data)  # [1, 2, 3, 4]
rebind(data); print(data)  # [1, 2, 3, 4] (unchanged)

Immutable args (ints, strings, tuples) can't be mutated, so they appear pass-by-value. The key is mutate-in-place vs reassign-the-name.

copy.deepcopy tracks already-copied objects in a memo dictionary, so it handles circular references without infinite recursion — each object is copied once and reused.

import copy
a = [1, 2]
a.append(a)            # a contains itself
b = copy.deepcopy(a)   # works — no infinite loop
b[2] is b              # True — the cycle is preserved in the copy

A naive recursive copy would loop forever; deepcopy's memo makes it safe. You can customize copying via __deepcopy__/__copy__ methods on your classes.

Using is to compare values is a bug — it tests identity, which only coincidentally matches for cached singletons (small ints, interned strings, None). It fails unpredictably for other values.

x = 1000
x is 1000        # may be False (and raises a SyntaxWarning in 3.8+)
x == 1000        # True

a = "long string value"
a is "long string value"  # often False

Rule: use == for value equality; reserve is for None, True, False, and sentinel objects. Linters flag is comparisons with literals.

Implement both __eq__ and __hash__, keeping them consistent: equal objects must have equal hashes. Base the hash on the same immutable fields used for equality.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
        return hash((self.x, self.y))

{Point(1, 2), Point(1, 2)}   # one element — treated as equal

Defining __eq__ without __hash__ makes the class unhashable (Python sets __hash__ = None). Only hash on fields that don't change after creation.

A @dataclass(frozen=True) makes instances immutable — attempting to set an attribute raises FrozenInstanceError. Frozen dataclasses also get a __hash__ automatically, so they're usable as dict keys / set members.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

p = Point(1, 2)
p.x = 9          # FrozenInstanceError
{p: "origin"}    # hashable

It's the concise modern way to define immutable value objects with auto-generated __init__, __eq__, __repr__, and __hash__.

Once created, a str can't be changed — any "modification" returns a new string. Immutability enables interning, safe use as dict keys (cached hash), thread safety, and predictable behavior.

s = "hello"
s[0] = "H"           # TypeError: 'str' does not support item assignment
s = s.replace("h", "H")  # new string, rebind
s += " world"        # new string each time

Repeated concatenation in a loop creates many throwaway strings — prefer "".join(parts) for efficiency, the Python analog of StringBuilder.

  • list — mutable, variable-length, for homogeneous, changing sequences.
  • tuple — immutable, fixed, for heterogeneous, fixed records (and hashable, so usable as dict keys).
point = (3, 4)        # fixed record — tuple
scores = [90, 85]     # changing collection — list
scores.append(70)     #
point[0] = 9          # tuples are immutable

d = {(0, 0): "origin"}  # tuple key ; list key would fail

Tuples are slightly faster and more memory-efficient, and signal "this shouldn't change." Use a list when you need to add/remove/reorder.

Modifying a list's size during iteration skips or repeats elements because the internal index shifts under you — a classic bug (Python doesn't always raise, it silently misbehaves; dicts/sets do raise RuntimeError).

nums = [1, 2, 3, 4]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)   # skips elements
print(nums)              # [1, 3] sometimes wrong for other inputs

nums = [n for n in nums if n % 2]      # build a new list

Iterate over a copy (for n in nums[:]) or, better, build a new list with a comprehension/filter.

del unbinds a name (or removes an item/slice/attribute) — it doesn't directly "delete" the object. The object is garbage-collected only when its reference count hits zero.

a = [1, 2, 3]
b = a
del a            # unbinds 'a'; the list still lives (b references it)
print(b)         # [1, 2, 3]

del b[0]         # removes an item -> [2, 3]

So del a removes the name, not necessarily the value. For container items it mutates the container. After del a, referencing a raises NameError.

Rebinding points a name at a new object (x = [...]); it doesn't affect other names pointing at the old object. Mutating changes an object in place (x.append(...)); all names referencing it see the change.

a = [1, 2]
b = a
a.append(3)   # mutate -> b sees it; b == [1, 2, 3]
a = [9]       # rebind -> b unchanged; b == [1, 2, 3]

This distinction explains most "why did my other variable change?" confusion. Aliases share mutations but not rebinding.

A mutable value assigned at class level (not in __init__) is one object shared by every instance. Mutating it through one instance affects all of them — a common bug.

class Cart:
    items = []          # shared by ALL instances
    def add(self, x): self.items.append(x)

a, b = Cart(), Cart()
a.add("apple")
b.items                 # ['apple'] — leaked into b!

class Cart:
    def __init__(self):
        self.items = []  # per-instance

Initialize mutable attributes in __init__ so each instance gets its own.

List multiplication copies the references, not the objects — so [[]] * 3 creates three references to the same inner list. Mutating one mutates all.

grid = [[]] * 3
grid[0].append(1)
print(grid)          # [[1], [1], [1]] — all share one list!

grid = [[] for _ in range(3)]  # three distinct lists
grid[0].append(1)
print(grid)          # [[1], [], []]

The same applies to [0] * 3 (fine for immutable ints) vs [[]] * 3 (broken for mutables). Use a comprehension to get independent inner objects.

collections.namedtuple (or typing.NamedTuple) creates an immutable tuple subclass with named fields — readable, hashable, lightweight records.

from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x          # 3  — named access
p[0]         # 3  — still index-accessible
p.x = 9      # immutable

It's great for returning multiple values with clear names while keeping tuple semantics. For more features (defaults, methods, mutability options), a @dataclass is the modern alternative.

They let you rebind a name from an outer scope. global targets module-level names; nonlocal targets the nearest enclosing function scope. Without them, assignment inside a function creates a new local instead.

count = 0
def inc():
    global count
    count += 1        # rebinds the module-level count

def outer():
    x = 1
    def inner():
        nonlocal x
        x = 2          # rebinds outer's x
    inner()
    return x           # 2

Note you only need them to reassign — you can mutate an outer mutable object (e.g. list.append) without global/nonlocal.

Several produce a shallow copy; copy.deepcopy is the only deep one.

a = [1, 2, 3]
a[:]            # slice copy
a.copy()        # method
list(a)         # constructor

d = {"x": 1}
d.copy()        # method
dict(d)         # constructor
{**d}           # unpacking

import copy
copy.deepcopy(a)  # deep — independent nested objects

All the shallow methods share nested mutable elements; choose deepcopy when you need full independence (at a performance cost).

None is a singleton — there's exactly one None object — so is None is the correct, fast, and idiomatic identity check. == None can be overridden by a custom __eq__, giving wrong or surprising results.

if x is None:        # idiomatic, can't be fooled
    ...

class Weird:
    def __eq__(self, other): return True
Weird() == None      # True  misleading
Weird() is None      # False

PEP 8 explicitly recommends is/is not for None comparisons.

Like dict keys, set members are stored by hash for O(1) membership tests, so they must be hashable (and thus effectively immutable). Lists, dicts, and sets can't be set elements; tuples and frozensets can.

{1, 2, 3}            #
{[1], [2]}           # TypeError: unhashable type: 'list'
{(1, 2), (3, 4)}     # tuples are hashable
{frozenset({1, 2})}  #

If you need a set of sets, use frozenset for the inner ones. The hashability requirement is the same reason lists can't be dict keys.

In Python 3, comprehensions have their own scope, so the loop variable does not leak into the surrounding scope (unlike a regular for loop, and unlike Python 2).

squares = [i * i for i in range(5)]
print(i)        # NameError — i is local to the comprehension

for j in range(5):
    pass
print(j)        # 4 — a normal for loop DOES leak

This avoids accidental variable clobbering. The same isolation applies to set, dict, and generator comprehensions.

Python evaluates the right side first into a tuple, then unpacks it into the left-side names — so you can swap without a temporary variable.

a, b = 1, 2
a, b = b, a        # builds (2, 1), then unpacks -> a=2, b=1

# also works for multiple/extended unpacking:
first, *rest = [1, 2, 3, 4]   # first=1, rest=[2, 3, 4]

The right-hand tuple is fully created before any assignment, which is why the swap is atomic and needs no temp. This is immutability of the intermediate tuple at work.

A shallow copy duplicates the outer container but shares the nested objects, so mutating a nested element changes both the original and the copy — a subtle aliasing bug.

import copy
original = {"users": ["ada"]}
shallow = copy.copy(original)
shallow["users"].append("grace")
original["users"]    # ['ada', 'grace'] — shared nested list!

deep = copy.deepcopy(original)
deep["users"].append("hopper")
original["users"]    # unchanged

Use deepcopy whenever you copy a structure with nested mutables you intend to modify independently.

A frozenset is an immutable version of set — same operations (union, intersection, membership) but no add/remove. Because it's immutable, it's hashable, so it can be a dict key or an element of another set.

fs = frozenset([1, 2, 3])
fs.add(4)              # AttributeError — immutable
{fs: "a set"}          # hashable key
{frozenset({1}), frozenset({2})}  # set of sets

Use it for constant sets and whenever you need a set-like value that must be hashable.

They must stay consistent: if a == b, then hash(a) == hash(b). Otherwise hash-based containers (dict, set) misbehave — an object you stored becomes unfindable.

class Money:
    def __init__(self, cents): self.cents = cents
    def __eq__(self, o): return self.cents == o.cents
    # defined __eq__ but not __hash__ -> unhashable
{Money(100)}   # TypeError: unhashable type: 'Money'

Defining __eq__ sets __hash__ to None automatically (making instances unhashable) unless you also define __hash__. Hash only on fields that never change after creation, or make the object immutable.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.