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.
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4] — "a" changed too!
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:
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:
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 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:
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!
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
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:
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'] !
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!
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:
numbers = [3, 1, 4] # .sort() returns None! numbers = numbers.sort() print(numbers) # None
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 listreversed(list)— returns an iteratorlist + other_list— returns a new listlist * n— returns a new list
Key Insight #5: List Multiplication Creates Shared References
This is a subtle trap with nested structures:
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!
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!
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):
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]
Slices of nested structures still share inner references:
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
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:
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
The iterator tracks position by index. When you remove an item, everything shifts, causing the iterator to skip the next element.
Solutions
# 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:
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]
result = [] for item in items: result = result + [item] # Creates new list each time
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):
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):
queue = [] queue.append("first") queue.append("second") queue.pop(0) # O(n) shifts all elements
from collections import deque queue = deque() queue.append("first") queue.append("second") queue.popleft() # O(1)
Key Insight #10: Checking for Empty Lists
items = [] # Empty list is falsy if not items: print("List is empty")
items = [] # Unnecessarily explicit if len(items) == 0: print("List is empty") if items == []: print("List is empty")
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:
# 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
| Operation | Average Case | Notes |
|---|---|---|
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 list | O(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
| Concept | Key Takeaway |
|---|---|
| Variables are references | Assignment creates another name for the same object |
| Copy explicitly | Use .copy(), list(), [:], or deepcopy() when you need independence |
| Mutable default args | Use 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 iterating | Iterate over a copy or build a new list instead |
| Right tool for the job | Consider set for membership, deque for queues, tuple for immutability |