What is a variable in Python?
In plain words
Imagine a sticky note that says x. The note isn't the number. It's stuck on a real thing sitting in memory. Two notes can sit on the same thing, and moving a note doesn't change the thing. It just points the note somewhere else.
The precise model has three parts: the name (the sticky note), the object (the thing in memory), and the reference (the stickiness connecting them).
1. Name
The label you type, such as x. It lives in a namespace.
It is not a storage box.
2. Object
The actual value: 42, "hello", [1, 2, 3].
These live on the heap and each has its own identity.
3. Reference
The connection. Assignment sticks a name onto an object. More than one name can share one object.
Many languages teach a variable as a named box that contains a value.
Python is different. When you write x = 42, Python creates or finds
an integer object, then binds the name x to it.
The name does not contain the number; it points at the object that represents it.
= binds a name to an object. It doesn't copy the object unless you ask Python for a copy.
What is id()?
In plain words
id() answers one question: are these two names looking at the same thing, or at two things that happen to look alike? The same id means one object with two nametags.
Every object has a unique identity, which in CPython is a stand-in for its memory address. id(x) returns that as an integer. Two names with the
same id() point to the exact same object. No copy was made.
is vs ==== checks value equality: do the objects look the same?is checks identity: are they literally the same object in memory?Use
is None, is True, is False for singletons.
For everything else, prefer ==.
Can the object itself change?
In plain words Some things you can edit (a shopping list). Some things you can only replace (a printed number). If you "change" a number, Python throws the old one away and points your name at a new one.
That's mutability. Once a name points at an object, ask whether the object can change in place, or whether a "change" has to create a different object.
The object's value cannot change. A "change" creates or finds another object and rebinds the name to it.
int float str tuple
The object's contents can change while its identity stays the same. All aliases see the updated contents.
list dict set
a = [1, 2, 3] followed by b = a does not copy the list.
Both names point to one object. If b.append(4) runs, a
sees the new item too.
How Python tracks when to free memory
In plain words Python counts how many nametags are stuck on an object. When you add a name, the count goes up. When you peel one off, it goes down. When the last nametag comes off, Python can throw the object away.
That hidden counter is the reference count. CPython updates it as names and containers are bound and unbound. At zero the object is freed immediately, unless it's stuck in a cycle, which needs a separate collector.
Try this on a list, not on a number. A few values (small integers, True, False, None) are immortal in CPython 3.12 and later. Their count is pinned and they're never freed, so sys.getrefcount(42) returns a huge fixed number rather than a count you can watch move. There's more on that further down.
Refcount increases when a new name is bound to the object
(x = obj, b = a, or passing it as a function argument).
Refcount decreases when a name is rebound (x = other),
deleted (del x), or goes out of scope (function returns).
When the refcount reaches 0, CPython frees the object's memory right away. No waiting and no delay, it's collected on the spot.
The distinction that matters most
In plain words Did you move the nametag, or did you edit the thing it was stuck to? Moving the nametag leaves the other nametags alone. Editing the thing is visible to everyone still pointing at it.
Those two operations are rebinding and mutation. Almost every "why did my list change?" bug comes from mixing them up.
x = new_value makes the name point to a different object.
The old object is unchanged; it simply loses one reference.
x.append() or x[0] = ... changes the existing object.
Every name pointing at that object observes the change.
Immutable types (int, str, float, tuple) can only be rebound. You can never change the object itself.
Mutable types (list, dict, set) can be both mutated
and rebound.
| Operation | Example | Creates new object? | id() changes? | Other aliases see change? |
|---|---|---|---|---|
| Rebind | x = 99 |
Yes | Yes | No |
| Augmented rebind | x += 1 (int) |
Yes | Yes | No |
| Mutate (append) | lst.append(x) |
No | No | Yes |
| Mutate (index) | lst[0] = x |
No | No | Yes |
| Augmented rebind | lst += [x] (list) |
No* | No* | Yes* |
* For lists, += calls __iadd__ which mutates in-place (unlike with immutable types).
All the Variable Types
In plain words You don't need to memorise every type today. For each one, ask one question: can I edit this thing, or can I only replace it?
Python's built-in types differ mainly by mutability, and that decides whether a "change" is a mutation or a rebind.
x = -7
x = 0
x += 1 creates a new int object, doesn't change the old one.x = 2.0
x = -0.5
s = 'world'
s = """multi
line"""
s[0] = 'H' raises a TypeError.active = False
int! True == 1, False == 0. Immutable singleton objects.t = ("a", True)
nums.append(4)
nums[0] = 99
d["b"] = 2
del d["a"]
s.add(4)
s.discard(1)
Quick Reference: Mutability at a glance
| Type | Literal | Mutable? | Why it matters |
|---|---|---|---|
| int | 42 |
No | x += 1 rebinds x to a new object |
| float | 3.14 |
No | Any arithmetic produces a new float |
| str | "hello" |
No | String methods return new strings; you can't change characters in-place |
| bool | True |
No | Only two boolean objects exist; they're singletons |
| NoneType | None |
No | One None object exists; always use is None to check |
| tuple | (1, 2) |
No | Can't add/remove/change; safe to use as dict keys |
| list | [1, 2] |
Yes | A function can modify the list you passed in, so be careful with aliases |
| dict | {"k": v} |
Yes | A function gets a second name for the same dict, so changes made inside it are visible to the caller |
| set | {1, 2} |
Yes | Can grow/shrink; elements must be immutable/hashable |
a = [1, 2, 3] then b = a does NOT copy the list.
Both a and b point to the same list object.
Doing b.append(4) will also affect a!
Use b = a.copy() or b = list(a) to get an independent copy.
-5 through 256. So
a = 5; b = 5 gives a is b → True: one object,
two names. It does the same for short identifier-like strings, which is why
s = "hello"; t = "hello" also gives s is t → True.
Outside that cached range there is no promise either way. Two large numbers computed separately are usually different objects (
n = 999; c = n + 1; d = 1000; c is d → False), but two identical literals written in the same file often still share one object, because the compiler stores the constant once. Don't test interning with
id(5) == id(5): that is True for every number, interned or
not, since both halves are the same literal.
One more consequence: in CPython 3.12 and later these shared values (small ints,
True, False, None) are immortal. They're never freed, and their reference count is pinned at a huge fixed number.
That is why sys.getrefcount(42) prints something like
4294967295 instead of a small count, and why the memory panel shows
∞ for 42 rather than a number that ticks up and down.
All of this is an implementation detail, not a language guarantee, so never write code
whose correctness depends on it.
a = [1, 2]; b = a; print(a is b) then b = a.copy(); print(a is b).
First True (alias), then False (new list).
A namespace of names, heap objects with identities, and arrows for references. Rebinding moves an arrow. Mutation changes the object the arrows already point at.
Four ways this goes wrong
In plain words Every one of these is the same slip: treating a nametag as if it were the thing it's stuck to. When code surprises you, find the object first, then count the names on it.
Expecting b = a to make a copy
It adds a second nametag to the same object. If the object is a list or a dict,
a change through either name is a change both names see. When you want a separate
object, ask for one: b = a.copy() or b = list(a).
(Session 03 shows why even that copies only one level.)
Thinking del x deletes the object
del removes the name. The object loses one reference, and it
is freed only if that was the last one. After a = [1, 2]; b = a; del a,
the list is alive and well under b. Nothing you write ever destroys an object directly. You only ever let go of it.
Reading += as "edit in place" for every type
On an int or a str, x += 1 builds a new object
and moves the name; other names keep the old value. On a list,
x += [1] edits the object, and every alias sees it. The same two
characters, two different operations. The famous trap: t = ([1], 2) then t[0] += [3] raises TypeError, and the inner list has already grown to [1, 3], because the mutation happened before the tuple refused the assignment.
Testing values with is
a is b asks "same object?", and for numbers and strings the answer depends on how the objects were made. A cached small int says True, two computed 1000s say False, and two literals in one file often say True. None of that is a language promise.
Compare values with ==; keep is for None,
True and False, and for checking aliasing while you learn.