Common gotchas
A gotcha is a piece of correct-looking Python that does something other than what you expect; this lesson covers the three that catch almost everyone: is versus ==, copying a nested list, and a mutable default argument.
Last updated: 13 Oct, 2026 · Python 3.12
The Decorators lesson leaned on one such trap: a wrapper that returns the wrong thing. This lesson collects three more that bite in real code, each shown with the output that gives it away, then the fix.
is checks identity, == checks value
== asks whether two values are equal. is asks whether they are the same object in memory. Two lists can be equal without being the same object, so the two questions give different answers.
print(). The runs here wrap the result in print() so the same comparisons show their values in a .py file.list1 = [1, 2, 3, "krish"]
list2 = [1, 2, 3, "krish"] # a second list with the same values
print(list1 == list2) # equal contents?
print(list1 is list2) # the same object in memory?
list2 = list1 # now list2 is another name for list1
print(list1 is list2)True False True
- list1 == list2 is True: the two lists hold equal values.
- list1 is list2 is False: they are two separate objects in memory, built one after the other.
- After list2 = list1, list1 is list2 is True: the assignment did not copy; it gave the one object a second name, so both point at it.
The rule of thumb: compare values with ==, and keep is for checking against None, as in if result is None. None is a single object, so is is exactly right there.
A shallow copy shares the inner lists
Copying a list of lists has a trap. The list's own .copy() makes a shallow copy: a new outer list whose slots point at the very same inner lists. copy.deepcopy makes a deep copy: new inner lists too, sharing nothing.
.copy() method, then changes an element of a nested inner list and shows the copy changing too. The run below does the same with list1 and list2, and adds copy.deepcopy as the fix.import copy
list1 = [[1, 2], [5, 6, 7, 8]]
shallow = list1.copy() # his .copy(): a shallow copy
deep = copy.deepcopy(list1) # a deep copy, with new inner lists too
list1[1][0] = 100 # change an inner list of the original
print("shallow:", shallow)
print("deep:", deep)shallow: [[1, 2], [100, 6, 7, 8]] deep: [[1, 2], [5, 6, 7, 8]]
- shallow: [[1, 2], [100, 6, 7, 8]]: the change to the original's inner list showed up in the shallow copy, because both point at the same inner list.
- deep: [[1, 2], [5, 6, 7, 8]]: the deep copy was untouched, because it has its own inner lists.
- list(x) and x[:] also make shallow copies, so they have the same trap with nested data.
This bites with lists of dictionaries, the chat-messages shape from the Lists of dictionaries lesson. A shallow copy of the message list shares the message dicts, so editing one edits the original conversation too.
messages = [{"role": "user", "content": "hi"}]
shallow = list(messages)
shallow[0]["content"] = "changed"
print(messages)[{'role': 'user', 'content': 'changed'}]Editing the dict through shallow changed the dict inside messages, because both lists hold the same dict. Use copy.deepcopy when you need to edit a copy of nested data and leave the original alone.
A mutable default argument is created once
A default value like basket=[] is built one time, when the function is defined, and reused by every call that does not pass its own. So the list fills up across calls instead of starting empty each time.
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry"))['apple'] ['apple', 'banana'] ['apple', 'banana', 'cherry']
Each call appended to the same list, so it grew. The fix is to default to None and build a fresh list inside the function.
def add_item(item, basket=None):
if basket is None: # a fresh list when none is given
basket = []
basket.append(item)
return basket
first = add_item("apple")
second = add_item("banana")
print(first, second)['apple'] ['banana']
Each call with no basket now started from an empty list, so first is ['apple'] and second is ['banana'], with nothing carried between them. The is None check is the right use of is from the first section.
is vs ==
is | == | |
|---|---|---|
| Compares | identity, the same object | value, equal contents |
| Two equal lists | False, separate objects | True |
| Keep it for | checking against None | comparing values |
Where these gotchas show up in AI code
- Checking a parsed field against
Nonewithis, and comparing two payloads for equality with==. - Copying a list of chat messages before editing one, with
copy.deepcopy, so the stored conversation is left untouched. - A function that collects results into a default list and surprises you by keeping the previous call's results.
def f(x, acc=[]) is made once and shared by every call, so results leak between calls and the bug shows up far from its cause. Default to None and build the list inside. The same holds for a default dict or set.Related
- Previous: Decorators
- Next: Type hints
- Reference: copy and comparisons in the Python docs
- Change
list(messages)tocopy.deepcopy(messages)and confirm the originalmessagesis left unchanged. - Call the
basket=[]version four times and watch the list keep every item; then switch to theNoneversion. - Make two lists with the same contents and print
a is b(False) anda == b(True); then setc = aand printc is a(True).
Little by little, you're building something great.