Python & AI Tutorials Logo
Python Programming

18. Python's Data and Object Model: References, Comparisons, and Copies

Understanding how Python stores and manages data is crucial for writing correct programs. In this chapter, we'll explore Python's object model—the fundamental system that governs how all data works in Python. You'll learn why some assignments create independent copies while others create shared references, how to compare objects correctly, and how to avoid common pitfalls when working with collections.

This knowledge will help you understand surprising behaviors you may have encountered, such as why modifying one list sometimes affects another, or why comparing two lists with == gives different results than comparing with is.

18.1) Everything Is an Object in Python

In Python, every piece of data is an object. This isn't just a theoretical concept—it has practical implications for how your programs work.

When you create a number, a string, a list, or any other value, Python creates an object in memory. An object is a container that holds:

  • The actual data (the value)
  • Information about what type of data it is (the type)
  • A unique identifier (the identity)

Let's see this in practice:

python
# Creating different types of objects
number = 42
text = "Hello"
items = [1, 2, 3]
 
# Each of these variables refers to an object in memory
print(number)  # Output: 42
print(text)    # Output: Hello
print(items)   # Output: [1, 2, 3]

Even simple values like integers are objects. This means they have capabilities beyond just storing a number:

python
# Integers are objects with methods
number = 42
print(number.bit_length())  # Output: 6
 
# Strings are objects with methods
text = "hello"
print(text.upper())  # Output: HELLO
 
# Lists are objects with methods
items = [3, 1, 2]
items.sort()
print(items)  # Output: [1, 2, 3]

Why does this matter? Because when you assign a variable or pass data to a function, you're not copying the object—you're creating a reference to the same object. This is fundamentally different from how some other programming languages work, and understanding this distinction will prevent many confusing bugs.

python
# Creating a list object
original = [1, 2, 3]
 
# This doesn't create a new list - it creates another reference
# to the SAME list object
another_name = original
 
# Modifying through one reference affects the other
another_name.append(4)
 
print(original)      # Output: [1, 2, 3, 4]
print(another_name)  # Output: [1, 2, 3, 4]

Both original and another_name refer to the same list object in memory. When we modify the list through another_name, we see the change through original because they're both looking at the same object.

Variable: original

List Object: 1, 2, 3, 4

Variable: another_name

This behavior is called reference semantics, and it's one of the most important concepts in Python programming. We'll explore it in depth throughout this chapter.

18.2) Identity, Type, and Value of Objects

Every object in Python has three fundamental characteristics that define it: identity, type, and value. Understanding these characteristics helps you reason about how objects behave and how to compare them correctly.

18.2.1) Object Identity with id()

An object's identity is a unique number that Python assigns when the object is created. This identity never changes during the object's lifetime—it's like a permanent address in memory.

You can retrieve an object's identity using the id() function:

python
# Creating objects and checking their identities
x = [1, 2, 3]
y = [1, 2, 3]
z = x
 
print(id(x))  # Output: 140234567890123 (example - actual number varies)
print(id(y))  # Output: 140234567890456 (different from x)
print(id(z))  # Output: 140234567890123 (same as x)

The actual numbers you see will be different each time you run the program, but the pattern remains: x and y have different identities because they're different objects, even though they contain the same values. Meanwhile, z has the same identity as x because z is just another name for the same object.

Here's a practical example showing why identity matters:

python
# Two students with the same grades
student1_grades = [85, 90, 92]
student2_grades = [85, 90, 92]
 
# These are different objects (different identities)
print(id(student1_grades))  # Output: 140234567890123 (example)
print(id(student2_grades))  # Output: 140234567890456 (different)
 
# Modifying one doesn't affect the other
student1_grades.append(88)
print(student1_grades)  # Output: [85, 90, 92, 88]
print(student2_grades)  # Output: [85, 90, 92]

Now consider a different scenario:

python
# One student's grades tracked by two variables
original_grades = [85, 90, 92]
backup_reference = original_grades
 
# These refer to the SAME object (same identity)
print(id(original_grades))    # Output: 140234567890123 (example)
print(id(backup_reference))   # Output: 140234567890123 (same!)
 
# Modifying through either name affects both
backup_reference.append(88)
print(original_grades)     # Output: [85, 90, 92, 88]
print(backup_reference)    # Output: [85, 90, 92, 88]

Key insight: When two variables have the same identity, they refer to the exact same object in memory. Changes made through one variable are visible through the other because there's only one object being modified.

18.2.2) Object Type with type()

An object's type determines what kind of data it holds and what operations you can perform on it. As we learned in Chapter 3, you can check an object's type using the type() function:

python
# Different types of objects
number = 42
text = "Hello"
items = [1, 2, 3]
mapping = {"name": "Alice"}
 
print(type(number))   # Output: <class 'int'>
print(type(text))     # Output: <class 'str'>
print(type(items))    # Output: <class 'list'>
print(type(mapping))  # Output: <class 'dict'>

The type of an object never changes after creation. You can't turn an integer into a string—you can only create a new string object based on the integer's value:

python
# The type is fixed at creation
x = 42
print(type(x))  # Output: <class 'int'>
 
# This doesn't change x's type - it creates a NEW string object
# and makes x reference that new object instead
x = str(x)
# The original integer object (42) still exists in memory until garbage collected
# x now points to a completely different object: the string "42"
 
print(type(x))  # Output: <class 'str'>
print(x)        # Output: 42 (now a string, not an integer)

Understanding types is crucial because different types support different operations:

python
# Lists support append
grades = [85, 90]
grades.append(92)
print(grades)  # Output: [85, 90, 92]
 
# Strings don't have append - they're immutable
text = "Hello"
# text.append(" World")  # AttributeError: 'str' object has no attribute 'append'
 
# But strings support concatenation
text = text + " World"
print(text)  # Output: Hello World

18.2.3) Object Value

An object's value is the actual data it contains. Unlike identity and type, the value can change for mutable objects (like lists and dictionaries) but cannot change for immutable objects (like integers and strings).

python
# For mutable objects, the value can change
shopping_cart = ["milk", "bread"]
print(shopping_cart)  # Output: ['milk', 'bread']
 
shopping_cart.append("eggs")
print(shopping_cart)  # Output: ['milk', 'bread', 'eggs']
# Same object (same identity), different value
 
# For immutable objects, the value cannot change
count = 5
print(count)  # Output: 5
 
count = count + 1
print(count)  # Output: 6
# This created a NEW object with a new identity

Here's a complete example showing all three characteristics:

python
# Creating a list object
data = [10, 20, 30]
 
print("Identity:", id(data))      # Output: Identity: 140234567890123 (example)
print("Type:", type(data))        # Output: Type: <class 'list'>
print("Value:", data)             # Output: Value: [10, 20, 30]
 
# Modifying the value (identity and type stay the same)
data.append(40)
 
print("Identity:", id(data))      # Output: Identity: 140234567890123 (unchanged)
print("Type:", type(data))        # Output: Type: <class 'list'> (unchanged)
print("Value:", data)             # Output: Value: [10, 20, 30, 40] (changed)

Object

Identity: Unique ID

Type: class 'list'

Value: 10, 20, 30, 40

Never changes

Never changes

Can change for mutable types

Understanding these three characteristics helps you predict how objects will behave in your programs. The identity tells you whether two variables refer to the same object, the type tells you what operations are allowed, and the value tells you what data the object currently holds.

18.3) Mutable and Immutable Types

One of the most important distinctions in Python is between mutable and immutable types. This distinction affects how objects behave when you try to change them, and understanding it prevents many common programming errors.

18.3.1) Immutable Types: Values That Cannot Change

An immutable object is one whose value cannot be changed after creation. When you perform an operation that seems to modify an immutable object, Python actually creates a new object with the modified value.

Python's immutable types include:

  • Integers (int)
  • Floating-point numbers (float)
  • Strings (str)
  • Tuples (tuple)
  • Booleans (bool)
  • None (NoneType)

Let's see immutability in action with integers:

python
# Creating an integer
x = 100
print("Original x:", x)           # Output: Original x: 100
print("Identity of x:", id(x))    # Output: Identity of x: 140234567890123 (example)
 
# This looks like we're modifying x, but we're actually creating a new object
x = x + 1
print("Modified x:", x)           # Output: Modified x: 101
print("Identity of x:", id(x))    # Output: Identity of x: 140234567890456 (different!)

The identity changed because x = x + 1 created a completely new integer object with the value 101. The original object with value 100 still exists (until Python's garbage collector removes it), but x now refers to a different object.

Strings demonstrate immutability even more clearly:

python
# Creating a string
message = "Hello"
print("Original:", message)        # Output: Original: Hello
print("Identity:", id(message))    # Output: Identity: 140234567890789 (example)
 
# String methods don't modify the original - they return new strings
uppercase = message.upper()
print("Original:", message)        # Output: Original: Hello (unchanged)
print("Uppercase:", uppercase)     # Output: Uppercase: HELLO
print("Identity of original:", id(message))    # Output: Identity of original: 140234567890789 (same)
print("Identity of uppercase:", id(uppercase)) # Output: Identity of uppercase: 140234567891012 (different)

Even operations that look like they're modifying a string actually create new string objects:

python
# Building a string with concatenation
text = "Python"
print("Before:", text, "- ID:", id(text))  # Output: Before: Python - ID: 140234567891234 (example)
 
text = text + " Programming"
print("After:", text, "- ID:", id(text))   # Output: After: Python Programming - ID: 140234567891567 (different)

Why immutability matters: Immutable objects are safe to share between different parts of your program because no part can accidentally modify them. This makes your code more predictable and easier to reason about.

18.3.2) Mutable Types: Values That Can Change

A mutable object is one whose value can be changed after creation without creating a new object. The object's identity remains the same, but its contents can be modified.

Python's mutable types include:

  • Lists (list)
  • Dictionaries (dict)
  • Sets (set)

Let's see mutability with lists:

python
# Creating a list
numbers = [1, 2, 3]
print("Original:", numbers)        # Output: Original: [1, 2, 3]
print("Identity:", id(numbers))    # Output: Identity: 140234567892345 (example)
 
# Modifying the list - same object, different value
numbers.append(4)
print("Modified:", numbers)        # Output: Modified: [1, 2, 3, 4]
print("Identity:", id(numbers))    # Output: Identity: 140234567892345 (same!)

The identity didn't change because we modified the existing list object rather than creating a new one. This is fundamentally different from how immutable types work.

Dictionaries and sets are also mutable:

python
# Dictionary example
student = {"name": "Alice", "grade": 85}
print("Before:", student, "- ID:", id(student))  # Output: Before: {'name': 'Alice', 'grade': 85} - ID: 140234567893012 (example)
 
student["grade"] = 90  # Modifying the dictionary
print("After:", student, "- ID:", id(student))   # Output: After: {'name': 'Alice', 'grade': 90} - ID: 140234567893012 (same)
 
# Set example
unique_numbers = {1, 2, 3}
print("Before:", unique_numbers, "- ID:", id(unique_numbers))  # Output: Before: {1, 2, 3} - ID: 140234567893345 (example)
 
unique_numbers.add(4)  # Modifying the set
print("After:", unique_numbers, "- ID:", id(unique_numbers))   # Output: After: {1, 2, 3, 4} - ID: 140234567893345 (same)

18.3.3) Why Mutability Matters in Practice

The difference between mutable and immutable types becomes critical when multiple variables refer to the same object:

python
# Immutable example - safe sharing
x = "Hello"
y = x  # y refers to the same string object
 
# "Modifying" x creates a new object
x = x + " World"
 
print(x)  # Output: Hello World
print(y)  # Output: Hello (unchanged - y still refers to the original)
python
# Mutable example - shared modifications
list1 = [1, 2, 3]
list2 = list1  # list2 refers to the SAME list object
 
# Modifying through list1 affects list2
list1.append(4)
 
print(list1)  # Output: [1, 2, 3, 4]
print(list2)  # Output: [1, 2, 3, 4] (also changed!)

Immutable Types

int, float, str, tuple, bool, None

Value cannot change

Operations create new objects

Safe to share

Mutable Types

list, dict, set

Value can change

Operations modify existing object

Sharing requires caution

Understanding mutability is essential for:

  1. Predicting behavior: Knowing whether an operation creates a new object or modifies an existing one
  2. Avoiding bugs: Preventing unintended modifications when objects are shared
  3. Writing efficient code: Choosing the right type for your use case
  4. Understanding function behavior: Knowing when function parameters can be modified

In the next sections, we'll explore how assignment works with these different types and how to create independent copies when needed.

18.4) How Assignment Works with Objects

Assignment in Python doesn't copy objects—it creates references to objects. Understanding this distinction is crucial for writing correct programs, especially when working with mutable types.

18.4.1) Assignment Creates References, Not Copies

When you write x = y, Python doesn't create a copy of the object that y refers to. Instead, it makes x refer to the same object that y refers to. Both variables become names for the same object in memory.

Let's see this with immutable objects first:

python
# Assignment with integers (immutable)
a = 100
b = a  # b now refers to the same integer object as a
 
print("a:", a)           # Output: a: 100
print("b:", b)           # Output: b: 100
print("Same object?", id(a) == id(b))  # Output: Same object? True
 
# "Modifying" a creates a new object
a = a + 1
 
print("a:", a)           # Output: a: 101
print("b:", b)           # Output: b: 100 (unchanged)
print("Same object?", id(a) == id(b))  # Output: Same object? False

With immutable objects, this behavior is usually safe because you can't modify the original object. When you perform an operation that changes the value, Python creates a new object.

However, with mutable objects, the behavior is very different:

python
# Assignment with lists (mutable)
list1 = [1, 2, 3]
list2 = list1  # list2 refers to the SAME list object as list1
 
print("list1:", list1)   # Output: list1: [1, 2, 3]
print("list2:", list2)   # Output: list2: [1, 2, 3]
print("Same object?", id(list1) == id(list2))  # Output: Same object? True
 
# Modifying through list1 affects list2
list1.append(4)
 
print("list1:", list1)   # Output: list1: [1, 2, 3, 4]
print("list2:", list2)   # Output: list2: [1, 2, 3, 4] (also changed!)
print("Same object?", id(list1) == id(list2))  # Output: Same object? True

Both list1 and list2 are names for the same list object. When you modify the list through either name, you see the change through both names because there's only one list.

Assignment with Immutable Types

Both variables refer to same object initially

Operations create new objects

Variables become independent

Assignment with Mutable Types

Both variables refer to same object

Operations modify shared object

Changes visible through both variables

Here's a practical example that shows why this matters:

python
# Managing student grades
alice_grades = [85, 90, 92]
backup_grades = alice_grades  # Trying to create a backup
 
print("Original:", alice_grades)  # Output: Original: [85, 90, 92]
print("Backup:", backup_grades)   # Output: Backup: [85, 90, 92]
 
# Adding a new grade
alice_grades.append(88)
 
# The "backup" was also modified!
print("Original:", alice_grades)  # Output: Original: [85, 90, 92, 88]
print("Backup:", backup_grades)   # Output: Backup: [85, 90, 92, 88]

This isn't a backup at all—both variables refer to the same list. To create a true backup, you need to create a copy (which we'll cover in Section 18.8).

18.4.2) Assignment in Function Calls

When you pass an argument to a function, Python uses the same reference semantics. The parameter becomes another name for the same object:

python
# Function with immutable parameter
def increment(number):
    number = number + 1  # Creates a new object
    return number
 
value = 5
result = increment(value)
 
print("Original value:", value)    # Output: Original value: 5 (unchanged)
print("Returned result:", result)  # Output: Returned result: 6

The parameter number initially refers to the same integer object as value. When we do number = number + 1, we create a new integer object and make number refer to it. The original object (and value) remain unchanged.

With mutable objects, the behavior is different:

python
# Function with mutable parameter
def add_item(items, new_item):
    items.append(new_item)  # Modifies the original list
 
shopping_list = ["milk", "bread"]
add_item(shopping_list, "eggs")
 
print("Original list:", shopping_list)  # Output: Original list: ['milk', 'bread', 'eggs']

The parameter items refers to the same list object as shopping_list. When we modify the list through items, we're modifying the original list.

Here's a common mistake and how to avoid it:

python
# MISTAKE: Unintentionally modifying the original
def process_grades(grades):
    grades.append(100)  # Modifies the original!
    return grades
 
student_grades = [85, 90, 92]
processed = process_grades(student_grades)
 
print("Original:", student_grades)  # Output: Original: [85, 90, 92, 100] (modified!)
print("Processed:", processed)      # Output: Processed: [85, 90, 92, 100]
 
# CORRECT: Create a copy if you don't want to modify the original
def process_grades_safely(grades):
    # Create a new list with the same elements
    result = grades + [100]  # Concatenation creates a new list
    return result
 
student_grades = [85, 90, 92]
processed = process_grades_safely(student_grades)
 
print("Original:", student_grades)  # Output: Original: [85, 90, 92] (unchanged)
print("Processed:", processed)      # Output: Processed: [85, 90, 92, 100]

Important note about mutable default arguments: A related common pitfall involves using mutable objects as default parameter values (like def func(items=[]):). Default parameters are created once when the function is defined, not each time it's called, which can lead to unexpected behavior where the default list accumulates values across multiple function calls. We'll explore this in detail in Chapter 20, but be aware that this is a frequent source of bugs when working with mutable parameters.

18.5) Reference Semantics and Object Aliasing

Reference semantics means that variables in Python are names that refer to objects, not containers that hold values. When multiple variables refer to the same object, we call this aliasing. Understanding aliasing is essential for predicting how your programs behave.

18.5.1) What Is Aliasing?

Aliasing occurs when two or more variables refer to the same object in memory. The variables are "aliases" for each other—different names for the same thing.

Let's see aliasing with a simple example:

python
# Creating a list and an alias
original = [1, 2, 3]
alias = original  # alias refers to the same list as original
 
print("Original:", original)  # Output: Original: [1, 2, 3]
print("Alias:", alias)        # Output: Alias: [1, 2, 3]
print("Same object?", id(original) == id(alias))  # Output: Same object? True
 
# Modifying through the alias
alias.append(4)
 
# The change is visible through both names
print("Original:", original)  # Output: Original: [1, 2, 3, 4]
print("Alias:", alias)        # Output: Alias: [1, 2, 3, 4]

There's only one list object in memory, but it has two names: original and alias. Any modification made through either name affects the same underlying object.

Here's a more realistic example with student records:

python
# Student database with aliasing
students = {
    "alice": {"name": "Alice", "grade": 85},
    "bob": {"name": "Bob", "grade": 90}
}
 
# Creating an alias to Alice's record
alice_record = students["alice"]
 
print("Alice's grade:", alice_record["grade"])  # Output: Alice's grade: 85
 
# Modifying through the alias
alice_record["grade"] = 95
 
# The change is visible in the original dictionary
print("Updated grade:", students["alice"]["grade"])  # Output: Updated grade: 95

The variable alice_record is an alias for the dictionary stored at students["alice"]. When we modify alice_record, we're modifying the same dictionary that's stored in the students dictionary.

18.5.2) Detecting Aliasing with the is Operator

You can check whether two variables are aliases (refer to the same object) using the is operator:

python
# Checking for aliasing
list1 = [1, 2, 3]
list2 = list1      # Alias
list3 = [1, 2, 3]  # Different object with same value
 
print("list1 is list2:", list1 is list2)  # Output: list1 is list2: True (aliases)
print("list1 is list3:", list1 is list3)  # Output: list1 is list3: False (different objects)
print("list1 == list3:", list1 == list3)  # Output: list1 == list3: True (same value)

The is operator checks identity (whether two variables refer to the same object), while == checks value (whether two objects have the same contents). We'll explore this distinction in detail in Section 18.6.

18.5.3) Aliasing in Collections

Aliasing becomes more complex when objects are stored in collections:

python
# Creating a list of lists
row = [0, 0, 0]
grid = [row, row, row]  # All three elements are aliases for the same list!
 
print("Grid:")
for r in grid:
    print(r)
# Output:
# [0, 0, 0]
# [0, 0, 0]
# [0, 0, 0]
 
# Modifying one element affects all rows
grid[0][0] = 1
 
print("\nAfter modification:")
for r in grid:
    print(r)
# Output:
# [1, 0, 0]
# [1, 0, 0]
# [1, 0, 0]

This is a common mistake when trying to create a 2D grid. All three rows are aliases for the same list, so modifying one row modifies all of them.

The correct way to create independent rows:

python
# Creating independent rows
grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]  # Each row is a separate list
 
print("Grid:")
for r in grid:
    print(r)
# Output:
# [0, 0, 0]
# [0, 0, 0]
# [0, 0, 0]
 
# Now modifying one element only affects that row
grid[0][0] = 1
 
print("\nAfter modification:")
for r in grid:
    print(r)
# Output:
# [1, 0, 0]
# [0, 0, 0]
# [0, 0, 0]

18.6) Equality, Identity, and Membership (==, is, and in) Across Types

Python provides three fundamental operators for comparing and checking relationships between objects: == for equality, is for identity, and in for membership. Understanding when to use each operator is crucial for writing correct programs.

18.6.1) Equality with == (Comparing Values)

The == operator checks whether two objects have the same value. It doesn't matter whether they're the same object in memory—only whether their contents are equal.

python
# Comparing values with ==
list1 = [1, 2, 3]
list2 = [1, 2, 3]
list3 = list1
 
print(list1 == list2)  # Output: True (same values)
print(list1 == list3)  # Output: True (same values)

Even though list1 and list2 are different objects in memory, they have the same value, so == returns True.

Here's how == works with different types:

python
# Equality across different types
print(42 == 42)              # Output: True (same integer value)
print(42 == 42.0)            # Output: True (integer equals float with same value)
print("hello" == "hello")    # Output: True (same string value)
print([1, 2] == [1, 2])      # Output: True (same list contents)
print({"a": 1} == {"a": 1})  # Output: True (same dictionary contents)
 
# Different values
print(42 == 43)              # Output: False
print("hello" == "Hello")    # Output: False (case-sensitive)
print([1, 2] == [2, 1])      # Output: False (order matters)

For collections, == performs a deep comparison—it checks whether all elements are equal:

python
# Deep comparison with nested structures
list1 = [[1, 2], [3, 4]]
list2 = [[1, 2], [3, 4]]
 
print(list1 == list2)  # Output: True (all nested elements are equal)
 
# Even if the inner lists are different objects
print(id(list1[0]) == id(list2[0]))  # Output: False (different objects)
print(list1[0] == list2[0])          # Output: True (same values)

18.6.2) Identity with is (Comparing Object Identity)

The is operator checks whether two variables refer to the same object in memory. It compares identities, not values.

python
# Comparing identities with is
list1 = [1, 2, 3]
list2 = [1, 2, 3]
list3 = list1
 
print(list1 is list2)  # Output: False (different objects)
print(list1 is list3)  # Output: True (same object)
 
# Confirming with id()
print(id(list1) == id(list2))  # Output: False
print(id(list1) == id(list3))  # Output: True

When to use is: The most common use of is is to check for None:

python
# Checking for None (the correct way)
def find_student(name, students):
    """Return student record or None if not found."""
    for student in students:
        if student["name"] == name:
            return student
    return None
 
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 90}
]
 
result = find_student("Charlie", students)
 
# Use 'is' to check for None
if result is None:
    print("Student not found")  # Output: Student not found
else:
    print(f"Found: {result}")

18.6.3) Membership with in (Checking Containment)

The in operator checks whether a value is contained in a collection. It works with strings, lists, tuples, sets, and dictionaries:

python
# Membership in different types
print(2 in [1, 2, 3])           # Output: True
print("hello" in "hello world")  # Output: True
print("x" in {"x": 10, "y": 20}) # Output: True (checks keys)
print(5 in {1, 2, 3, 4, 5})     # Output: True

For dictionaries, in checks whether a key exists:

python
# Checking dictionary membership
student = {"name": "Alice", "grade": 85, "age": 20}
 
print("name" in student)    # Output: True (key exists)
print("Alice" in student)   # Output: False (value, not key)
print("grade" in student)   # Output: True (key exists)
 
# Checking for values requires accessing .values()
print("Alice" in student.values())  # Output: True

The not in operator checks for absence:

python
# Checking for absence
shopping_list = ["milk", "bread", "eggs"]
 
if "butter" not in shopping_list:
    print("Don't forget to buy butter!")  # Output: Don't forget to buy butter!

Summary of when to use each operator:

  • Use == when you want to check if two objects have the same value
  • Use is when you want to check if two variables refer to the same object (most commonly with None, or when debugging aliasing)
  • Use in when you want to check if a value is contained in a collection

Understanding these distinctions helps you write more precise and correct comparisons in your programs.

18.7) Comparing Objects That Contain Other Objects

When objects contain other objects (like lists within lists, or dictionaries containing lists), comparisons become more nuanced. Understanding how Python compares nested structures is essential for working with complex data.

18.7.1) How == Works with Nested Structures

The == operator performs a recursive comparison for nested structures. It compares not just the outer container, but all nested objects as well:

python
# Comparing nested lists
list1 = [[1, 2], [3, 4]]
list2 = [[1, 2], [3, 4]]
 
print(list1 == list2)  # Output: True
 
# Even though the inner lists are different objects
print(id(list1[0]) == id(list2[0]))  # Output: False
print(list1[0] == list2[0])          # Output: True

Python recursively compares each element. For list1 == list2 to be True, every corresponding element must be equal, including nested elements.

Here's a more complex example:

python
# Nested structure with multiple levels
data1 = {
    "students": [
        {"name": "Alice", "grades": [85, 90, 92]},
        {"name": "Bob", "grades": [88, 91, 87]}
    ],
    "class": "Python 101"
}
 
data2 = {
    "students": [
        {"name": "Alice", "grades": [85, 90, 92]},
        {"name": "Bob", "grades": [88, 91, 87]}
    ],
    "class": "Python 101"
}
 
print(data1 == data2)  # Output: True

Python compares:

  1. Dictionary keys and values at the top level ("students" and "class")
  2. The list of students
  3. Each student dictionary (with "name" and "grades" keys)
  4. The grades list for each student
  5. Each individual grade number

All levels must match for the comparison to return True.

18.7.2) Order Matters for Sequences

For sequences (lists and tuples), the order of elements matters:

python
# Order matters in lists
list1 = [[1, 2], [3, 4]]
list2 = [[3, 4], [1, 2]]
 
print(list1 == list2)  # Output: False (different order)
 
# But order doesn't matter for sets
set1 = {frozenset([1, 2]), frozenset([3, 4])}
set2 = {frozenset([3, 4]), frozenset([1, 2])}
 
print(set1 == set2)  # Output: True (sets are unordered)

18.7.3) Comparing Collections of Different Types

Different collection types (list, tuple, set) are never equal to each other, even if they contain the same elements:

python
# Comparing different types
print([1, 2, 3] == (1, 2, 3))  # Output: False (list vs tuple)
print([1, 2, 3] == {1, 2, 3})  # Output: False (list vs set)
 
# Even with the same elements
list_version = [1, 2, 3]
tuple_version = (1, 2, 3)
set_version = {1, 2, 3}
 
print(list_version == tuple_version)  # Output: False
print(list_version == set_version)    # Output: False
print(tuple_version == set_version)   # Output: False

18.8) Shallow Copies of Lists, Dicts

When working with mutable objects, you often need to create independent copies to avoid unintended modifications. For example, when backing up data before processing it, creating test scenarios without affecting production data, or passing data to functions that shouldn't modify the original. Understanding how Python's copying mechanisms work helps you create truly independent copies when needed.

However, not all copying methods create completely independent copies. Understanding the difference between shallow copies and deep copies is crucial for avoiding subtle bugs.

18.8.1) What Is a Shallow Copy?

A shallow copy creates a new object, but doesn't create copies of the objects contained within it. Instead, the new object contains references to the same nested objects as the original.

Let's see this with a simple list:

python
# Creating a shallow copy of a simple list
original = [1, 2, 3]
copy = original.copy()  # Creates a shallow copy
 
print("Original:", original)  # Output: Original: [1, 2, 3]
print("Copy:", copy)          # Output: Copy: [1, 2, 3]
 
# They're different objects
print("Same object?", original is copy)  # Output: Same object? False
 
# Modifying the copy doesn't affect the original
copy.append(4)
 
print("Original:", original)  # Output: Original: [1, 2, 3]
print("Copy:", copy)          # Output: Copy: [1, 2, 3, 4]

For simple lists containing immutable objects (like integers), a shallow copy works perfectly. The copy is independent of the original.

But what happens with nested structures? Let's see where shallow copies show their limitations:

python
# Shallow copy with nested lists
original = [[1, 2], [3, 4]]
copy = original.copy()
 
print("Original:", original)  # Output: Original: [[1, 2], [3, 4]]
print("Copy:", copy)          # Output: Copy: [[1, 2], [3, 4]]
 
# The outer lists are different objects
print("Same outer list?", original is copy)  # Output: Same outer list? False
 
# But the nested lists are the SAME objects
print("Same nested list?", original[0] is copy[0])  # Output: Same nested list? True
 
# Modifying a nested list affects both
copy[0].append(99)
 
print("Original:", original)  # Output: Original: [[1, 2, 99], [3, 4]]
print("Copy:", copy)          # Output: Copy: [[1, 2, 99], [3, 4]]

Original List

Nested List 1: 1, 2, 99

Nested List 2: 3, 4

Shallow Copy

18.8.2) Creating Shallow Copies of Lists

There are several ways to create a shallow copy of a list:

python
# Method 1: Using the copy() method
original = [[1, 2], [3, 4]]
copy1 = original.copy()
 
# Method 2: Using list slicing
copy2 = original[:]
 
# Method 3: Using the list() constructor
copy3 = list(original)
 
# All three create shallow copies
print(copy1)  # Output: [[1, 2], [3, 4]]
print(copy2)  # Output: [[1, 2], [3, 4]]
print(copy3)  # Output: [[1, 2], [3, 4]]
 
# Outer list is different
print(original is copy1)  # Output: False
print(original is copy2)  # Output: False
print(original is copy3)  # Output: False
 
# But inner lists are SHARED
print(original[0] is copy1[0])  # Output: True
print(original[0] is copy2[0])  # Output: True
print(original[0] is copy3[0])  # Output: True

18.8.3) Creating Shallow Copies of Dictionaries

Dictionaries also support shallow copying:

python
# Method 1: Using the copy() method
original = {"name": "Alice", "grade": 85}
copy1 = original.copy()
 
# Method 2: Using the dict() constructor
copy2 = dict(original)
 
# Both create shallow copies
print(copy1)  # Output: {'name': 'Alice', 'grade': 85}
print(copy2)  # Output: {'name': 'Alice', 'grade': 85}
 
# They're different objects
print(original is copy1)  # Output: False
print(original is copy2)  # Output: False
 
# Modifying the copy doesn't affect the original
copy1["grade"] = 90
 
print("Original:", original)  # Output: Original: {'name': 'Alice', 'grade': 85}
print("Copy:", copy1)         # Output: Copy: {'name': 'Alice', 'grade': 90}

However, with nested structures, the same shallow copy limitation applies:

python
# Shallow copy with nested dictionary
original = {
    "name": "Alice",
    "grades": [85, 90, 92]
}
 
copy = original.copy()
 
print("Original:", original)  # Output: Original: {'name': 'Alice', 'grades': [85, 90, 92]}
print("Copy:", copy)          # Output: Copy: {'name': 'Alice', 'grades': [85, 90, 92]}
 
# The dictionaries are different objects
print("Same dict?", original is copy)  # Output: Same dict? False
 
# But the grades list is the SAME object
print("Same grades list?", original["grades"] is copy["grades"])  # Output: Same grades list? True
 
# Modifying the grades list affects both
copy["grades"].append(88)
 
print("Original:", original)  # Output: Original: {'name': 'Alice', 'grades': [85, 90, 92, 88]}
print("Copy:", copy)          # Output: Copy: {'name': 'Alice', 'grades': [85, 90, 92, 88]}
© 2025. Primesoft Co., Ltd.
support@primesoft.ai