Python's data structures are powerful, but they have behavior that surprises many developers - especially around copying and references. Understanding how Python handles objects in memory is critical for writing correct code.
In this lab, you'll learn:
- Core data structures: List, Dict, Tuple, Set
- The difference between mutable and immutable types
- Why assignment doesn't create copies
- When to use
copy()vsdeepcopy() - How functions modify objects in-place
- Sets and hashability (clearing up a common misconception)
# Launch Python REPL
python
>>> # Create lists
>>> numbers = [1, 2, 3, 4, 5]
>>> mixed = [1, "hello", 3.14, True]
>>> nested = [[1, 2], [3, 4], [5, 6]]
>>> # Common operations
>>> numbers.append(6)
>>> numbers
[1, 2, 3, 4, 5, 6]
>>> numbers.pop()
6
>>> numbers[0] = 100 # Modify by index
>>> numbers
[100, 2, 3, 4, 5]
>>> len(numbers)
5
>>> 3 in numbers
True
>>> # Slicing - [start:stop:step] (stop is exclusive)
>>> nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> nums[2:5] # Index 2 to 4 (5 is exclusive)
[2, 3, 4]
>>> nums[:3] # First 3 items
[0, 1, 2]
>>> nums[7:] # From index 7 to end
[7, 8, 9]
>>> nums[::2] # Every 2nd item
[0, 2, 4, 6, 8]
>>> nums[::-1] # Reverse (step backwards)
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
>>> # Slicing works with strings and tuples too
>>> "hello"[1:4]
'ell'
>>> (10, 20, 30, 40, 50)[::2]
(10, 30, 50)>>> # Create dictionaries
>>> server = {
... "hostname": "web-01",
... "ip": "192.168.1.10",
... "port": 8080,
... "active": True
... }
>>> # Access and modify
>>> server["hostname"]
'web-01'
>>> server["port"] = 443
>>> server["location"] = "us-east-1" # Add new key
>>> server.keys()
dict_keys(['hostname', 'ip', 'port', 'active', 'location'])
>>> server.values()
dict_values(['web-01', '192.168.1.10', 443, True, 'us-east-1'])
>>> server.get("hostname")
'web-01'
>>> server.get("missing", "default")
'default'>>> # Create tuples
>>> coordinates = (10.5, 20.3)
>>> config = ("localhost", 8080, "admin")
>>> # Access
>>> coordinates[0]
10.5
>>> # Tuples are immutable - this fails!
>>> coordinates[0] = 15
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> # Unpacking
>>> host, port, user = config
>>> host
'localhost'- Objects inside the set must be Hashable. This is the same for Dictionary Keys.
>>> # Create sets
>>> numbers = {1, 2, 3, 4, 5}
>>> duplicates = {1, 2, 2, 3, 3, 3}
>>> duplicates
{1, 2, 3} # Duplicates removed!
>>> # Set operations
>>> numbers.add(6)
>>> numbers.remove(1)
>>> numbers
{2, 3, 4, 5, 6}
>>> 3 in numbers
True
>>> # Set math
>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> a & b # Intersection
{3, 4}
>>> a | b # Union
{1, 2, 3, 4, 5, 6}
>>> a - b # Difference
{1, 2}
>>> # Hashable vs Unhashable
>>> hash((1, 2, 3)) # Tuples are hashable
2528502973977326415
>>> {(1, 2), (3, 4)} # Tuples can be in sets
{(1, 2), (3, 4)}
>>> hash([1, 2, 3]) # Lists are NOT hashable
TypeError: unhashable type: 'list'
>>> {[1, 2], [3, 4]} # Lists cannot be in sets
TypeError: unhashable type: 'list'Exit the REPL for now:
>>> exit()This is where most bugs come from. Let's see it in action.
Create reference_demo.py:
# Primitives (int, float, str, bool) create copies
x = 10
y = x
y = 20
print(f"x = {x}") # x is still 10
print(f"y = {y}") # y is 20
# Strings too
name1 = "Alice"
name2 = name1
name2 = "Bob"
print(f"name1 = {name1}") # Still "Alice"
print(f"name2 = {name2}") # Now "Bob"Run it:
python reference_demo.pyExpected output:
x = 10
y = 20
name1 = Alice
name2 = Bob
This works because primitives are immutable. Assignment creates a new value.
Create list_reference.py:
# Lists - this is the GOTCHA!
list1 = [1, 2, 3]
list2 = list1 # This does NOT create a copy!
# Modify list2
list2.append(4)
print(f"list1 = {list1}") # SURPRISE! list1 changed too!
print(f"list2 = {list2}")
# They're the same object!
print(f"list1 is list2: {list1 is list2}") # is compares memory locations
print(f"id(list1) = {id(list1)}") # id prints memory location
print(f"id(list2) = {id(list2)}")Run it:
python list_reference.pyOutput:
list1 = [1, 2, 3, 4]
list2 = [1, 2, 3, 4]
list1 is list2: True
id(list1) = 140234567890123
id(list2) = 140234567890123 # Same memory address!
BOTH LISTS CHANGED! list2 = list1 created a reference, not a copy.
Create list_copy.py:
# Method 1: .copy()
list1 = [1, 2, 3]
list2 = list1.copy()
list2.append(4)
print(f"list1 = {list1}") # [1, 2, 3] - unchanged!
print(f"list2 = {list2}") # [1, 2, 3, 4]
print(f"list1 is list2: {list1 is list2}")
print()
# Method 2: list() constructor
list3 = [10, 20, 30]
list4 = list(list3)
list4.append(40)
print(f"list3 = {list3}") # [10, 20, 30]
print(f"list4 = {list4}") # [10, 20, 30, 40]
print()
# Method 3: Slice notation
list5 = [100, 200, 300]
list6 = list5[:]
list6.append(400)
print(f"list5 = {list5}") # [100, 200, 300]
print(f"list6 = {list6}") # [100, 200, 300, 400]Run it:
python list_copy.pyCreate dict_reference.py:
# Dictionaries - same issue!
server1 = {"hostname": "web-01", "port": 8080}
server2 = server1 # Reference, not copy!
server2["port"] = 443
print(f"server1 = {server1}") # Port changed to 443!
print(f"server2 = {server2}")
print()
# Solution: use .copy()
server3 = {"hostname": "db-01", "port": 5432}
server4 = server3.copy()
server4["port"] = 3306
print(f"server3 = {server3}") # Port still 5432
print(f"server4 = {server4}") # Port is 3306Run it:
python dict_reference.pyShallow copy (.copy()) only copies the top level. Nested objects are still references!
Create shallow_copy_problem.py:
# Nested lists - shallow copy isn't enough!
list1 = [[1, 2], [3, 4]]
list2 = list1.copy() # Shallow copy
# Modify a nested list
list2[0].append(999)
print(f"list1 = {list1}") # SURPRISE! list1 changed!
print(f"list2 = {list2}")
# The outer lists are different objects
print(f"list1 is list2: {list1 is list2}") # False
# But the inner lists are the same!
print(f"list1[0] is list2[0]: {list1[0] is list2[0]}") # True!Run it:
python shallow_copy_problem.pyOutput:
list1 = [[1, 2, 999], [3, 4]] # Inner list changed!
list2 = [[1, 2, 999], [3, 4]]
list1 is list2: False
list1[0] is list2[0]: True # Inner lists are references!
Create deep_copy_solution.py:
import copy
# Deep copy copies EVERYTHING recursively
list1 = [[1, 2], [3, 4]]
list2 = copy.deepcopy(list1)
# Modify nested list
list2[0].append(999)
print(f"list1 = {list1}") # Unchanged!
print(f"list2 = {list2}") # Only list2 changed
print(f"list1[0] is list2[0]: {list1[0] is list2[0]}") # False now!
print()
# Works with nested dictionaries too
config1 = {
"database": {
"host": "localhost",
"port": 5432
},
"cache": {
"host": "localhost",
"port": 6379
}
}
config2 = copy.deepcopy(config1)
config2["database"]["port"] = 3306
print(f"config1 port: {config1['database']['port']}") # Still 5432
print(f"config2 port: {config2['database']['port']}") # Changed to 3306Run it:
python deep_copy_solution.pyRule of thumb:
- Simple structures: Use
.copy() - Nested structures: Use
copy.deepcopy()
When you pass an object to a function, the function gets a reference to the original object!
Create function_modify.py:
def add_item(my_list, item):
"""Add item to list"""
my_list.append(item)
print(f"Inside function: {my_list}")
# The function modifies the original list!
numbers = [1, 2, 3]
print(f"Before: {numbers}")
add_item(numbers, 4)
print(f"After: {numbers}") # Changed!Run it:
python function_modify.pyOutput:
Before: [1, 2, 3]
Inside function: [1, 2, 3, 4]
After: [1, 2, 3, 4] # Original list modified!
Create function_safe.py:
def add_item_safe(my_list, item):
"""Add item to a COPY of the list"""
new_list = my_list.copy()
new_list.append(item)
return new_list
numbers = [1, 2, 3]
print(f"Before: {numbers}")
result = add_item_safe(numbers, 4)
print(f"After: {numbers}") # Unchanged
print(f"Result: {result}") # New list with item added
print()
# Alternative: create copy when calling
def add_item(my_list, item):
my_list.append(item)
return my_list
numbers2 = [10, 20, 30]
result2 = add_item(numbers2.copy(), 40) # Pass a copy!
print(f"numbers2: {numbers2}") # Unchanged
print(f"result2: {result2}")Run it:
python function_safe.pyKey insight: This is why many functions return None - they modify in-place!
list.append()returnsNone(modifies in-place)list.sort()returnsNone(modifies in-place)sorted()returns a new list (doesn't modify original)
Your task: Create a server inventory manager
Requirements:
- Store server information in nested dictionaries
- Implement functions that:
- Add a server (without modifying original inventory)
- Remove a server (without modifying original inventory)
- Get unique list of all regions (using sets)
- Update server status (safely)
- Use
copy.deepcopy()where needed - Demonstrate that original inventory isn't modified
Example data structure:
inventory = {
"web-servers": [
{"hostname": "web-01", "region": "us-east", "status": "active"},
{"hostname": "web-02", "region": "us-west", "status": "active"},
],
"db-servers": [
{"hostname": "db-01", "region": "us-east", "status": "active"},
]
}Hints:
- Use
copy.deepcopy()for nested dictionaries - Use sets to collect unique regions
- Test that the original inventory is unchanged
You've completed this lab when you can:
- Explain the difference between mutable and immutable types
- Understand why
list2 = list1creates a reference, not a copy - Use
.copy()for shallow copies - Use
copy.deepcopy()for nested structures - Predict how functions will modify parameters
- Explain why sets require hashable (not just immutable) types
- Write functions that don't accidentally modify collections
What you learned:
- Reference behavior - Assignment creates references for collections
- Shallow vs deep copy -
.copy()vscopy.deepcopy() - Function parameters - Collections are modified in-place
- Hashability - Sets need hashable items (related to, but not the same as immutability)
- Sets = Dict keys - Similar implementation, similar constraints
Why this matters:
- Prevents hard-to-debug mutations in production code
- Essential for writing correct configuration management tools
- Critical for API response handling and data processing
- Avoids race conditions in concurrent code
- Common interview topic and source of real-world bugs
Next steps: Use these skills when working with JSON configs, API responses, and automation scripts.
- Python Data Structures Documentation
- Copy Module Documentation
- Python's Data Model
- Understanding Python's
isvs==
# Creating collections
my_list = [1, 2, 3]
my_dict = {"key": "value"}
my_tuple = (1, 2, 3) # Immutable
my_set = {1, 2, 3} # Unique, unordered
# Copying
shallow = my_list.copy() # Top level only
deep = copy.deepcopy(my_list) # Recursive copy
# Checking identity
list1 is list2 # Same object?
id(list1) # Memory address
# Sets
unique = set([1, 2, 2, 3]) # {1, 2, 3}
frozen = frozenset([1, 2]) # Immutable set
# Hashable check
hash(42) # Works - int is hashable
hash([1, 2]) # Fails - list is not hashableCongratulations! You now understand Python's reference behavior and data structures - knowledge that will save you from countless debugging sessions!