What you'll learn
- Distinguish names from objects, and see why assignment never copies
- Explain mutability in terms of objects, and predict what shared references will do
- Copy correctly with shallow and deep copies, and know which one you need
- Understand reference counting, garbage collection,
__slots__, and how to measure object size
Concept
In Python, a variable is a name bound to an object; it is not a box that holds a value. a = [1, 2] creates a list object and binds the name a to it. b = a binds a second name to the same object; nothing is copied. This is why b.append(3) shows up through a: there is one list with two names. Every "surprising" behavior around mutation, defaults, and copying follows from this one fact, and the variables lesson introduced it with the label-and-box picture. Now you have the tools to see it directly: id() returns an object's identity, and is compares identities.

Mutability is a property of the object, not the name. Lists, dicts, sets, and most instances can be changed in place; ints, floats, strings, tuples, and frozensets cannot. Rebinding a name, x = x + 1, always creates or finds a new object; mutating, x.append(1), changes the object every name bound to it sees. That is why an immutable default argument is safe and a mutable one is a bug, why tuples can be dictionary keys, and why a function that receives a list can change the caller's list but a function that receives an int cannot change the caller's int.
| Operation | Creates a new object? | Affects other names bound to the old one? |
|---|---|---|
b = a |
no | (they are the same object) |
a = a + [4] |
yes | no |
a += [4] on a list |
no (in place) | yes |
a += 1 on an int |
yes (ints are immutable) | no |
a.append(4) |
no | yes |
b = a.copy() / a[:] |
yes (shallow) | inner objects still shared |
b = copy.deepcopy(a) |
yes (deep) | nothing shared |
Copying has two depths. A shallow copy, list(a), a[:], a.copy(), dict(d), creates a new outer container whose items are the same objects as before, so nested lists are still shared. copy.deepcopy recursively copies everything, so nothing is shared, at the cost of time and memory. Most of the time a shallow copy is what you want, because the items are immutable or you will not mutate them; reach for deepcopy when you have nested mutable data and will change the inner parts.
Objects live as long as something refers to them. CPython counts references: each name, container slot, or attribute pointing at an object adds one, and when the count reaches zero the object is freed immediately. Reference cycles, two objects that point at each other, cannot reach zero on their own, so a cyclic garbage collector runs periodically to find and free them; gc exposes it, and you rarely need to touch it. sys.getrefcount and sys.getsizeof let you see counts and sizes. For classes with many instances, __slots__ replaces the per-instance __dict__ with fixed slots, cutting memory per object substantially and preventing accidental new attributes; dataclasses accept slots=True for the same effect.

Code
Names and objects made visible with id and is. Rebinding gives a new identity; mutating keeps it and is seen through every name.
# names_objects.py
a = [1, 2]
b = a
print(a is b, id(a) == id(b))
b.append(3)
print(a, "<- a changed through b")
a = a + [4]
print(a is b, a, b)
x = 10
y = x
x += 1
print(x, y, x is y)
s = "hi"
t = s
s += "!"
print(s, t)
True True
[1, 2, 3] <- a changed through b
False [1, 2, 3, 4] [1, 2, 3]
11 10 False
hi! hiShallow versus deep copies on nested data, and the function-argument consequence: the list is changed for the caller, the int is not.
# copies.py
import copy
config = {"name": "app", "tags": ["a", "b"], "limits": {"cpu": 2}}
shallow = dict(config)
deep = copy.deepcopy(config)
shallow["name"] = "other"
shallow["tags"].append("c")
deep["limits"]["cpu"] = 8
print(config["name"], config["tags"], config["limits"])
print(shallow["name"], shallow["tags"] is config["tags"], deep["tags"] is config["tags"])
def add_item(items, count):
items.append("x")
count += 1
return count
things, n = [], 0
result = add_item(things, n)
print(things, n, result)
app ['a', 'b', 'c'] {'cpu': 2}
other True False
['x'] 0 1Reference counts, immediate freeing, and a cycle that needs the collector. weakref lets you observe an object being freed without keeping it alive.
# lifetimes.py
import gc
import sys
import weakref
class Node:
def __init__(self, name):
self.name = name
self.other = None
a = Node("a")
print("refs to a (including the call's own):", sys.getrefcount(a))
holder = [a]
print("after adding to a list:", sys.getrefcount(a))
alive = weakref.ref(a)
del holder
del a
print("freed as soon as the last reference went:", alive() is None)
x, y = Node("x"), Node("y")
x.other, y.other = y, x # a cycle
watch = weakref.ref(x)
del x, y
print("cycle still alive after del:", watch() is not None)
gc.collect()
print("collected by the cyclic GC:", watch() is None)
refs to a (including the call's own): 2
after adding to a list: 3
freed as soon as the last reference went: True
cycle still alive after del: True
collected by the cyclic GC: True__slots__ and object size. The slotted class is smaller per instance and refuses attributes it did not declare; a dataclass gets the same with slots=True.
# slots.py
import sys
from dataclasses import dataclass
class Plain:
def __init__(self, x, y):
self.x, self.y = x, y
class Slotted:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
@dataclass(slots=True)
class Point:
x: float
y: float
p, s = Plain(1, 2), Slotted(1, 2)
plain_size = sys.getsizeof(p) + sys.getsizeof(p.__dict__)
print("slotted smaller than plain:", sys.getsizeof(s) < plain_size)
print("dataclass with slots has no __dict__:", not hasattr(Point(1.0, 2.0), "__dict__"))
p.z = 3
print("plain accepts new attributes:", p.z)
s.z = 3
slotted smaller than plain: True
dataclass with slots has no __dict__: True
plain accepts new attributes: 3
Traceback (most recent call last):
File "slots.py", line 29, in <module>
s.z = 3
^^^
AttributeError: 'Slotted' object has no attribute 'z' and no __dict__ for setting new attributesTry it yourself
Create memory.py and, for each statement, predict the output before running: bind two names to one list and mutate through one; rebind one with + and mutate again; shallow-copy a dict of lists and change an inner list; deep-copy and change again. Then create 100,000 instances of a plain class and of a __slots__ class and compare total memory with sys.getsizeof summed over instances and their __dict__s. Finally build a two-object cycle, watch it with weakref, and confirm gc.collect() frees it.
Common mistakes
- Thinking
=copies. It binds another name; use.copy(),[:],dict(), ordeepcopywhen you need a separate object. - Shallow-copying nested data before mutating the inside. The inner objects are shared;
deepcopyor rebuild them. - Reaching for
gcto fix leaks. Most "leaks" are a forgotten reference, a global list or a cache that grows; find the reference, do not tune the collector.
What's next
- Names are bound to objects; assignment binds, mutation changes the object for every name, rebinding creates a new object.
- Shallow copies share inner objects;
deepcopyshares nothing; a function can mutate a caller's list but not its int. - Reference counting frees objects immediately; the cyclic GC handles cycles;
__slots__shrinks instances and blocks stray attributes.
You understand where objects live and die. Next you go one level deeper into how attributes and classes themselves work: descriptors, __init_subclass__, and metaclasses.