Every Python value is an object with an identity, a type and a value. Knowing which objects are shared, which can change, and how copies work explains most “spooky action at a distance” bugs.
Identity, type, value
a = [1, 2, 3]
b = a # a second name for the SAME object
c = [1, 2, 3] # a different object with an equal value
print(a == c, a is c) # True False
print(a is b, id(a) == id(b)) # True True
print(type(a) is list) # True| Question | Operator / function | Calls |
|---|---|---|
| Same value? | a == b | a.__eq__(b) — can be customised |
| Same object? | a is b | compares identities — can't be overridden |
| Which object? | id(a) | an integer unique among live objects (the address in CPython) |
| What kind? | type(a), isinstance(a, T) | isinstance also accepts subclasses |
Small-int cache & string interning
CPython pre-creates the integers −5…256 and reuses (interns) many identifier-like strings, so equal values may happen to be the same object. That is an implementation detail — never write code whose result depends on it.
import sys
x = 256
y = int("256") # built at run time, still the cached object
print(x is y) # True
big1 = int("1000")
big2 = int("1000")
print(big1 == big2, big1 is big2) # True False
s1 = "".join(["hello", " ", "world"]) # built at run time: not interned
s2 = sys.intern("hello world")
s3 = sys.intern(s1) # returns the canonical copy
print(s1 == s2, s3 is s2) # True TrueMutable vs immutable
| Immutable — value can never change | Mutable — can change in place |
|---|---|
int, float, complex, bool, str, bytes | list, dict, set, bytearray |
tuple, frozenset, range, None | most user-defined class instances |
“Changing” an immutable object really creates a new object and rebinds the name. Mutating a mutable object is visible through every name that refers to it.
s = "hi"
before = id(s)
s += "!" # new str object, name rebound
print(id(s) == before) # False
nums = [1]
before = id(nums)
nums += [2] # list.__iadd__ mutates in place
print(id(nums) == before) # TrueAliasing through function calls
def add_tax(prices):
for i in range(len(prices)):
prices[i] = round(prices[i] * 1.18, 2) # mutates the caller's list!
cart = [100, 50]
add_tax(cart)
print(cart) # [118.0, 59.0]
def add_tax_safe(prices):
return [round(p * 1.18, 2) for p in prices] # new list, input untouched
cart = [100, 50]
print(add_tax_safe(cart), cart) # [118.0, 59.0] [100, 50]Shallow vs deep copy
| Way | Result |
|---|---|
b = a | No copy — just another name |
a[:], list(a), a.copy(), dict(d), d.copy(), set(s) | Shallow: new outer container, same inner objects |
copy.copy(x) | Shallow copy of any object |
copy.deepcopy(x) | Deep: recursively copies everything reachable |
import copy
config = {"name": "app", "ports": [80, 443]}
shallow = config.copy()
deep = copy.deepcopy(config)
config["name"] = "renamed" # rebinding a key: only config changes
config["ports"].append(8080) # mutating a shared inner list
print(shallow["name"], shallow["ports"]) # app [80, 443, 8080]
print(deep["name"], deep["ports"]) # app [80, 443]deepcopy handles cycles; __copy__ / __deepcopy__
deepcopy keeps a memo dict of objects already copied, so shared references stay shared and cycles don't recurse forever. Classes can customise copying with __copy__ and __deepcopy__(self, memo).
import copy
node = {"name": "a"}
node["self"] = node # a cycle
clone = copy.deepcopy(node)
print(clone["self"] is clone, clone is node) # True False
shared = [0]
pair = [shared, shared]
pair_copy = copy.deepcopy(pair)
print(pair_copy[0] is pair_copy[1]) # True ← sharing preserved
class Session:
def __init__(self, user, cache):
self.user, self.cache = user, cache
def __deepcopy__(self, memo):
# copy the user data, but share the (expensive) cache
return Session(copy.deepcopy(self.user, memo), self.cache)
s = Session({"id": 1}, cache={"big": "data"})
t = copy.deepcopy(s)
print(t.user == s.user, t.user is s.user, t.cache is s.cache) # True False TruePuzzle: a tuple that changes
t = ([1, 2], "x")
try:
t[0] += [3] # list.__iadd__ runs, THEN tuple item assignment fails
except TypeError as e:
print(type(e).__name__) # TypeError
print(t) # ([1, 2, 3], 'x')t[0] += [3] is t[0] = t[0].__iadd__([3]): the list is extended in place first, then storing it back into the tuple raises. The error is real — and so is the mutation.
What is the difference between is and ==?
Why does a is b return True for 256 but False for 1000?
When do you need a deep copy?