Skip to main content

Mutability, Identity, and Object Semantics

This guide explains how Python compares objects, what makes objects the "same," and how copying works.

== Vs is: equality vs identity​

Python has two ways to compare objects: == checks if values are equal, while is checks if they're the same object.

== checks equality​

The == operator checks if two objects have the same value:

a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True - same values

Even though a and b are different objects in memory, they contain the same values, so == returns True.

is checks identity​

The is operator checks if two names reference the same object:

a = [1, 2, 3]
b = [1, 2, 3]
print(a is b) # False - different objects

c = a
print(a is c) # True - same object

a and b are different list objects (even though they have the same contents), so a is b is False. But c = a makes c reference the same object as a, so a is c is True.

When to use each​

Use == when:

  • Comparing values (numbers, strings, list contents, etc.)
  • Checking if two objects represent the same data
  • Most comparisons in everyday code

Use is when:

  • Checking for None: if x is None:
  • Checking for sentinel values: if result is MISSING:
  • Verifying two names reference the same object
  • Performance-critical code (slightly faster than ==)
# Good: use 'is' for None
if user is None:
print("No user")

# Good: use 'is' for sentinel values
MISSING = object()
if value is MISSING:
print("Value not provided")

# Good: use '==' for value comparison
if user.age == 18:
print("User is 18")
note

Never use == to compare with None. Always use is None or is not None. This is both more Pythonic and slightly faster.

Object identity​

Every object in Python has a unique identity—think of it as the object's memory address. The id() function returns this identity, and is compares identities.

Understanding id()​

a = [1, 2, 3]
b = [1, 2, 3]
c = a

print(id(a)) # 140234567890123 (example)
print(id(b)) # 140234567890456 (different)
print(id(c)) # 140234567890123 (same as a)

print(a is b) # False - different identities
print(a is c) # True - same identity

Two objects with the same value can have different identities. Two names referencing the same object will have the same identity.

Identity persists through mutation​

When you mutate an object, its identity stays the same:

my_list = [1, 2, 3]
original_id = id(my_list)

my_list.append(4)
print(id(my_list)) # Same as original_id
print(my_list is my_list) # True (obviously)

The object's identity doesn't change when you modify it. Only rebinding changes which object a name references.

Identity vs equality summary​

ComparisonWhat it checksExample
a == bDo they have the same value?[1, 2] == [1, 2] → True
a is bAre they the same object?[1, 2] is [1, 2] → False
id(a) == id(b)Same as a is bEquivalent to identity check

Shallow vs deep copies​

Sometimes you want a copy of an object. Python offers two types: shallow copies and deep copies. The difference matters when objects contain other objects.

Shallow copies​

A shallow copy creates a new object, but it doesn't recursively copy nested objects. Instead, it copies references to the nested objects.

import copy

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

print(original == shallow) # True - same values
print(original is shallow) # False - different objects

# But the nested lists are the same objects!
print(original[0] is shallow[0]) # True - same nested list

When you modify a nested object in a shallow copy, the original is also affected:

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

shallow[0].append(5)
print(shallow) # [[1, 2, 5], [3, 4]]
print(original) # [[1, 2, 5], [3, 4]] - also changed!

This happens because shallow[0] and original[0] reference the same list object.

Deep copies​

A deep copy creates a new object and recursively copies all nested objects:

import copy

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

print(original == deep) # True - same values
print(original is deep) # False - different objects
print(original[0] is deep[0]) # False - different nested lists too!

Now modifications to the deep copy don't affect the original:

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

deep[0].append(5)
print(deep) # [[1, 2, 5], [3, 4]]
print(original) # [[1, 2], [3, 4]] - unchanged!

When to use each​

Use shallow copies when:

  • You have a flat structure (no nested mutable objects)
  • You want to share nested objects (sometimes intentional)
  • Performance matters (shallow copies are faster)

Use deep copies when:

  • You have nested mutable objects
  • You need complete independence between copies
  • You're not sure which you need (safer default)

Copy methods for common types​

Some types have built-in copy methods that create shallow copies:

import copy

# Lists
original = [1, 2, 3]
shallow = original.copy() # or original[:] or list(original)
deep = copy.deepcopy(original)

print(original is shallow) # False - different objects
print(original is deep) # False - different objects
print(shallow == deep) # True - same values

# Dictionaries
original = {"a": 1, "b": 2}
shallow = original.copy()
deep = copy.deepcopy(original)

print(original is shallow) # False
print(original is deep) # False
print(shallow == deep) # True

# Sets
original = {1, 2, 3}
shallow = original.copy()
deep = copy.deepcopy(original)

print(original is shallow) # False
print(original is deep) # False
print(shallow == deep) # True

For nested structures, the difference between shallow and deep copies becomes clear:

import copy

# Nested list example
original = [[1, 2], [3, 4]]
shallow = original.copy()
deep = copy.deepcopy(original)

# Modify nested list
shallow[0].append(5)
deep[1].append(6)

print(original) # [[1, 2, 5], [3, 4]] - affected by shallow copy
print(shallow) # [[1, 2, 5], [3, 4]]
print(deep) # [[1, 2], [3, 4, 6]] - independent

For deep copies, always use copy.deepcopy(). The built-in .copy() methods only create shallow copies.

Why small integers appear "cached"​

You might notice something surprising with small integers:

a = 256
b = 256
print(a is b) # True - same object!

c = 257
d = 257
print(c is d) # False - different objects (usually)

This is called interning. CPython (the standard Python implementation) caches small integers (typically -5 to 256) as an optimization. These integers are reused, so multiple assignments create references to the same object.

Don't rely on interning​

Important: This is an implementation detail. Don't write code that depends on it:

# BAD: Don't do this
a = 100
b = 100
if a is b: # Might work, but unreliable
print("Same object")

# GOOD: Use == for value comparison
a = 100
b = 100
if a == b: # Always correct
print("Same value")

The range of interned integers can vary between Python versions and implementations. Always use == for value comparisons, never is for integers (except when checking for None).

Why this optimization exists​

Small integers are used frequently (loop counters, indices, small calculations). By reusing the same objects, Python:

  • Saves memory (no need to create new objects for common values)
  • Speeds up comparisons (identity checks are faster than value checks)
  • Simplifies some internal operations

But this is purely an optimization. Your code should never depend on it.

Why strings are immutable by design​

Strings in Python are immutable—once created, they cannot be changed. This is a deliberate design choice with important benefits.

What immutability means​

text = "Hello"
text[0] = "h" # TypeError: 'str' object does not support item assignment

You cannot modify a string in place. Any operation that appears to change a string actually creates a new string:

text = "Hello"
new_text = text.upper() # Creates a new string
print(text) # "Hello" - original unchanged
print(new_text) # "HELLO" - new object

Benefits of immutable strings​

1. Safety and predictability

Since strings can't be changed, you can safely pass them around without worrying about accidental modifications:

def process_name(name):
# Can't accidentally modify 'name' here
return name.upper()

my_name = "Alice"
result = process_name(my_name)
print(my_name) # "Alice" - guaranteed unchanged

2. Hashability

Immutable objects can be used as dictionary keys and set elements:

# Strings can be keys
user_data = {"alice": 25, "bob": 30}

# Lists cannot (they're mutable)
# invalid = {[1, 2]: "value"} # TypeError: unhashable type: 'list'

3. Memory efficiency (interning)

Like small integers, Python interns some strings (string literals, some computed strings). This allows string identity checks to work for common cases:

a = "hello"
b = "hello"
print(a is b) # True - same object (usually, but don't rely on it!)

But again, this is an implementation detail. Always use == for string comparisons.

4. Thread safety

Immutable objects are inherently thread-safe. Multiple threads can reference the same string without synchronization issues.

Why not make strings mutable?​

If strings were mutable, you'd have to constantly worry about:

  • Accidental modifications when passing strings to functions
  • Thread safety issues
  • Unpredictable behavior when strings are used as keys
  • More complex memory management

Immutability makes strings safer and more predictable, which is worth the small performance cost of creating new strings for modifications.

Working with strings efficiently​

If you need to build a string from many parts, use .join() instead of concatenation:

# Inefficient: creates many intermediate strings
result = ""
for word in words:
result += word # Creates new string each time

# Efficient: builds list, joins once
result = "".join(words) # Single string creation

The .join() method is faster because it builds the final string in one operation, rather than creating many intermediate strings.

Summary​

  • == checks equality (same values), is checks identity (same object)
  • Use is for None and sentinel values, == for everything else
  • Shallow copies share nested objects; deep copies are completely independent
  • Small integers are interned as an optimization—don't rely on it
  • Strings are immutable by design for safety, hashability, and predictability
  • Always use == for value comparisons; is is for identity checks only

Understanding these concepts helps you write more predictable code and avoid subtle bugs related to object identity and mutability.