HelpWithWebGet Help Now
← Back to Blog
Python8 min read

Python Gotchas: Mutable Defaults, `is` vs `==`, and Surprises

The mutable default argument trap, integer caching, late binding in closures, slicing edge cases, and the other Python gotchas that catch experienced devs.

ByDino Bartolome
Laptop screen displaying colorful code with syntax highlighting
Photo by Mohammad Rahmani on Unsplash

Python is designed to be readable and explicit, but a handful of subtle behaviors trip up even experienced devs. Most of these come from how Python handles object identity, mutability, and binding — concepts you don't think about until they bite you.

1. The Mutable Default Argument Trap

This is the most famous Python gotcha:

```python def add_item(item, items=[]): items.append(item) return items

print(add_item("apple")) # ['apple'] print(add_item("banana")) # ['apple', 'banana'] ← surprises beginners print(add_item("cherry")) # ['apple', 'banana', 'cherry'] ```

The default [] is evaluated once when the function is defined, not each time it's called. The same list object is reused on every call.

The fix:

``python def add_item(item, items=None): if items is None: items = [] items.append(item) return items ``

Same gotcha applies to {}, set(), and any other mutable default.

2. is vs ==

  • == checks value equality
  • is checks identity (same object in memory)

```python a = [1, 2, 3] b = [1, 2, 3] a == b # True (same values) a is b # False (different objects)

c = a a is c # True (same object) ```

CPython caches small integers (-5 to 256) and some short strings, so identity checks on them give surprising results:

```python a = 256 b = 256 a is b # True (cached)

a = 257 b = 257 a is b # False (not cached, different objects) ```

Use == for value comparison. Reserve is for None, True, False checks (which are singletons).

3. Late binding closures

``python funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2] ← not [0, 1, 2] ``

The lambdas capture i by reference, and by the time they're called i is 2 (the last value).

The fix — use a default argument to bind eagerly:

``python funcs = [lambda i=i: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2] ``

4. Integer division surprises

``python 5 / 2 # 2.5 (true division — float, always) 5 // 2 # 2 (floor division) -5 // 2 # -3 (floors toward negative infinity, not zero!) -5 % 2 # 1 (sign follows divisor) ``

If you want Python 2-style int division, use //. The negative-floor behavior is especially surprising for devs coming from C/Java/JavaScript where -5 / 2 = -2 (truncation toward zero).

5. Slicing is forgiving — and that's both good and bad

``python s = "hello" s[10:20] # '' (no IndexError, just empty) s[::-1] # 'olleh' (reverse trick) s[1:] # 'ello' s[:-1] # 'hell' s[::2] # 'hlo' (every other char) ``

But indexing isn't:

``python s[10] # IndexError: string index out of range ``

Slicing never raises; indexing does. Be careful when using user input as an index.

6. Shallow copy vs deep copy

```python import copy

original = [[1, 2], [3, 4]] shallow = copy.copy(original) shallow[0][0] = 99 print(original) # [[99, 2], [3, 4]] ← mutated! print(shallow) # [[99, 2], [3, 4]]

deep = copy.deepcopy(original) deep[0][0] = 0 print(original) # [[99, 2], [3, 4]] unchanged ```

list(...), dict(...), obj.copy(), and slicing (lst[:]) all do shallow copies. Nested mutable objects are shared.

7. List comprehension scoping

In Python 3, list comprehension variables don't leak (good!):

``python [x for x in range(5)] print(x) # NameError in Python 3 (was 4 in Python 2) ``

But generator expressions hold references that can surprise you:

``python x = 10 gen = (x for _ in range(3)) x = 20 print(list(gen)) # [20, 20, 20] ← not [10, 10, 10] ``

The generator captures x by reference, evaluated when list() consumes it.

8. Truthiness rules

Falsy values in Python:

`` False, None, 0, 0.0, 0j, '', [], (), {}, set(), range(0) ``

Plus any object whose __bool__() returns False or __len__() returns 0.

``python if some_list: # True if non-empty if some_string: # True if non-empty if some_dict: # True if non-empty ``

Don't use if x == None: — use if x is None: (faster and clearer).

9. range() doesn't make a list

``python r = range(10) type(r) # <class 'range'> r[5] # 5 (supports indexing) 5 in r # True (efficient membership) list(r) # [0, 1, ..., 9] (to materialize) ``

range is a lazy iterable. You can index it, but it doesn't allocate the full list.

10. += on lists vs strings vs tuples

```python lst = [1, 2] lst += [3, 4] # Modifies lst in place: [1, 2, 3, 4]

s = "hello" s += " world" # Strings are immutable — creates a new string # Repeated string concat in a loop is O(n²) — use .join() instead

t = (1, 2) t += (3, 4) # Tuples are immutable — creates a new tuple ```

For large concatenation jobs, "".join(parts) or io.StringIO is dramatically faster than s += x.

11. Dictionaries preserve insertion order (since 3.7)

``python d = {"a": 1, "b": 2, "c": 3} list(d.keys()) # ['a', 'b', 'c'] — guaranteed since Python 3.7 ``

If you read older code that uses collections.OrderedDict, it's now mostly redundant. OrderedDict still differs from dict for equality (OrderedDict compares order; dict doesn't).

Need help untangling a Python codebase?

We've debugged production Python systems that were brought down by exactly these gotchas. If you've got a flaky service, a memory leak, or just a confusing legacy codebase, get in touch.

Need Help With Your Website?

I fix these problems every day. Send me a message and I'll take a look.

Get Help Now