Lists and Mutability in Python

Understanding references, shared state, and the most common list pitfalls every Python developer encounters

The Core Concept: References, Not Boxes

The most important mental shift for developers coming to Python is this: variables don't contain values — they point to objects.

python
a = [1, 2, 3]
b = a
b.append(4)

print(a)  # [1, 2, 3, 4] — "a" changed too!
Why This Matters

In Python, a and b are two names pointing to the same list object in memory. This surprises developers from languages where variables are like boxes that hold values. Understanding this is essential to avoiding a whole class of bugs.

Key Insight #1: Assignment Never Copies

This is the rule that causes the most confusion:

python
original = [1, 2, 3]

reference = original           # NOT a copy — same object
shallow   = original.copy()    # Shallow copy — new list, same contents
shallow2  = list(original)     # Also a shallow copy
shallow3  = original[:]        # Also a shallow copy (slice of everything)

You can verify identity with is:

python
original = [1, 2, 3]
reference = original
shallow = original.copy()

print(reference is original)   # True — same object
print(shallow is original)     # False — different object
print(shallow == original)     # True — equal contents
is vs == distinction

is checks identity (same object in memory). == checks equality (same value).

Key Insight #2: Shallow vs Deep Copy

A shallow copy creates a new list but doesn't copy the nested objects inside:

python
original = [[1, 2], [3, 4]]
shallow = original.copy()

shallow.append([5, 6])
print(original)  # [[1, 2], [3, 4]] — outer list unchanged, good!

shallow[0].append(999)
print(original)  # [[1, 2, 999], [3, 4]] — inner list changed, surprise!
Shared References

The shallow copy created a new outer list, but the inner lists are still shared references. Modifying a nested object affects both copies.

When You Need True Independence

python
import copy

original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)

deep[0].append(999)
print(original)  # [[1, 2], [3, 4]] — completely independent
  • Simple list of immutables (ints, strings): shallow copy is fine
  • Nested structures you'll modify: use deepcopy
  • Read-only access: no copy needed

Key Insight #3: The Mutable Default Argument Trap

This is Python's most infamous gotcha:

Wrong — Mutable Default
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b'] !
print(add_item("c"))  # ['a', 'b', 'c'] !
Right — None Sentinel
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b'] fresh!
Why This Happens

Default arguments are evaluated once when the function is defined, not each time it's called. That empty list [] is created once and reused across every call. The None sentinel pattern is idiomatic Python.

Key Insight #4: In-Place Operations vs Rebinding

Lists have methods that modify them in place and return None:

Wrong — Assigning None
numbers = [3, 1, 4]

# .sort() returns None!
numbers = numbers.sort()
print(numbers)  # None
Right — Use Correctly
numbers = [3, 1, 4]

# In-place: just call it
numbers.sort()
print(numbers)  # [1, 3, 4]

# Or get a new list:
sorted_nums = sorted(numbers)
  • .append(x) — add to end
  • .extend(iterable) — add multiple items
  • .insert(i, x) — insert at position
  • .remove(x) — remove first occurrence
  • .pop([i]) — remove and return item (this one returns the removed item)
  • .clear() — remove all items
  • .sort() — sort in place
  • .reverse() — reverse in place
  • sorted(list) — returns a new sorted list
  • reversed(list) — returns an iterator
  • list + other_list — returns a new list
  • list * n — returns a new list

Key Insight #5: List Multiplication Creates Shared References

This is a subtle trap with nested structures:

Wrong — Shared Inner Lists
matrix = [[0] * 3] * 3
print(matrix)
# [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
# All rows changed!
Right — List Comprehension
matrix = [[0] * 3 for _ in range(3)]
print(matrix)
# [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
# Only first row changed!
Why?

The * 3 created three references to the same inner list. Multiplying works fine for immutables like [0] * 5, but creates shared references for mutable objects.

Key Insight #6: Slicing Creates Shallow Copies

Slicing always creates a new list (unlike assignment):

python
original = [1, 2, 3, 4, 5]
sliced = original[1:4]
sliced[0] = 999

print(original)  # [1, 2, 3, 4, 5] — unchanged
print(sliced)     # [999, 3, 4]
Still Shallow!

Slices of nested structures still share inner references:

python
original = [[1, 2], [3, 4], [5, 6]]
sliced = original[0:2]
sliced[0][0] = 999

print(original)  # [[999, 2], [3, 4], [5, 6]] — inner list modified!

Slice Assignment Modifies In Place

python
nums = [1, 2, 3, 4, 5]

# Replace a range
nums[1:4] = [20, 30]
print(nums)  # [1, 20, 30, 5]

# Insert without removing (empty slice)
nums[1:1] = [15, 16, 17]
print(nums)  # [1, 15, 16, 17, 20, 30, 5]

# Delete a range
nums[1:4] = []
print(nums)  # [1, 20, 30, 5]

Key Insight #7: Iteration and Modification Don't Mix

Never modify a list while iterating over it with a for loop:

python
numbers = [2, 4, 6, 8]

# BUG: This skips elements!
for num in numbers:
    if num % 2 == 0:
        numbers.remove(num)

print(numbers)  # [4, 8] — wrong! Items were skipped
Why?

The iterator tracks position by index. When you remove an item, everything shifts, causing the iterator to skip the next element.

Solutions

python
# Solution 1: Iterate over a copy
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers[:]:  # slice creates a copy
    if num % 2 == 0:
        numbers.remove(num)

# Solution 2: Build a new list (more Pythonic)
numbers = [1, 2, 3, 4, 5, 6]
numbers = [num for num in numbers if num % 2 != 0]

# Solution 3: Iterate backwards (if you must modify in place)
numbers = [1, 2, 3, 4, 5, 6]
for i in range(len(numbers) - 1, -1, -1):
    if numbers[i] % 2 == 0:
        del numbers[i]

Key Insight #8: append() vs extend() vs +

These look similar but behave differently:

python
a = [1, 2, 3]

# append adds ONE item (even if it's a list)
a.append([4, 5])
print(a)  # [1, 2, 3, [4, 5]]

a = [1, 2, 3]

# extend adds EACH item from an iterable
a.extend([4, 5])
print(a)  # [1, 2, 3, 4, 5]

a = [1, 2, 3]

# + creates a NEW list
b = a + [4, 5]
print(a)  # [1, 2, 3] — unchanged
print(b)  # [1, 2, 3, 4, 5]

# += modifies in place (like extend, but also rebinds)
a += [4, 5]
print(a)  # [1, 2, 3, 4, 5]
Slow — O(n²) total
result = []
for item in items:
    result = result + [item]
    # Creates new list each time
Fast — O(n) total
result = []
for item in items:
    result.append(item)

# Even more Pythonic:
result = [item for item in items]

Key Insight #9: Lists as Stacks and Queues

Lists work well as stacks (LIFO):

python
stack = []
stack.append("first")
stack.append("second")
stack.append("third")

print(stack.pop())  # "third"
print(stack.pop())  # "second"

But they're inefficient as queues (FIFO) because pop(0) is O(n):

Slow — O(n) per pop
queue = []
queue.append("first")
queue.append("second")
queue.pop(0)  # O(n) shifts all elements
Right — Use deque
from collections import deque
queue = deque()
queue.append("first")
queue.append("second")
queue.popleft()  # O(1)

Key Insight #10: Checking for Empty Lists

Pythonic
items = []

# Empty list is falsy
if not items:
    print("List is empty")
Avoid — Verbose
items = []

# Unnecessarily explicit
if len(items) == 0:
    print("List is empty")

if items == []:
    print("List is empty")
Truthiness of Lists

Empty list [] is falsy. Any non-empty list is truthy (even [0] or [False]).

Key Insight #11: List Comprehensions vs Loops

List comprehensions aren't just shorter — they're often faster and express intent more clearly:

python
# Traditional loop
squares = []
for x in range(10):
    squares.append(x ** 2)

# List comprehension
squares = [x ** 2 for x in range(10)]

# With conditional
evens = [x for x in range(20) if x % 2 == 0]

# With transformation and conditional
result = [x ** 2 for x in range(20) if x % 2 == 0]

# Nested (flatten a 2D list)
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [num for row in matrix for num in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]
  • When you need to break early
  • When the logic is complex (deeply nested comprehensions are hard to read)
  • When you're not building a list (just iterating for side effects)

Performance Characteristics

OperationAverage CaseNotes
list[i]O(1)Direct index access
list.append(x)O(1)**Amortized; occasional resize
list.pop()O(1)From end
list.pop(0)O(n)From front — use deque
list.insert(i, x)O(n)Must shift elements
x in listO(n)Linear search — use set for O(1)
list.sort()O(n log n)Timsort
len(list)O(1)Stored as attribute

Summary: The Mutability Mindset

ConceptKey Takeaway
Variables are referencesAssignment creates another name for the same object
Copy explicitlyUse .copy(), list(), [:], or deepcopy() when you need independence
Mutable default argsUse None as a sentinel — never use [] or {} as defaults
In-place methods.sort(), .append(), etc. return None — don't assign their result
List multiplication[[0]*3]*3 shares references — use comprehensions for nested structures
Don't modify while iteratingIterate over a copy or build a new list instead
Right tool for the jobConsider set for membership, deque for queues, tuple for immutability