Everything is Object
Introduction
The first time someone told me “in Python, everything is an object,” I nodded politely and understood nothing. It sounded like one of those slogans you repeat in interviews. Then I hit a bug: I passed a list to a function, the function never returned anything, and yet my original list came back four items longer. Nothing in my mental model explained that. The fix wasn’t more syntax — it was realising that a Python variable is not a box that holds a value. It is a name tag stuck onto an object that lives somewhere in memory. Integers, strings, lists, functions, classes, even the None you return by accident — all of them are objects with an identity, a type, and a value. Once you see variables as labels rather than containers, the weird behaviour stops being weird. This post walks through that model: how to inspect objects with id and type, what separates mutable from immutable, how CPython cheats for performance with small integers, and why all of it decides what happens when you pass something into a function.
id and type
Every object in Python carries two pieces of metadata you can query directly. type() answers what kind of thing is this? and id() answers which exact object is this? In CPython, id() returns the object's address in memory, so two names with the same id are two labels on one single object — there is no copy involved. This gives us a debugger for the entire mental model: whenever you're unsure whether something was copied or shared, print the ids.
>>> a = 89
>>> type(a)
<class ‘int’>
>>> id(a)
9789248>>> s = “Best School”
>>> type(s)
<class ‘str’>
>>> id(s)
140234567891234The is operator is just a friendlier id() comparison — a is b is exactly id(a) == id(b). This is the single most important distinction beginners miss: == asks "do these two objects have the same value?" while is asks "are these two names pointing at the very same object?"
>>> l1 = [1, 2, 3]
>>> l2 = [1, 2, 3]
>>> l1 == l2 # same contents
True
>>> l1 is l2 # but two separate objects
False
>>> id(l1), id(l2)
(140234500000000, 140234500001234)Mutable objects
A mutable object can be changed in place: its value changes while its identity stays the same. The id before and after the modification is identical, because you never made a new object — you edited the existing one. The mutable built-in types are list, dict, set, and bytearray (along with most user-defined classes).
>>> l = [1, 2, 3]
>>> id(l)
140234500000000
>>> l.append(4)
>>> l
[1, 2, 3, 4]
>>> id(l)
140234500000000 # unchanged: same object, new contentsThis is also where aliasing becomes visible. When you write l2 = l1, Python does not copy the list. It binds a second name to the same object, creating an alias. Mutating through either name is visible through both, because there was only ever one list:
>>> l1 = [1, 2, 3]
>>> l2 = l1 # alias, not a copy
>>> l1.append(4)
>>> l2
[1, 2, 3, 4] # l2 “changed” without ever being touched
>>> l1 is l2
TrueHere is the memory schema for that alias:
BEFORE l1.append(4) AFTER l1.append(4)l1 ──┐ l1 ──┐
├──▶ ┌──────────────┐ ├──▶ ┌─────────────────┐
l2 ──┘ │ list @0x7f10 │ l2 ──┘ │ list @0x7f10 │
│ [1, 2, 3] │ │ [1, 2, 3, 4] │
└──────────────┘ └─────────────────┘one object, two names same id — the object was edited in placeIf you want an actual copy, you must ask for one explicitly — slicing is the classic three-line answer:
python
def copy_list(l):
return l[:]>>> a = [1, 2, 3]
>>> b = copy_list(a)
>>> b == a, b is a
(True, False) # equal in value, distinct in identityImmutable objects
An immutable object cannot be modified after creation. Any operation that looks like a modification actually builds a new object and rebinds your name to it. The immutable built-ins are numbers (int, float, complex), str, tuple, frozenset, and bytes.
python
>>> s = "Best"
>>> id(s)
140234511111111
>>> s += " School" # looks like an edit...
>>> s
'Best School'
>>> id(s)
140234522222222 # ...but it is a brand-new string objectHow is an immutable object stored in memory? It is allocated once, on the heap, and then frozen — its bytes are never rewritten. Because the interpreter knows the contents can never change, it is free to hand the same object to many different names without any risk. That safety is what makes the optimisations in the next section possible. Compare the two behaviours side by side:
python
>>> l = [1, 2, 3] # mutable
>>> id(l); l += [4]; id(l)
140234500000000
140234500000000 # SAME object, extended in place>>> t = (1, 2, 3) # immutable
>>> id(t); t += (4,); id(t)
140234533333333
140234544444444 # DIFFERENT object, rebuilt from scratchWhy it matters, and how Python treats the two differently
The difference matters because Python is allowed to share and cache immutable objects but must keep mutable ones separate. If two variables shared a list, editing one would corrupt the other — so lists are never silently shared. But if two variables share the integer 89, nothing can go wrong, because nobody can ever change what 89 means.
CPython leans on this hard with small integer pre-allocation. At interpreter startup, CPython creates a cache of the integers it expects to see most often and reuses those objects forever instead of allocating new ones. The bounds live in Objects/longobject.c as two macros:
c
#define NSMALLPOSINTS 257
#define NSMALLNEGINTS 5NSMALLNEGINTS (5) sets how many negative integers are cached, and NSMALLPOSINTS (257) how many non-negative ones. Together they cover the range -5 through 256 — which is 5 + 257 = 262 integer objects created before your program runs a single line. When you write a = 89, CPython does not build a new int; it looks 89 up in that array and hands you a pointer to the existing object. Outside the range, you get a fresh object each time:
Get D Indimuli’s stories in your inbox
Join Medium for free to get updates from this writer.
python
>>> a = 89
>>> b = 89
>>> a is b
True # both names point into the small-int cache>>> x = 1000
>>> y = 1000
>>> x is y
False # 1000 > 256, so two separate objects were builtWhy these values? Because they cover the integers real programs actually use. Loop counters, list indices, string lengths, byte values (0–255), boolean-ish flags, and small negative offsets like -1 for "last item" account for the overwhelming majority of integers in typical code. Caching them removes an enormous number of allocations for essentially no memory cost, while the rare 1_000_000 pays the normal price. It is a deliberate trade sized to the most-used integers.
String literals get a related treatment called interning. Short, identifier-like strings are cached and reused, while strings containing spaces generally are not:
python
>>> s1 = "Best"
>>> s2 = "Best"
>>> s1 is s2
True # interned>>> a = "Best School" # typed line by line in the REPL
>>> b = "Best School"
>>> a is b
False # not auto-interned; each line compiled separately
>>> a == b
True # values still matchOne consequence to keep in your pocket: never use is to compare values. Use it only for identity checks like x is None. The moment your integers leave the cache, is starts lying to you.
The special case of tuples and frozensets
tuple and frozenset are immutable — but only shallowly. What is frozen is the set of references the container holds, not the objects those references point to. A tuple can therefore contain a mutable object, and that object can still be changed through it:
python
>>> t = (1, 2, [3, 4])
>>> id(t)
140234555555555
>>> t[2].append(5) # legal: we mutate the list inside the tuple
>>> t
(1, 2, [3, 4, 5])
>>> id(t)
140234555555555 # tuple identity unchanged>>> t[2] = [9] # illegal: we would rebind a slot of the tuple
TypeError: 'tuple' object does not support item assignmentt ──▶ ┌────────────── tuple @0x7f20 (frozen) ───────────────┐
│ slot 0 ──▶ int 1 │
│ slot 1 ──▶ int 2 │
│ slot 2 ──▶ ┌─ list @0x7f30 ─┐ ◀── still mutable! │
│ │ [3, 4, 5] │ │
│ └────────────────┘ │
└─────────────────────────────────────────────────────┘ the SLOTS cannot be repointed — the OBJECTS they point to can changeThis has a practical bite: a tuple is only hashable, and therefore only usable as a dictionary key, if everything inside it is also immutable. hash((1, 2, [3])) raises TypeError.
How arguments are passed to functions
Python is neither “pass by value” nor “pass by reference.” It is pass by assignment: calling f(x) performs the equivalent of parameter = x inside the function's local scope. The parameter becomes a new name bound to the same object the caller passed in. What happens next depends entirely on whether that object is mutable — and on whether you mutate it or rebind the name.
With an immutable object, mutation is impossible, so any += inside the function creates a new local object and rebinds the local name. The caller sees nothing:
python
def increment(n):
n += 1 # builds a new int, rebinds the LOCAL name na = 1
increment(a)
print(a) # 1 — unchangedWith a mutable object, mutating in place changes the one shared object, and the caller absolutely sees it:
python
def increment(l):
l.append(4) # mutates the shared list objectl = [1, 2, 3]
increment(l)
print(l) # [1, 2, 3, 4] — changedBut rebinding a parameter never affects the caller, even for a mutable object — you have only pointed the local name somewhere else:
python
def assign_value(n, v):
n = v # rebinds the local name; the caller's object is untouchedl1 = [1, 2, 3]
l2 = [4, 5, 6]
assign_value(l1, l2)
print(l1) # [1, 2, 3] — unchangedThe schema for that last one makes the rule obvious:
caller: l1 ──▶ ┌ list A [1, 2, 3] ┐ l2 ──▶ ┌ list B [4, 5, 6] ┐ on call: n ──▶ list A v ──▶ list B
after
n = v: n ──╳ list A n ──▶ list B
▲
└── l1 still points here, so the caller sees [1, 2, 3]So the rule is short: mutating reaches the caller, rebinding does not. The same distinction explains one last classic:
python
l1 = [1, 2, 3]
l2 = l1
l1 = l1 + [4] # concatenation builds a NEW list, rebinds l1
print(l2) # [1, 2, 3]l1 = [1, 2, 3]
l2 = l1
l1 += [4] # += calls list.__iadd__, mutating IN PLACE
print(l2) # [1, 2, 3, 4]Two lines that look interchangeable, two completely different outcomes — and now you can predict which is which without running them.
Conclusion
“Everything is an object” turns out to be a practical claim, not a philosophical one. Variables are names, names point at objects, objects have an identity you can read with id() and a type you can read with type(). Mutable objects — list, dict, set, bytearray — can change in place, which makes aliasing dangerous and makes function arguments a two-way street. Immutable objects — numbers, str, tuple, frozenset, bytes — cannot, which is exactly what lets CPython pre-allocate the 262 integers from -5 to 256 and reuse them forever. Once these pieces click together, the bug I opened with stops being mysterious and becomes the only thing that could possibly have happened.



