Skip to main content
Understanding what happens under the hood when Python runs your code makes you a dramatically better programmer. You’ll stop being surprised by subtle bugs around object identity, mutation, and shared references — and you’ll write code that’s both correct and efficient from the start.

Everything in Python Is an Object

In CPython (the standard Python implementation), every value you work with — an integer, a string, a function, even a class itself — is a full-blown object stored in memory. Each object carries three pieces of information:
  • A value — the data it holds (e.g., 42 or "hello")
  • A type — what the object can do (e.g., <class 'int'>)
  • An identity — a unique integer representing its memory address, returned by id()
Because everything is an object, you can assign functions to variables, store them in lists, and pass them as arguments — a foundation for closures, decorators, and functional programming.

Mutable vs Immutable Objects

Python divides objects into two camps. Understanding which camp each type belongs to prevents an entire category of subtle bugs.
A mutable object can be changed in-place after creation. Its memory address stays the same even when its contents change.Mutable types: list, dict, set, and most custom class instances.
Because mutations happen in-place, any variable that references the same object sees the change:
Mutable objects inside immutable containers: A tuple is immutable — you cannot reassign its elements. But if a tuple holds a mutable object like a list, that list’s contents can still change:
The tuple itself didn’t change (it still holds a reference to the same list object), but the list’s contents did.

Identity (is) vs Equality (==)

== checks whether two objects have the same value. is checks whether they are the same object in memory — i.e., id(a) == id(b).
Use == when you care about value, and is only when checking object identity (e.g., if result is None).
CPython integer caching: CPython pre-creates and caches small integers from -5 to 256 to save memory. This means is comparisons on small integers can return True — but don’t rely on this behaviour.
Always use == to compare values, not is.

Reference Counting and Garbage Collection

CPython tracks how many variables point to each object using a reference count. When you assign an object to a variable, its count goes up. When the variable goes out of scope or is reassigned, the count goes down. When the count reaches zero, Python immediately frees the memory.
Circular references bypass reference counting. If object A references B and B references A, neither count ever reaches zero — even when both are unreachable from the rest of your program. CPython solves this with a generational garbage collector (gc module) that periodically scans for and destroys circular reference cycles.

The Global Interpreter Lock (GIL)

The GIL is a mutex inside CPython that ensures only one thread executes Python bytecode at a time. It exists to protect CPython’s reference-counting memory model from race conditions.

What the GIL prevents

Multiple threads cannot corrupt an object’s reference count by incrementing or decrementing it simultaneously — the GIL serialises those operations.

What it means for you

CPU-bound tasks (e.g., number crunching) do not get faster with Python threads because only one thread runs at a time. Use multiprocessing or libraries like NumPy (which release the GIL) instead.
For I/O-bound tasks (network calls, file reads, database queries), threads and asyncio work well because Python releases the GIL while waiting on I/O.

Shallow Copy vs Deep Copy

When you need a copy of a collection rather than a shared reference, you must choose between shallow and deep copying.
Use copy.copy() (or list slicing lst[:]) when your collection contains only immutable objects like strings or numbers. A shallow copy is faster and sufficient because the elements cannot be mutated anyway.
Use copy.deepcopy() when your collection contains nested mutable objects (lists of lists, dicts of lists, etc.) and you want a fully independent copy that shares no references with the original.

Practical Implications

1

Avoid mutable default arguments

Never use a mutable object as a function default argument. The same object is shared across all calls:
2

Use is None, not == None

None is a singleton — there is exactly one None object in CPython. Use is None and is not None to check for it, not == None.
3

Be deliberate about shared references

When you pass a list to a function, the function receives a reference to the same object. Mutations inside the function affect the caller’s list.
Pass items[:] or copy.copy(items) if you want the function to work on an independent copy.
Python 3.12 introduced a per-interpreter GIL option, and Python 3.13 adds an experimental free-threaded build (--disable-gil). The landscape around the GIL is actively evolving, but for everyday Python the behaviour described here applies.