Avoid Common Pitfalls
Problem
Python’s flexibility and dynamic typing create traps that catch even experienced developers. These pitfalls stem from Python’s design choices and differ significantly from patterns in statically-typed languages.
This guide shows how to recognize and prevent the most common Python pitfalls with working examples.
Mutable Default Arguments Trap
Python evaluates default arguments once at function definition time, not at call time. Mutable defaults create shared state across calls.
The Trap
def add_user(user, users=[]):
users.append(user)
return users
print(add_user("Alice")) # ['Alice']
print(add_user("Bob")) # ['Alice', 'Bob'] - Unexpected!
print(add_user("Charlie")) # ['Alice', 'Bob', 'Charlie']The Fix
def add_user(user, users=None):
if users is None:
users = []
users.append(user)
return users
print(add_user("Alice")) # ['Alice']
print(add_user("Bob")) # ['Bob'] - Fresh list
print(add_user("Charlie")) # ['Charlie']
def add_user(user, users=None):
users = users if users is not None else []
users.append(user)
return users
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list)
team1 = Team("Engineering")
team2 = Team("Design")
team1.members.append("Alice")
print(team2.members) # [] - Separate listsWhy It Happens
def show_default(items=[]):
print(f"ID: {id(items)}, Contents: {items}")
items.append("new")
show_default() # ID: 140234567, Contents: []
show_default() # ID: 140234567, Contents: ['new'] - Same object!
show_default() # ID: 140234567, Contents: ['new', 'new']Late Binding Closure Gotcha
Python closures bind to variables, not values. In loops, this creates unexpected behavior.
The Trap
def create_multipliers():
funcs = []
for i in range(5):
funcs.append(lambda x: x * i)
return funcs
multipliers = create_multipliers()
print(multipliers[0](2)) # Expected: 0, Actual: 8
print(multipliers[1](2)) # Expected: 2, Actual: 8
print(multipliers[2](2)) # Expected: 4, Actual: 8The Fix
def create_multipliers():
funcs = []
for i in range(5):
funcs.append(lambda x, i=i: x * i) # Capture current i
return funcs
multipliers = create_multipliers()
print(multipliers[0](2)) # 0
print(multipliers[1](2)) # 2
print(multipliers[2](2)) # 4
from functools import partial
def multiply(x, i):
return x * i
def create_multipliers():
return [partial(multiply, i=i) for i in range(5)]
def create_multipliers():
return [lambda x, i=i: x * i for i in range(5)]Why It Happens
x = 10
f = lambda: x
x = 20
print(f()) # 20, not 10Name Shadowing Built-ins
Python allows shadowing built-in names, creating confusing bugs.
The Trap
def process_data():
list = [1, 2, 3] # Shadows built-in list!
# Later in function...
numbers = list(range(10)) # TypeError: 'list' object is not callable
for sum in [1, 2, 3]: # Shadows built-in sum!
print(sum)
total = sum([1, 2, 3]) # TypeError!The Fix
def process_data():
items = [1, 2, 3] # Clear, doesn't shadow
numbers = list(range(10)) # Works!
for value in [1, 2, 3]:
print(value)
total = sum([1, 2, 3]) # Works!
import builtins
name = "list"
print(hasattr(builtins, name)) # True - it's a built-in!Why It Happens
Iterator Exhaustion
Iterators can only be consumed once. Reusing them yields nothing.
The Trap
numbers = (x ** 2 for x in range(5)) # Generator expression
print(list(numbers)) # [0, 1, 4, 9, 16]
print(list(numbers)) # [] - Iterator exhausted!
with open('data.txt') as f:
lines = f # File object is an iterator
print(len(list(lines))) # Works
print(len(list(lines))) # 0 - Exhausted!The Fix
numbers = [x ** 2 for x in range(5)] # List, not generator
print(numbers) # [0, 1, 4, 9, 16]
print(numbers) # [0, 1, 4, 9, 16] - Still available
def get_numbers():
return (x ** 2 for x in range(5))
numbers1 = get_numbers()
numbers2 = get_numbers()
print(list(numbers1)) # [0, 1, 4, 9, 16]
print(list(numbers2)) # [0, 1, 4, 9, 16]
with open('data.txt') as f:
lines = list(f) # Load into list
print(len(lines)) # Works
print(len(lines)) # Still works
from itertools import tee
numbers = (x ** 2 for x in range(5))
iter1, iter2 = tee(numbers, 2)
print(list(iter1)) # [0, 1, 4, 9, 16]
print(list(iter2)) # [0, 1, 4, 9, 16]Why It Happens
gen = (x for x in range(3))
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # StopIteration - No more itemsDictionary KeyError Handling
Accessing non-existent keys raises KeyError. Several patterns handle this safely.
The Trap
user_data = {"name": "Alice", "age": 30}
email = user_data["email"] # KeyError: 'email'
if "email" in user_data:
email = user_data["email"]
else:
email = NoneThe Fix
email = user_data.get("email") # None if missing
email = user_data.get("email", "no-email@example.com") # Custom default
from collections import defaultdict
user_posts = defaultdict(list)
user_posts["alice"].append("Post 1") # No KeyError
user_posts["alice"].append("Post 2")
print(user_posts["bob"]) # [] - Auto-initialized
settings = {}
log_level = settings.setdefault("log_level", "INFO") # Returns and sets default
print(settings) # {"log_level": "INFO"}
try:
email = user_data["email"]
except KeyError:
email = None # Or raise custom error with contextWhen to Use Each
email = config.get("email", "default@example.com")
from collections import defaultdict
word_counts = defaultdict(int)
for word in words:
word_counts[word] += 1 # No KeyError even for new words
cache = {}
result = cache.setdefault(key, expensive_computation())
try:
value = config["required_key"]
except KeyError:
raise ConfigurationError("Missing required_key in config")List/String Mutability Confusions
Lists are mutable, strings are immutable. This creates different behavior patterns.
The Trap
text = "hello"
text.upper() # Creates new string, doesn't modify text
print(text) # Still "hello"
def add_item(item, items):
items.append(item) # Modifies original list!
return items
original = [1, 2, 3]
result = add_item(4, original)
print(original) # [1, 2, 3, 4] - Modified!
list1 = [1, 2, 3]
list2 = list1 # Same object, not a copy
list2.append(4)
print(list1) # [1, 2, 3, 4] - Also changed!The Fix
text = "hello"
text = text.upper() # Assign result
print(text) # "HELLO"
def add_item(item, items):
result = items.copy() # or items[:]
result.append(item)
return result
original = [1, 2, 3]
result = add_item(4, original)
print(original) # [1, 2, 3] - Unchanged
print(result) # [1, 2, 3, 4]
import copy
nested = [[1, 2], [3, 4]]
shallow = nested.copy() # Copies outer list only
deep = copy.deepcopy(nested) # Copies everything
nested[0].append(99)
print(shallow) # [[1, 2, 99], [3, 4]] - Inner lists shared
print(deep) # [[1, 2], [3, 4]] - Completely separate
def add_item_inplace(item, items):
"""Mutates items in place."""
items.append(item)
def add_item_copy(item, items):
"""Returns new list, original unchanged."""
return items + [item]Integer Caching Surprises
Python caches small integers (-5 to 256). Identity checks can surprise.
The Trap
a = 256
b = 256
print(a is b) # True - Cached
a = 257
b = 257
print(a is b) # False - Not cached!
x = 1000
y = 1000
if x is y: # False - Don't use 'is' for numbers!
print("Equal")The Fix
a = 257
b = 257
print(a == b) # True - Correct comparison
x = None
if x is None: # Correct - Testing identity
print("x is None")
y = []
z = y
if y is z: # Correct - Testing if same object
print("Same object")
print(id(10) == id(10)) # True
print(id(300) == id(300)) # May be FalseWhy It Matters
Summary
Python’s pitfalls mostly stem from its dynamic nature and design choices that prioritize flexibility. Mutable default arguments create shared state because defaults are evaluated once at function definition time - use None as a sentinel and initialize inside the function instead. Late binding in closures means loop variables are looked up when functions execute, not when they’re created - capture values as default arguments or use comprehensions that create new scopes.
Shadowing built-in names creates confusing bugs because Python’s namespace lookup puts local variables before built-ins. Use descriptive names and configure linters to warn about shadowing. Iterators and generators can only be consumed once - convert to lists if you need multiple passes, or re-create the iterator for each use.
Dictionary access with square brackets raises KeyError for missing keys. The get() method provides safe access with defaults, defaultdict automatically initializes missing keys, and setdefault initializes and returns in one operation. Choose based on your usage pattern - get() for simple defaults, defaultdict for repeated insertions, try/except when missing keys represent errors.
Lists mutate in place while strings are immutable and return new values. This difference creates aliasing issues where multiple variables point to the same mutable list. Create copies explicitly when you need to avoid mutation, and use deepcopy for nested structures. Integer caching in the range -5 to 256 makes identity checks with ‘is’ unreliable - always use == for value comparison and reserve ‘is’ for None checks and intentional identity tests.
These pitfalls share a pattern: behavior that makes sense given Python’s design but differs from other languages or intuition. Understanding why they occur helps you avoid them and debug issues when they arise. Configure linters to catch common mistakes, use type hints for better tooling support, and follow Python’s idioms rather than importing patterns from other languages.