Common Python Pitfalls
Even experienced Python developers get bitten by these gotchas. This guide covers the most common pitfalls, explains why they happen, and shows you how to fix them.
Mutable Default Arguments
This is the single most common Python gotcha. When you use a mutable object (like a list or dictionary) as a default argument, that object is shared across all calls to the function.
# BUG: The default list is shared between all calls!
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] — Wait, what?!
The default value [] is created once when the function is defined, not each time it is called. Every call that uses the default shares the same list object.
The fix: Use None as the default and create a new list inside the function.
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Click "Run" to execute your codeLate Binding Closures in Loops
When you create closures (functions that reference variables from an enclosing scope) inside a loop, all closures share the same variable. By the time the closures execute, the loop variable has its final value.
# BUG: All functions return 4 (the last value of i)
functions = []
for i in range(5):
functions.append(lambda: i)
print([f() for f in functions]) # [4, 4, 4, 4, 4]
The fix: Capture the variable's current value using a default argument.
functions = []
for i in range(5):
functions.append(lambda i=i: i) # i=i captures the current value
print([f() for f in functions]) # [0, 1, 2, 3, 4]
Click "Run" to execute your codeModifying a List While Iterating
Changing a list while you are looping over it leads to skipped elements and confusing behavior.
# BUG: Skips elements!
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers:
if num % 2 == 0:
numbers.remove(num)
# Result: [1, 3, 5, 6] — 6 was skipped!
The fix: Iterate over a copy, or use a list comprehension to create a new list.
# Fix 1: Iterate over a copy
for num in numbers[:]: # [:] creates a shallow copy
if num % 2 == 0:
numbers.remove(num)
# Fix 2: List comprehension (preferred)
numbers = [num for num in numbers if num % 2 != 0]
Click "Run" to execute your codeis vs == (Identity vs Equality)
== checks whether two objects have the same value. is checks whether they are the same object in memory. These are not the same thing.
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — same value
a is b # False — different objects!
a = b
a is b # True — now they point to the same object
The rule: Use == for value comparison. Only use is for checking None, True, and False:
# Good
if x is None:
...
if x is not None:
...
# Bad — don't use is for value comparison
if x is 42: # Don't do this!
...
Click "Run" to execute your codeInteger Caching Surprises
CPython caches small integers (typically -5 to 256) for performance. This means is comparisons on small numbers may accidentally work, giving you a false sense of security.
a = 256
b = 256
a is b # True — cached!
a = 257
b = 257
a is b # False — not cached (in most contexts)
This behavior is an implementation detail that varies across Python versions and implementations. Never use is for number comparisons.
Click "Run" to execute your codeCircular Imports
Circular imports happen when module A imports module B, and module B imports module A. Python handles this by partially loading modules, which can lead to ImportError or AttributeError.
# module_a.py
from module_b import helper_b
def helper_a():
return "A"
# module_b.py
from module_a import helper_a # ImportError or AttributeError!
def helper_b():
return "B"
Fixes:
- Restructure your code to remove the circular dependency (best solution)
- Move imports inside functions so they happen at call time, not import time
- Use a third module to hold shared code
# Fix: Import inside the function
# module_b.py
def helper_b():
from module_a import helper_a # Imported when called, not when loaded
return "B"
Bare except: Catching Too Much
A bare except: (or except Exception:) catches more than you think, including KeyboardInterrupt and SystemExit, which prevents users from stopping your program with Ctrl+C.
# BAD: Catches EVERYTHING, even Ctrl+C
try:
do_something()
except:
pass # Silently swallows all errors
# BAD: Still too broad
try:
do_something()
except Exception:
pass # At least doesn't catch KeyboardInterrupt
# GOOD: Catch specific exceptions
try:
value = int(user_input)
except ValueError:
print("Please enter a valid number")
Click "Run" to execute your codeForgetting self in Methods
When defining a method in a class, the first parameter must be self (for instance methods). Forgetting it leads to confusing errors.
# BUG: Missing self
class Calculator:
def add(a, b): # Oops! Missing self
return a + b
calc = Calculator()
calc.add(2, 3) # TypeError: add() takes 2 positional arguments but 3 were given
The error message is confusing because Python automatically passes the instance as the first argument. So calc.add(2, 3) actually calls add(calc, 2, 3).
# FIX: Include self
class Calculator:
def add(self, a, b):
return a + b
Click "Run" to execute your codeVariable Scope Gotchas (UnboundLocalError)
If you assign to a variable inside a function, Python treats it as a local variable for the entire function -- even before the assignment. This can cause UnboundLocalError.
x = 10
def print_x():
print(x) # Works fine — reads the global x
def modify_x():
print(x) # UnboundLocalError! Python sees the assignment below
x = 20 # This makes x local for the ENTIRE function
# Fix: Use the global keyword (or better, avoid globals)
def modify_x():
global x
print(x)
x = 20
Click "Run" to execute your codeOOP Pitfalls
Object-oriented programming in Python has its own set of traps. These are especially dangerous because they often produce code that works initially but breaks in subtle ways as the codebase grows.
The Fragile Base Class Problem
When you inherit from a class, you create a tight coupling. Changes to the base class can silently break subclasses. This is the fragile base class problem.
class BaseList:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def add_many(self, items):
for item in items:
self.add(item) # Calls self.add()
class CountingList(BaseList):
def __init__(self):
super().__init__()
self.add_count = 0
def add(self, item):
self.add_count += 1
super().add(item)
# Seems fine, but...
cl = CountingList()
cl.add_many([1, 2, 3])
# add_count is 3 — correct! But only because BaseList.add_many calls self.add()
# If BaseList changes to use self.items.append() directly, our count breaks.
Click "Run" to execute your codeDiamond Inheritance Confusion
Multiple inheritance with a shared ancestor (the diamond pattern) creates confusion about which methods get called and in what order.
class A:
def __init__(self):
print("A.__init__")
self.value = "A"
class B(A):
def __init__(self):
print("B.__init__")
super().__init__()
self.value = "B"
class C(A):
def __init__(self):
print("C.__init__")
super().__init__()
self.value = "C"
class D(B, C):
def __init__(self):
print("D.__init__")
super().__init__()
# What is D().value? The answer depends on the MRO.
Click "Run" to execute your codeOverusing Inheritance
A common mistake is reaching for inheritance when composition would be simpler and more flexible.
# Overuse of inheritance
class DatabaseConnection:
...
class UserRepository(DatabaseConnection): # Is UserRepository a database connection? No!
...
# Better: composition
class UserRepository:
def __init__(self, db: DatabaseConnection):
self.db = db
Ask yourself: "Is my subclass truly a kind of the parent class?" If not, use composition.
super() Gotchas with Multiple Inheritance
super() does not call the parent class -- it calls the next class in the MRO. This is a critical distinction with multiple inheritance.
class A:
def method(self):
print("A.method")
class B(A):
def method(self):
print("B.method")
super().method() # Calls C.method, NOT A.method!
class C(A):
def method(self):
print("C.method")
super().method() # Calls A.method
class D(B, C):
def method(self):
print("D.method")
super().method()
# D.method -> B.method -> C.method -> A.method
Click "Run" to execute your codeForgetting super().__init__() in Diamond Hierarchies
When using multiple inheritance, every __init__ in the chain must call super().__init__(). If even one class forgets, part of the hierarchy goes uninitialized.
class A:
def __init__(self):
self.a_value = "initialized"
class B(A):
def __init__(self):
super().__init__() # MUST call super()
self.b_value = "initialized"
class C(A):
def __init__(self):
# BUG: Forgot super().__init__()!
self.c_value = "initialized"
class D(B, C):
def __init__(self):
super().__init__()
# D() -> B.__init__ -> C.__init__ (no super!) -> A.__init__ never called!
Click "Run" to execute your codeQuick Reference: Pitfall Checklist
| Pitfall | Symptom | Fix |
|---|---|---|
| Mutable default args | Data bleeds between calls | Use None as default |
| Late binding closures | All closures return same value | Capture with i=i |
| Modify list while iterating | Skipped or missing elements | Iterate over copy or use comprehension |
is vs == |
False negatives on value comparison | Use == for values, is for None |
| Integer caching | is works for small numbers only |
Always use == for numbers |
Bare except: |
Swallows KeyboardInterrupt |
Catch specific exceptions |
Forgetting self |
Confusing TypeError messages |
Always include self as first param |
UnboundLocalError |
Variable seems to vanish | Don't mix reading/assigning globals in functions |
| Fragile base class | Subclass breaks when parent changes | Prefer composition |
| Diamond inheritance | Wrong method called, missing init | Understand MRO, always call super().__init__() |
What's Next?
Learn how to handle errors gracefully in Error Handling Best Practices.