FreeCampus Python

When Names Share the Same Object

Follow names and container entries to objects, distinguish equality from identity, and use identity evidence without relying on implementation-specific object reuse.
python-foundations mutability-identity-copying references identity
Open in Colab
  • Level: Python Foundations · Unit 6
  • Estimated time: 3–4.5 hours
  • You will learn: Trace bindings and references, distinguish rebinding from mutation and equality from identity, use is None, and repair accidental sharing in a small object graph.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. Two inventory names can lead to one list

nova_gear = ["lamp", "rope"]
sol_gear = nova_gear

sol_gear.append("key")

assert nova_gear == ["lamp", "rope", "key"]
assert sol_gear == ["lamp", "rope", "key"]

There are two names but one list. The assignment sol_gear = nova_gear evaluates the expression on the right and binds another name to that same object. It does not run the list literal again or copy its contents.

This lesson answers four questions that printed values alone cannot settle:

  • Which object does each name or container position reach?
  • Did an operation rebind a name or mutate an existing object?
  • Do two values compare equal, or are they the same object?
  • When is identity the correct test rather than value equality?

2. A binding connects a name to an object

Use these terms consistently:

Term Meaning in this lesson
object a Python value with a type, state/value, and identity
name an identifier such as nova_gear in a namespace
binding the relationship from a name to an object
reference a way for a name or container entry to reach an object
alias another reference to an object that is already reachable

The list literal creates a list object. The first assignment binds nova_gear. The second assignment adds the alias sol_gear.

Both names reach one list. Rebinding sol_gear later changes its arrow, not the old list or nova_gear.

flowchart LR
  A["nova_gear"] --> C["list: lamp, rope"]
  B["sol_gear before rebind"] --> C
  D["sol_gear after rebind"] --> E["new list: compass"]

The arrows are a learning representation, not syntax Python stores in your source file. They help predict which path will observe a later mutation.

3. Assignment can rebind one name

Continue from one shared list, then rebind only sol_gear:

nova_gear = ["lamp", "rope"]
sol_gear = nova_gear

sol_gear = ["compass"]

assert nova_gear == ["lamp", "rope"]
assert sol_gear == ["compass"]
assert nova_gear is not sol_gear

The new list literal creates a second list. Assignment changes the binding for sol_gear. It does not reach backward through the old binding and replace what nova_gear means.

Compare the two events:

Event Binding changes? Existing list state changes?
sol_gear.append("key") no yes
sol_gear = ["compass"] yes no

This distinction remains important inside functions, loops, and object attributes. Lesson 4 applies it to parameter names.

4. Equality asks about values; identity asks about the object

Create one alias and one equal independent list:

first = ["map", "key"]
alias = first
independent = ["map", "key"]

assert first == alias
assert first is alias

assert first == independent
assert first is not independent

== delegates to the values’ equality behavior. For lists, corresponding elements are compared. is does not compare contents; it asks whether both expressions produce the same object.

Four combinations are useful to reason about:

Relationship == is Example
same object, equal to itself True True first and alias
distinct objects, equal contents True False first and independent
distinct objects, unequal contents False False [1] and [2]
same object but unequal to itself unusual True specialized values such as NaN require separate care

The final row is a reminder that equality is behavior defined by a type; it is not the definition of identity. Ordinary beginner data will mostly use the first three rows.

Checkpoint: binding, rebinding, and mutation

5. Use id as temporary diagnostic evidence

id(object) returns an integer identifying that object during its lifetime:

items = ["map"]
alias = items
independent = ["map"]

assert id(items) == id(alias)
assert id(items) != id(independent)

The exact integers do not matter. Do not treat them as memory addresses promised by the Python language, sort them for meaning, save them as permanent record IDs, or compare them across program runs. After an object no longer exists, an implementation may reuse its old ID for a later object.

For ordinary code, left is right communicates an identity question more directly. id is helpful in an evidence table or diagram when several paths must be compared at once.

6. None is a singleton sentinel

Python has one None object. It represents the intentional absence of a value in many APIs:

leader = None

if leader is None:
    message = "Choose a party leader"
else:
    message = f"Leader: {leader}"

assert message == "Choose a party leader"

Use is None or is not None. This is an identity question about a documented singleton, not ordinary content equality.

Do not generalize that style to strings or numbers:

answer = "moon"
assert answer == "moon"

attempts = 3
assert attempts == 3

Those are value questions, so == is the meaningful operator.

7. Do not learn equality from string or integer reuse

Python implementations may reuse some immutable objects. A literal experiment can therefore make is appear to compare values:

left = "".join(["moon", "light"])
right = "moonlight"

assert left == right

Do not add an assertion about left is right. Its result is not the value contract and can vary with implementation, optimization, or construction path. The same warning applies to small integers and some tuples or constants.

The robust rule is simple:

  • use == when the program cares whether ordinary values are equal;
  • use is when the program explicitly cares about the same object, especially None or a private sentinel created for that purpose.

Checkpoint: equality, identity, and sentinels

8. Containers hold references to their elements

Sharing can happen without two top-level names:

shared_badges = ["starter"]
party = [
    {"name": "Nova", "badges": shared_badges},
    {"name": "Sol", "badges": shared_badges},
]

assert party[0] is not party[1]
assert party[0]["badges"] is party[1]["badges"]

party[0]["badges"].append("vault scout")
assert party[1]["badges"] == ["starter", "vault scout"]

The two dictionaries are distinct, but their "badges" entries reach one list. An outer identity check cannot answer every nested-sharing question. Follow the complete access path to the object that will mutate.

This explains why nested diagrams matter. A program can have independent outer records and still leak edits through a shared descendant.

9. Rebinding one reference does not remove the others

inventory = ["lamp"]
backup_name = inventory

del inventory

assert backup_name == ["lamp"]

del inventory removes that name from the current namespace. It does not destroy the list while backup_name still reaches it. Similarly, replacing one dictionary entry leaves an old object available through any other references.

Python automatically manages object lifetime. The language does not require you to count references manually, and this unit does not depend on CPython’s reference-counting details. The useful program-level question is whether any reachable path still leads to the object.

10. Repair sharing only when it is accidental

This construction gives every record the same badges list:

template_badges = ["starter"]
players = []

for name in ["Nova", "Sol"]:
    players.append({"name": name, "badges": template_badges})

assert players[0]["badges"] is players[1]["badges"]

If badges belong to each player, construct a list for each iteration:

players = []

for name in ["Nova", "Sol"]:
    players.append({"name": name, "badges": ["starter"]})

assert players[0]["badges"] == players[1]["badges"]
assert players[0]["badges"] is not players[1]["badges"]

If the shared list represents one team scoreboard that everyone should observe, the first graph may be correct. Identity is not itself a bug. A mismatch between actual sharing and intended ownership is the bug.

11. Lab: map a party roster

Implement helpers that report relationships and build independent player records:

def relationship(left, right):
    """Return equal and identical evidence for two values."""
    raise NotImplementedError


def build_player(name, starting_badges, *, leader=None):
    """Return a player with an independent badges list and optional leader."""
    raise NotImplementedError


def add_badge(player, badge):
    """Append one badge to player and return None."""
    raise NotImplementedError

Use these acceptance checks:

starting_badges = ["starter"]
nova = build_player("Nova", starting_badges)
sol = build_player("Sol", starting_badges, leader="Nova")

assert relationship(nova["badges"], sol["badges"]) == {
    "equal": True,
    "identical": False,
}
assert relationship(nova, nova) == {"equal": True, "identical": True}
assert nova["leader"] is None
assert sol["leader"] == "Nova"

result = add_badge(nova, "vault scout")
assert result is None
assert nova["badges"] == ["starter", "vault scout"]
assert sol["badges"] == ["starter"]
assert starting_badges == ["starter"]
assert nova is not sol

Before implementing, draw three list objects: the source badges, Nova’s badges, and Sol’s badges. Equal initial contents do not require shared identity.

Hint: construct the owned list at the player boundary

relationship returns the results of == and is. In build_player, create a new outer dictionary and use list(starting_badges) for the list that the new player owns. add_badge intentionally mutates that one nested list.

Show one complete solution after attempting the lab
def relationship(left, right):
    """Return equal and identical evidence for two values."""
    return {"equal": left == right, "identical": left is right}


def build_player(name, starting_badges, *, leader=None):
    """Return a player with an independent badges list and optional leader."""
    return {
        "name": name,
        "badges": list(starting_badges),
        "leader": leader,
    }


def add_badge(player, badge):
    """Append one badge to player and return None."""
    player["badges"].append(badge)

Checkpoint: nested reference repair

12. Explain the graph after every event

Use your completed lab to answer:

  1. Which object does each outer player name reach?
  2. Why do the two badges lists compare equal before mutation?
  3. Which assertion proves they are independently owned?
  4. What changes when add_badge runs: a name, an outer dictionary, or a nested list’s state?
  5. Why is leader is None a different kind of question from leader == "Nova"?

Then deliberately make both players share one badges list, predict the leak, observe it once, and restore the independent construction. The failure is useful evidence only when you can point to the exact shared path.

Key points

TipKey points
  • Assignment binds a name to an evaluated object; it does not copy that object.
  • Rebinding redirects a name. Mutation changes an object seen through every reference that still reaches it.
  • == asks about value equality; is asks about object identity.
  • Use is None for the singleton sentinel and == for ordinary string, number, and collection values.
  • Treat id() as opaque temporary diagnostic evidence and never rely on literal interning for program correctness.
  • Containers hold references, so independent outer objects may still share a nested mutable object.
  • Sharing is correct when it matches ownership; repair only accidental sharing.

References

Back to top