Create your own
Lesson illustration

Diagnosing Shared-Reference Mutations in Nested Data Structures

Hello, and welcome to the first module of this advanced Python course. We begin with a topic that causes subtle production bugs even in otherwise clean code: state that appears independent but actually shares nested mutable objects.

In this lesson, you will learn to diagnose those bugs rather than merely memorizing a copying recipe. The core skill is to read a nested structure as an object graph: names and containers hold references to objects, and a mutation is visible through every route that reaches the same mutable object. This matters in configuration templates, test fixtures, policy objects, and parsed API payloads.


Names label objects; they do not contain values

Python assignment binds a name to an object. It does not, by itself, duplicate that object.

policy = {"enabled": True, "prefixes": []}
candidate = policy

candidate["prefixes"].append("10.20.0.0/16")

print(policy)

Output:

{'enabled': True, 'prefixes': ['10.20.0.0/16']}

This is not a propagation mechanism or a hidden synchronization feature. policy and candidate are simply two names for one dictionary:

print(policy is candidate)  # True

The is operator tests identity: whether two expressions refer to the exact same object. In contrast, == tests equality: whether two objects currently have equal contents.

left = ["site-a"]
right = ["site-a"]

print(left == right)  # True: same contents
print(left is right)  # False: different list objects

For shared-state debugging, identity is usually the question that matters.

Programming FAQ — Python 3.14.7 documentation

Read the Python documentation’s explanations of name binding, mutable objects, and the nested-list multiplication trap. These are the language-level rules behind most shared-reference defects.

In the entry “Why did changing list ‘y’ also change list ‘x’?”, read the name binding explanation. Focus on the distinction between mutating a list and rebinding a name to a newly created immutable value. Then find the entry “How do I create a multidimensional list?” and read from the multiplication example. Notice that the problem is not specific to matrices: list repetition duplicates references to an existing inner list.

The important condition is mutability. Lists, dictionaries, sets, and most ordinary class instances can change in place. If two paths reach one of those objects, a mutation through either path is observable through both.

Immutable values, such as integers and strings, behave differently: an apparent “change” produces a new object and rebinds a name or container slot to it. That is why aliasing is often harmless for immutable leaves, but dangerous for mutable nested containers.


Nested containers create multiple levels of identity

Now consider a realistic nested configuration shape:

baseline = {
    "revision": 1,
    "sites": [
        {
            "name": "chennai",
            "prefixes": ["10.20.0.0/16"],
        }
    ],
}

candidate = baseline.copy()

At first glance, candidate looks like an independent copy. It is independent only at the outer dictionary level:

assert candidate is not baseline

But .copy() on a dictionary is a shallow copy. It creates a new outer dictionary whose values still reference the original child objects:

assert candidate["sites"] is baseline["sites"]
assert candidate["sites"][0] is baseline["sites"][0]
assert candidate["sites"][0]["prefixes"] is baseline["sites"][0]["prefixes"]

So this top-level assignment affects only candidate:

candidate["revision"] = 2

print(baseline["revision"])   # 1
print(candidate["revision"])  # 2

The outer dictionaries are distinct, and the "revision" slot belongs to each outer dictionary independently.

However, this nested mutation leaks back into the baseline:

candidate["sites"][0]["prefixes"].append("10.30.0.0/16")

print(baseline["sites"][0]["prefixes"])
# ['10.20.0.0/16', '10.30.0.0/16']

The append() call mutates the one shared inner list.

Two separate outer lists, named `a` and `b`, both contain references to one shared inner list. Changing that inner list through either outer list is visible through the other.

A useful way to describe this structure is:

  • baseline and candidate reference different outer dictionaries.
  • Their "sites" entries reference the same list.
  • The first elements of that list reference the same site dictionary.
  • The "prefixes" entries in that dictionary reference the same list.

Printing both variables can be misleading because print() shows values, not identity relationships. Two structures can look identical while sharing some, all, or none of their nested objects.


Mutation versus rebinding: inspect the container being changed

A reliable diagnostic question is:

Which exact object is this operation changing?

Compare these statements:

candidate["revision"] = 2

This replaces a value in the independent outer candidate dictionary. No leak occurs.

candidate["sites"][0]["prefixes"].append("10.30.0.0/16")

This mutates a list that both structures reach. The change leaks.

A more subtle case is this:

candidate["sites"][0]["prefixes"] = ["192.168.0.0/16"]

This also affects baseline. It may look like replacement rather than mutation, but the assignment changes a key in the shared site dictionary. The relevant question is not whether the right-hand value is new; it is whether the container receiving the assignment is shared.

This principle generalizes:

OperationObject changedLeaks from a shallow copy?
candidate["revision"] = 2Independent outer dictionaryNo
candidate["sites"].append(new_site)Shared sites listYes
candidate["sites"][0]["name"] = "blr"Shared site dictionaryYes
candidate["sites"][0]["prefixes"].append(prefix)Shared prefixes listYes

The “depth” of an expression is not itself the issue. The issue is whether the immediate object being mutated is shared.

Lecture 11: Aliasing and Cloning

Watch MIT OpenCourseWare’s “Lecture 11: Aliasing and Cloning” for a visual trace of an alias, a shallow copy, and a fully independent nested structure.

Watch nested shallow copying first. Pay particular attention to the contrast between adding an item to the outer list and changing an item within a shared inner list. Then watch deep copy contrast for the conceptual contrast; the next lesson will cover selecting copying strategies in detail.


Common sources of accidental shared nested state

Once you think in terms of object identity, several familiar Python patterns become easy to diagnose.

1. Direct assignment

active_policy = baseline

This is an alias: both names refer to the same outer object, and therefore to the same entire nested object graph.

assert active_policy is baseline

Direct assignment is often correct. For example, a function may deliberately receive and update a live configuration object. The defect begins only when code expects isolation but uses assignment.

2. Shallow-copy operations

Several operations create a fresh outer container while preserving references to nested values:

candidate = baseline.copy()      # dictionary shallow copy
rows_copy = rows[:]              # list shallow copy
rows_copy = list(rows)           # list shallow copy
rows_copy = [row for row in rows]  # new outer list, same row objects

For a flat structure containing only immutable values, this is generally sufficient. For nested mutable state, inspect the descendants before assuming independence.

3. Repetition with *

This is a particularly common test-data and matrix-construction bug:

counters = [[0] * 3] * 4

counters[0][1] = 99
print(counters)

Output:

[[0, 99, 0], [0, 99, 0], [0, 99, 0], [0, 99, 0]]

The expression [[0] * 3] creates one inner list. Repetition then places four references to that same list into the outer list.

Confirm it directly:

assert counters[0] is counters[1]
assert counters[1] is counters[2]

When each row must be independent, construct a new inner list for each iteration:

counters = [[0] * 3 for _ in range(4)]

assert counters[0] is not counters[1]

This distinction is especially important in automated tests. A shared nested fixture can cause one test case to contaminate another, creating failures that depend on execution order.

4. Function calls

Function parameters are additional names bound to the passed objects. A function can therefore mutate caller-owned state:

def add_prefix(site: dict, prefix: str) -> None:
    site["prefixes"].append(prefix)

site = {"name": "chennai", "prefixes": []}
add_prefix(site, "10.20.0.0/16")

print(site)
# {'name': 'chennai', 'prefixes': ['10.20.0.0/16']}

This can be a good API design when documented: the function has a clear in-place update contract. It is a bug when callers expect a transformed independent result instead.


A practical diagnostic workflow

When a baseline, fixture, or template changes unexpectedly, avoid immediately adding a copy at random. First locate the shared object and the mutating operation.

1. Reduce the structure

Create the smallest version that reproduces the behavior:

original = {"routes": [{"prefixes": []}]}
working = original.copy()

Smaller structures make it possible to inspect every identity boundary.

2. Identify the suspicious mutation

Look especially for operations that change an object in place:

items.append(value)
items.extend(values)
items.sort()
mapping.update(other)
mapping[key] = value
members.add(value)

Also inspect item assignment at each nesting level. It mutates the container on the left of the final indexing operation.

3. Probe identities along the path

Use is at each layer:

print(original is working)  # False
print(original["routes"] is working["routes"])  # True
print(original["routes"][0] is working["routes"][0])  # True
print(
    original["routes"][0]["prefixes"]
    is working["routes"][0]["prefixes"]
)  # True

id() can be useful in temporary debug logs:

logger.debug(
    "prefix-list identities: original=%s working=%s",
    id(original["routes"][0]["prefixes"]),
    id(working["routes"][0]["prefixes"]),
)

Prefer is in assertions and conditional logic. An id() is implementation-specific and is only meaningful while its object remains alive.

4. Classify the problem

Most cases fall into one of these categories:

ObservationLikely cause
Outer objects are identicalDirect aliasing through assignment
Outer objects differ, immediate children matchShallow copy
Multiple rows or defaults all change togetherRepetition of a mutable object, often using *
State changes after a helper callFunction mutated an argument
A supposedly fresh test fixture contains earlier stateShared object created outside the fixture or reused by a shallow copy

5. State the intended ownership

Before fixing the code, describe the intended relationship precisely:

  • “This helper should update the supplied object in place.”
  • “Each test case needs its own mutable nested state.”
  • “This candidate policy may change top-level metadata but must not modify site definitions.”
  • “These rows must be independent.”

That statement determines whether shared references are a defect or deliberate shared state.


A compact inspection harness

For nested list-based data, a few assertions reveal the key relationships quickly:

def inspect_rows(original: list[list[str]], candidate: list[list[str]]) -> None:
    print("same outer object:", original is candidate)

    for index, (old_row, new_row) in enumerate(zip(original, candidate)):
        print(f"row {index} shared:", old_row is new_row)

Use it like this:

original = [["edge-1"], ["edge-2"]]
candidate = original.copy()

inspect_rows(original, candidate)

Expected result:

same outer object: False
row 0 shared: True
row 1 shared: True

The outer copy is real, but it is insufficient if you plan to mutate the rows.

For routine tests, encode the intended independence as an assertion. This catches regressions at the construction point, rather than much later when a corrupted template produces unexpected behavior:

assert candidate is not original
assert candidate["sites"] is not original["sites"]

The second assertion should appear only when independent site collections are genuinely required. Shared references are not inherently bad; unintended sharing is.


Key takeaways

Python variables and container slots hold references to objects. Shared references become visible when the shared object is mutable and some code mutates it in place.

To diagnose an unexpected nested mutation:

  1. Find the exact container or object changed by the operation.
  2. Use is to inspect identity at each relevant nesting level.
  3. Distinguish an alias from a shallow copy.
  4. Look for common construction causes, especially direct assignment, shallow copying, function-side mutation, and * with nested mutable objects.
  5. Define whether the state was intended to be shared before choosing a remedy.

In the next lesson, you will move from diagnosis to construction: choosing shallow copying when outer independence is enough, and deep copying when nested mutable state must be independent.

Can't find a good explanation? Sign up and we'll make it for you

Sign up