Variables and Memory in Python: References and Mutability

Understand how Python names refer to objects, how mutation differs from reassignment, and where shared references need an explicit copying policy.

A Python function receives a list, changes it, and returns nothing. The caller’s list has changed anyway. Another function assigns a new list to the same parameter, and the caller sees no change. Both behaviors follow one rule: names are bound to objects, and assignment does not copy an object.

I find this a more useful starting point than asking whether Python passes variables by value or by reference. Those phrases carry different assumptions across languages. Drawing the names, objects, and references usually makes the behavior clear before running the code.

This article follows Basic of Python in Python for Backend Developers. The examples are checked on CPython 3.14.4 with the GIL enabled. The reference and mutation rules are Python semantics. Object addresses, caches, and reclamation timing belong to a separate implementation discussion.

Names point to objects

An object has an identity, a type, and a value. A name lets a program refer to an object. With pending = ["build"], the list expression creates a list and the assignment binds pending to it. With alias = pending, Python binds a second name to the object already found through pending. No second list is requested. These distinctions follow the Python data model and assignment rules.

pending = ["build"]
alias = pending
pending = ["deploy"]
alias.append("test")

assert pending == ["deploy"]
assert alias == ["build", "test"]
assert pending is not alias

The third statement redirects pending to another list. It leaves alias alone. The append then changes the older list through alias. The assertions describe two different objects with different contents, without relying on a memory address.

Rebinding and mutation affect different thingsRead the two numbered states. First pending is rebound to a new list containing deploy, while alias still reaches the old list containing build. Appending test through alias changes only that old list. pending continues to reach the deploy list.Rebinding redirects a name; mutation changes an object1. pending = ['deploy']2. alias.append('test')pendingalias['deploy']['build']pendingalias['deploy']['build','test']The old list still exists through alias.pending still refers to the other list.
Rebinding and mutation affect different thingsRead the two numbered states. First pending is rebound to a new list containing deploy, while alias still reaches the old list containing build. Appending test through alias changes only that old list. pending continues to reach the deploy list.Rebinding versus mutation1. Rebind pendingpendingalias['deploy']['build']2. Mutate through aliaspendingalias['deploy']['build','test']Two names now reach different lists.
The second state follows the first. Rebinding changes one name's destination. Mutation changes the object at a destination.

Identity means sameness of object, rather than sameness of contents. id() exposes an integer identity that is stable during an object’s lifetime. Treating that integer as a durable business identifier is a mistake: identities can be reused after objects die. There is no need to print addresses to explain aliasing when is can ask whether two live references identify the same object.

Dynamic typing describes bindings and operations

Python is dynamically typed: a name can be rebound to objects of different types, and operations are resolved using the objects present at runtime. The integer object does not turn into a string when a name is reassigned. The name now refers to a different object.

payload = 42
assert type(payload) is int
payload = "42"
assert type(payload) is str

def double(value: int) -> int:
    return value * 2

assert double(3) == 6
assert double("ha") == "haha"  # A type checker should flag this call.

The second call also demonstrates a boundary of annotations. Python does not enforce int in this function at runtime. A static checker should reject the string argument, but execution repeats the string because multiplication supports that operation. The typing documentation explicitly separates annotations from runtime enforcement.

Static typing is a different checking strategy, not a promise of immutability. A statically checked reference may still point to mutable state. Conversely, a dynamically typed Python program can work primarily with immutable values. I keep these questions separate: what types are allowed here, and who is allowed to change the object?

At a service boundary, annotations help describe the intended contract. They do not parse an HTTP parameter, validate a JSON document, or convert text into an integer. Do that work explicitly. A function accepting retries: int should receive a validated integer from the boundary that understands the input format.

Mutation changes an object; rebinding changes a name

Integers and strings are immutable. Their values cannot be modified in place. Lists are mutable: operations such as append, item replacement, and extend change an existing list. An alias to that list observes the change because there is only one list involved.

retries = 3
previous_retries = retries
retries += 1

route = "jobs"
previous_route = route
route += "/pending"

assert previous_retries == 3 and retries == 4
assert previous_route == "jobs" and route == "jobs/pending"

The earlier integer and string remain unchanged through their other names. The updated names refer to the results of the operations. The semantic point is that the earlier values cannot be changed through those objects. Whether an implementation allocates or reuses a particular immutable result is a different question.

Augmented assignment needs care because += is not a universal synonym for creating a replacement object. For a built-in list, it extends the list in place. Ordinary list addition creates a new list, and the following assignment rebinds the name.

pending = ["build"]
alias = pending
pending += ["test"]
assert pending is alias
assert alias == ["build", "test"]

pending = pending + ["deploy"]
assert pending is not alias
assert alias == ["build", "test"]
assert pending == ["build", "test", "deploy"]

This difference matters in a helper that receives a caller’s list. Changing steps = steps + extra to steps += extra can change the observable API behavior even if the final local contents look identical. For user-defined types, augmented assignment can have custom behavior. Read the operation’s contract rather than inferring mutation from the punctuation alone. The augmented assignment specification describes the in-place protocol and fallback.

An immutable container can contain mutable objects

A tuple fixes its sequence of element references. That does not freeze every object reachable through those references. A tuple containing a list can therefore expose changing data while the tuple’s own elements remain the same objects.

batch = ("nightly", ["build"])
batch[1].append("test")
assert batch == ("nightly", ["build", "test"])

try:
    batch[0] = "weekly"
except TypeError:
    pass
else:
    raise AssertionError("Tuple item replacement must fail")

The append changes the list. The attempted item assignment would replace a tuple element, so it raises TypeError. These are different operations at different levels of the object graph. The distinction is useful when reviewing a supposedly immutable configuration structure: a tuple around a dictionary does not make the dictionary read-only.

There is a related hashing consequence. This tuple cannot be a dictionary key because one element is an unhashable list. “Tuple” does not automatically mean “safe key” or “deeply immutable value.” Decide what must remain stable throughout the structure, including nested containers, before using immutability as a design guarantee.

A call binds parameter names to argument objects

When a function is called, its parameter names are bound to the supplied objects. The caller’s name and the callee’s parameter can refer to the same list. Assigning another object to the parameter changes the local binding. Mutating the shared list changes what the caller can observe. The function tutorial calls this passing the object reference as the argument value.

def replace_steps(steps):
    steps = ["deploy"]
    return steps

def add_step(steps):
    steps.append("test")

pending = ["build"]
replacement = replace_steps(pending)
assert pending == ["build"]
assert replacement == ["deploy"]

add_step(pending)
assert pending == ["build", "test"]
Caller and parameter refer to the same argument objectRead the caller name pending and local parameter steps first. Both arrows reach the same list. Appending through steps changes that shared list, which the caller can observe. Reassigning the local parameter would not redirect the caller's binding.A call adds a local parameter bindingCallerInside add_step(steps)pendingsteps['build']One shared listappend('test')['build','test']Reassigning steps would redirect only the local name.
Caller and parameter refer to the same argument objectRead the caller name pending and local parameter steps first. Both arrows reach the same list. Appending through steps changes that shared list, which the caller can observe. Reassigning the local parameter would not redirect the caller's binding.Arguments bind local namesCallerInside functionpendingsteps['build']The same list, not a copyappend('test')['build', 'test']Mutation is visible to the caller.Rebinding steps changes only steps.
The function receives access to the object. Local reassignment and shared mutation have different effects.

Returning the replacement makes it available to the caller, which can choose to bind it. The local name inside replace_steps cannot silently redirect the caller’s name. This remains true if the argument is a dictionary, an instance, or a function object.

I prefer APIs that make their mutation policy easy to discover. A function called add_step can reasonably document an in-place update. A transformation returning a new job should build the intended result and preserve its input. Neither style is universally better, but mixing them unpredictably makes it hard to reason about retries, validation failures, and reuse of templates.

That policy should also cover retained arguments. A function can leave the input untouched during the call but store it in a registry and mutate it later. Returning from the function is not an ownership transfer enforced by Python. If the caller continues to modify the same object, both sides remain coupled. Document whether the callee borrows the object for the duration of the call, retains it, or creates independent state. Those are application conventions that the reference model can support, but does not automatically enforce.

An exception does not undo mutation. If a helper appends a step and later raises an error, the caller still owns the changed list. Where all-or-nothing behavior matters, prepare the new state before replacing the old state, or establish an explicit rollback strategy. Passing an object into a function does not create a transaction around it.

Defaults can create sharing across calls

Default argument expressions are evaluated when the function definition executes. A list used as a default can therefore be reused by later calls. That may be deliberate for a cache, but it is usually wrong for a fresh per-call history. The default argument documentation demonstrates this lifetime rule.

def record_step(step, history=None):
    if history is None:
        history = []
    history.append(step)
    return history

first = record_step("build")
second = record_step("test")
shared = []
assert record_step("build", shared) is shared
assert record_step("test", shared) == ["build", "test"]
assert first == ["build"] and second == ["test"]
assert first is not second

Here None requests a fresh list. An explicitly provided list requests shared accumulation, and the return value is that same object. The function makes both behaviors intentional. If None must itself be a valid business value, use a private sentinel object instead of overloading its meaning.

Shared references also appear without defaults. Putting a mutable class attribute on a class can share it between instances. A closure can keep a container alive between calls. A module-level dictionary can become process-wide state. In each case, ask where the object was created and which references reach it. The syntax of the latest call is only part of the explanation.

A new outer container does not imply new inner containers

Container repetition duplicates references. It does not repeatedly evaluate the expression that produced each element. This is why a grid built from one inner list can make updates appear in several rows.

rows = [[]] * 3
rows[0].append("failed")
assert rows == [["failed"], ["failed"], ["failed"]]
assert rows[0] is rows[1]

independent = [[] for _ in range(3)]
independent[0].append("failed")
assert independent == [["failed"], [], []]
assert independent[0] is not independent[1]

The comprehension evaluates [] on every iteration, producing independent rows. A practical failure case is building a batch result matrix with multiplication, then marking one row failed. Every alias to the same inner row appears failed. The outer list’s length is correct, so a test that checks only dimensions misses the defect. The sequence operations documentation explains the reference repetition behavior.

The same question applies to dictionaries loaded from a reusable template. A fresh outer dictionary can still refer to the template’s list of steps. Reviewing only the top-level is not assertion is insufficient. Inspect the nested mutable objects the next operation will actually touch.

Choose the depth of independence

A shallow copy creates a new outer container and inserts references to the same contained objects. A deep copy recursively copies contents, using bookkeeping to handle cycles and repeated references. These are the definitions in the copy module documentation. Neither is a universal ownership policy.

from copy import deepcopy

original = {"steps": ["build"], "retries": 2}
shallow = original.copy()
deep = deepcopy(original)

assert shallow is not original
assert shallow["steps"] is original["steps"]
assert deep["steps"] is not original["steps"]

shallow["retries"] = 5
shallow["steps"].append("test")
assert original == {"steps": ["build", "test"], "retries": 2}
assert deep == {"steps": ["build"], "retries": 2}
Shallow and deep copies of nested containersRead from the outer dictionaries to the inner lists. Original and shallow are separate dictionaries whose steps entries point to the same list. Deep is a third dictionary whose steps entry points to an independent list. The diagram shows the state immediately after copying, before any mutation.Copying: inspect the inner referencesEach outer dictionary is distinct.original dictshallow dictdeep dictsteps: ['build']One list shared by two dictionariessteps: ['build']A separate copied listAppending through shallow['steps'] also changes original['steps'].
Shallow and deep copies of nested containersRead from the outer dictionaries to the inner lists. Original and shallow are separate dictionaries whose steps entries point to the same list. Deep is a third dictionary whose steps entry points to an independent list. The diagram shows the state immediately after copying, before any mutation.Copying nested referencesThree different outer dictionariesoriginal dictshallow dictsteps: ['build']One shared inner listDeep copy creates a separate list:deep dictsteps: ['build']Independent from the originalShallow: new outer, shared inner.
State immediately after copying. A shallow copy isolates top-level replacement, while a deep copy also isolates this nested steps list.

Replacing shallow["retries"] changes only the shallow dictionary. Appending to its steps changes the list also reachable from original. The deep copy has an independent steps list and keeps the earlier contents. Notice that the important distinction is the list reference, not whether the integer values happen to share identity.

Deep copying can be expensive and can duplicate state that should stay shared. It is not a reliable way to clone live connections, locks, open files, or arbitrary application resources. Objects can customize copying, and some kinds of objects are deliberately not copied. Prefer an explicit transformation when a boundary requires independence for only a few known fields.

Deep copying also preserves intentional sharing within a copied graph through its memo. If two original fields point to one inner list, their deep-copied counterparts can point to one new inner list. “Deep” does not mean that every path becomes unrelated to every other path. It means copying the reachable structure according to the copy protocol.

Use equality for values and identity for sameness

== asks the objects’ equality protocol whether they compare equal. is asks whether the operands are the same object. Two independently created lists can be equal without being identical. An alias is identical even after the shared list changes.

left = ["build"]
right = ["build"]
alias = left

assert left == right
assert left is not right
assert left is alias

missing = object()
options = {"timeout": None}
assert options.get("timeout", missing) is None
assert options.get("retries", missing) is missing

The sentinel example uses identity deliberately. A missing option is different from an option explicitly set to None. Because the private sentinel is a unique object, no value comparison is needed to distinguish those states. Checking value is None is another common intentional identity test.

A status string or numeric identifier should normally be compared with ==. CPython may reuse some immutable objects, making an accidental is check appear to work in a small example. That observation says nothing useful about the next value parsed from a request. The data model permits implementation choices around immutable-object reuse; application equality should survive those choices.

Functions and classes are objects too

A function definition creates a function object and binds its name. A class definition likewise produces a class object. These objects can be assigned to another name, stored in a dictionary, passed as arguments, and called where appropriate. A new name does not copy the function’s implementation or create a second class.

def normalize(value):
    return value.strip().lower()

class Job:
    def __init__(self, name):
        self.name = name

normalizer = normalize
handlers = {"name": normalizer}
factory = Job
job = factory(handlers["name"]("  BUILD  "))

assert normalizer is normalize
assert isinstance(job, Job)
assert job.name == "build"
assert callable(factory) and callable(normalizer)

This is enough machinery for a small handler registry or a configurable factory. The dictionary stores a function object, and factory refers to the class used to create a job. The classes tutorial connects these features to the same names-and-objects model. Metaclass internals are unnecessary for understanding this example.

Make ownership visible at the boundary

Suppose a job template is reused to prepare independent requests. Copy the steps that each request may change, while carrying forward immutable fields. The policy is explicit and limited to the data contract.

def prepare_job(template, step):
    return {
        "name": template["name"],
        "steps": [*template["steps"], step],
        "retries": template["retries"],
    }

template = {"name": "nightly", "steps": ["build"], "retries": 2}
job = prepare_job(template, "test")
job["steps"].append("package")

assert template["steps"] == ["build"]
assert job["steps"] == ["build", "test", "package"]
assert job["steps"] is not template["steps"]

This shallow expansion is sufficient because the steps are immutable strings. If each step later becomes a dictionary of mutable options, the contract and copying strategy need another decision. A successful test today does not make future nested fields independent automatically.

When debugging unexpected state, I work through four questions: which object changed, which operation mutated it, which references reach it, and where independence was supposed to begin. That sequence usually exposes the faulty assumption more quickly than adding copies throughout the call chain.

The next article, Python Memory Management: Reference Counting and Garbage Collection, follows these references into object lifetime. The final article examines interning and constant folding in CPython. Both build on this distinction between program semantics and implementation behavior.

Share
Prabhat Kashyap

Prabhat Kashyap

Senior Technical Architect at HCLTech · working with Leonteq Security AG

I have 10+ years of experience building distributed systems and fintech platforms. I write about practical, non-obvious engineering details that official documentation often skips.

Newsletter

Get new engineering deep dives

Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.

Join the newsletter