Developers

Insight

Two Names, One List

Lists, tuples and dictionaries looked like syntax. What took longer was that a name points at an object, so two names can share one list.

Published 10/19/2026  ·  9 min read

DEVI Ops Team

Lists, tuples and dictionaries looked like the easy part: square brackets, parentheses, curly braces. The syntax took an afternoon. What took longer was one idea underneath all three: a variable is a name attached to an object, not a box that holds a value. Almost every surprise I hit with collections came back to that.

To keep the examples in one place, picture a small script that tracks file hashes collected from a drive: which hashes were seen, what file each belongs to, and which ones need review. (The hashes are shortened to four characters to keep things readable.)

A second name does not copy

seen = ["a1f3", "9c2e"]
review = seen
review.append("77bd")
print(seen)
print(review is seen)
['a1f3', '9c2e', '77bd']
True

I expected review to be a separate list. It is not. review = seen attaches a second name to the same list object, so a change through either name shows up through both. This is called aliasing, and is checks for it: it asks "same object?", where == asks "same contents?"

Binding a name copies the arrow, not the list. append changes the one object both names reach.

With numbers and strings this never bit me, because those types are immutable. Code that seems to change them actually builds a new object and moves the name. Lists are mutable, so the object itself changes, and every name pointing at it sees the change.

Passing a list into a function is this same second name. That case is in What a Function Hands Back.

A copy can still share

So I reached for .copy(), and that only half fixed it:

import copy
cases = [["a1f3"], ["9c2e"]]
shallow = cases.copy()
shallow[0].append("77bd")
print(cases)
deep = copy.deepcopy(cases)
deep[1].append("0e44")
print(cases)
[['a1f3', '77bd'], ['9c2e']]
[['a1f3', '77bd'], ['9c2e']]

.copy() (and [:], and list()) makes a shallow copy: a new outer list whose slots point at the same inner lists. Changing an inner list through the copy changes the original. copy.deepcopy() rebuilds the inner objects too, so the second change stayed private. The copy module docs spell out the difference.

A shallow copy is a new outer list. The inner lists are still shared. deepcopy rebuilds those too.

The same trap hides in a common one-liner for building a grid:

grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
[[1, 0, 0], [0, 0, 0], [0, 0, 0]]

* 3 on the outer list repeats a reference to one row three times, so there is really one row with three names. The comprehension runs [0] * 3 once per row and builds three separate lists. The Programming FAQ covers this exact case.

Multiplying the outer list repeats one row. A comprehension builds a new row each time.

What I check now: when something "changed by itself", I ask which other name points at the same object.

Methods that change things in place return None

sizes = [4096, 512, 2048]
result = sizes.sort()
print(result, sizes)
print(sorted([4096, 512, 2048], reverse=True))
None [512, 2048, 4096]
[4096, 2048, 512]

list.sort() reorders the list itself and returns None. sorted() leaves the input alone and returns a new list. The None is deliberate: it is Python's signal that the method changed the object rather than producing a new one. append, extend, insert, reverse and remove follow the same rule, so items = items.append(x) quietly replaces a list with None.

Two more details mattered once the data had more than one field:

files = [("b.log", 512), ("a.log", 2048), ("c.log", 512)]
print(sorted(files, key=lambda f: f[1]))
[('b.log', 512), ('c.log', 512), ('a.log', 2048)]

key= takes a function that is called once per item, and the sort compares what it returns. And Python's sort is stable: items with equal keys keep their original order, so b.log stays ahead of c.log. That is guaranteed, not luck (Sorting HOWTO), and it means sorting by a secondary field first and the primary field second gives a correct multi-level sort.

A tuple is immutable, its contents may not be

Tuples felt like "lists you cannot change." That is only true one level deep:

record = ("evidence.img", ["intake"])
record[1].append("imaged")
print(record)
try:
    record[0] = "other.img"
except TypeError as e:
    print(type(e).__name__)
('evidence.img', ['intake', 'imaged'])
TypeError

The tuple fixes which objects it holds. It does not freeze those objects. A list inside it can still grow. Replacing a slot is refused. This connects directly to dictionaries.

Dictionary keys must be hashable

A dictionary finds a key by its hash, a number computed from the key's value. If a key could change after it was stored, its hash would change and the dictionary would lose track of it. So keys must be hashable, which in practice means immutable all the way down:

print(hash(("evidence.img", "a1f3")) == hash(("evidence.img", "a1f3")))
try:
    {record: "case-12"}
except TypeError as e:
    print("TypeError:", e)
True
TypeError: unhashable type: 'list'

A tuple of strings works as a key. The record tuple from above does not, because it holds a list. The error names the list, not the tuple, which confused me until I understood that hashing a tuple means hashing everything inside it (glossary: hashable).

Missing keys, and which value wins

hashes = {}
hashes["a1f3"] = "report.docx"
hashes["9c2e"] = "photo.jpg"
hashes["77bd"] = "notes.txt"
print(list(hashes))
print(hashes.get("ffff"))
print(hashes.get("ffff", "unknown"))
try:
    hashes["ffff"]
except KeyError as e:
    print("KeyError:", e)
['a1f3', '9c2e', '77bd']
None
unknown
KeyError: 'ffff'

Since Python 3.7, dictionaries are guaranteed to keep insertion order (stdtypes: dict), so iterating gives keys in the order they were added. For lookups, d[key] raises KeyError if the key is absent, and d.get(key) returns None (or a default you pass). I now pick on purpose: [] when a missing key means a bug I want to see, .get() when missing is a normal case. In a hash lookup, "not found" is often the interesting answer, and .get() with a default can hide it if I am careless.

Merging two passes of results raised a quieter question: when both have the same key, which value survives?

first_pass = {"a1f3": "report.docx", "9c2e": "photo.jpg"}
second_pass = {"9c2e": "photo_renamed.jpg"}
print(first_pass | second_pass)
print(second_pass | first_pass)
first_pass.update(second_pass)
print(first_pass)
{'a1f3': 'report.docx', '9c2e': 'photo_renamed.jpg'}
{'9c2e': 'photo.jpg', 'a1f3': 'report.docx'}
{'a1f3': 'report.docx', '9c2e': 'photo_renamed.jpg'}

With | (Python 3.9+) and update(), the right side wins on conflicting keys. Reversing the operands silently changes which data survives, and also the key order, since order comes from the left. And update() changes first_pass in place, the same in-place idea as sort(). There is no error in any of these. The only protection is knowing the rule.

Do not change a collection while looping over it

sizes = {"a1f3": 0, "9c2e": 2048, "77bd": 0}
try:
    for h in sizes:
        if sizes[h] == 0:
            del sizes[h]
except RuntimeError as e:
    print("RuntimeError:", e)
RuntimeError: dictionary changed size during iteration

The dictionary at least complains. A list does not:

names = ["a.tmp", "b.tmp", "c.log"]
for n in names:
    if n.endswith(".tmp"):
        names.remove(n)
print(names)
['b.tmp', 'c.log']

Removing a.tmp shifts everything left, the loop moves to the next position, and b.tmp is skipped. No error, wrong answer.

The list shifts left under the loop. The next index lands on c.log, so b.tmp is never visited.

The fix for both is the same: loop over a copy (for h in list(sizes):) or build a new collection:

names = [n for n in names if not n.endswith(".tmp")]
['c.log']

(Run from the original three names; looping over list(sizes) and deleting leaves {'9c2e': 2048}.)

Habits I am keeping

  1. Ask "same object or a copy?" before changing a collection that another name might also reach. Use is to check.
  2. Know how deep the copy goes. Shallow for flat data, copy.deepcopy() for nested.
  3. **Never build a grid with [[x] * n] * m.** Use a comprehension.
  4. Do not assign the result of an in-place method. If it changed the object, it returned None.
  5. Rely on stable sorts for multi-level ordering, with key= doing the work.
  6. Treat tuples as fixed slots, not frozen data, and use only fully immutable values as keys.
  7. Choose `[]` or `.get()` on purpose, depending on whether a missing key is a bug or an expected case.
  8. Put the data that should win on the right of | or inside update().
  9. Never add or remove items in the collection being looped over.

What this insight does and does not support

  • Observed. Working with Python lists, tuples and dictionaries, the syntax was quick. The mistakes I made came from treating names as copies: aliasing, shallow copies of nested data, assigning the None from an in-place method, and changing a collection mid-loop. The examples are my own and were run on Python 3.13.
  • Provides context. Useful for anyone moving from single values to collections, especially when data is passed around and merged. The behaviors described are documented on docs.python.org and linked above.
  • Not established by this piece alone. These are one learner's notes. They are not a statement about any tool, product or organization, and the hash-tracking example is a simplified illustration, not a forensic method.

Views are my own.

Indexed under

  • Developers
  • Insights
  • Programming Fundamentals
  • Beginner
  • Python

A first-person educational note. Developer insights record something that stuck while learning to build software. They are not Digital Forensics findings, not product claims, and not career advice.

Check your own project by what actually happens when you skip a step. Read how these insights are written.

← All insights