The one mental model that fixes 90% of Python surprises for a Java developer: a variable is a name (a sticky label), not a box that holds a value. Assignment never copies — it just moves a label.
Coming from Java, you picture a variable as a typed box: int x reserves a slot that
holds the number 5; an object variable holds a reference to an object. Python looks
similar but the model is subtly — and importantly — different. In Python there are no boxes. There are
objects floating in memory (every value is an object: a list, an int, a string, a function), and
there are names that are stuck onto them like sticky labels. = doesn't put a value
into a variable; it binds a name to an object.
This single shift explains the behaviour that trips up every newcomer: why two variables sometimes
change together, why += on a list behaves differently than on a number, and why a function
can quietly mutate the list you passed in. Get this model right once and those stop being surprises.
Objects come in two flavours. Mutable objects can be changed in place after they're created:
list, dict, set, and most custom classes. You can append to a
list and it's still the same object, just with new contents. Immutable objects can never
change once created: int, float, str, tuple,
bool, frozenset. When you "change" an immutable value, Python actually builds a
new object and rebinds the name to it — the original is untouched.
That's why x = 5; y = x; y += 1 leaves x at 5 (ints are
immutable, so += made a new 6 and rebound y), but the same pattern
on a list lets the change leak across names. The interactive below walks exactly that scenario for a
list so you can watch the labels move.
How to read it: the boxes on the left are names (a,
b); the boxes on the right are objects in memory (with a pretend address like
0x1a). An arrow means "this name is currently stuck on this object." Watch how
b = a makes two arrows hit the same box (aliasing), how .append
changes a box's contents without moving any arrow, and how re-assignment swings an arrow to a
new box.
is vs ==Because names and objects are separate, Python gives you two different questions. == asks
"do these two objects have the same value?" — it compares contents. is asks
"are these the very same object?" — it compares identity (literally whether the arrows point at the
same box). The built-in id(x) returns that object's identity, so a is b is just
id(a) == id(b). Two lists [1,2] == [1,2] is True (same contents)
but [1,2] is [1,2] is False (two separate boxes). The everyday rule:
use == for values, and reserve is for the singletons None,
True, False (write if x is None:).
When you call f(my_list), Python binds the parameter name inside the function to
the same object you passed — no copy is made. So if the function does my_list.append(…)
(a mutation), your original list changes. But if the function does my_list = […] (a rebind),
it only moves the function's local label; your variable outside is unaffected. This is neither Java's
"pass by value" nor "pass by reference" exactly — it's often called pass by object reference (or
"call by sharing"). The mutate-vs-rebind distinction you just watched is the whole story.
Here's where the model earns its keep. A function's default values are evaluated once, when the
def runs — not on every call. So a mutable default (like =[]) is created a
single time and shared across every call. Click below and watch the buggy version accumulate
state between calls, while the idiomatic =None fix stays clean.
Buggy — one list, made at def time, reused forever:
Fixed — a fresh list each call:
The names-and-objects model is what makes Python fast to write and flexible — no manual copying, cheap passing of big objects, easy sharing. The cost is exactly the surprises above: shared mutable state. Internalise these and you'll rarely get bitten:
list(xs),
xs[:], dict(d), or copy.deepcopy(x) for nested structures..append, d[k]=…, x.attr=…) is visible to every
name on that object. Re-binding (x = …) only moves one name.None and build inside.== for value, is only for None/True/False.list, dict,
set can; str, tuple, int, frozenset
cannot. The deeper reason matters more than the answer: mutability + shared references is what produces
every aliasing bug you'll meet.